195 lines
10 KiB
Python
195 lines
10 KiB
Python
"""Workout rendering and HTMX lifecycle checks, using an isolated database."""
|
||
import unittest
|
||
from datetime import date
|
||
from types import SimpleNamespace
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
|
||
class WorkoutViewTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
# Import the application without opening a real database connection.
|
||
with patch('psycopg_pool.ConnectionPool'):
|
||
import app
|
||
cls.module = app
|
||
cls.app = app.app
|
||
cls.app.config.update(TESTING=True)
|
||
|
||
def setUp(self):
|
||
import routes.workout as workout
|
||
self.workout = workout
|
||
self.db = MagicMock()
|
||
for module in (self.module, workout):
|
||
patcher = patch.object(module, 'db', self.db)
|
||
patcher.start()
|
||
self.addCleanup(patcher.stop)
|
||
self.user = SimpleNamespace(id=1, name='Peter', is_authenticated=True,
|
||
is_active=True, is_anonymous=False,
|
||
is_admin=False, get_id=lambda: '1')
|
||
loader = patch.object(self.module.login_manager, '_user_callback', lambda _: self.user)
|
||
loader.start()
|
||
self.addCleanup(loader.stop)
|
||
self.client = self.app.test_client()
|
||
with self.client.session_transaction() as session:
|
||
session['_user_id'] = '1'
|
||
session['_fresh'] = True
|
||
self.top_sets = [
|
||
dict(topset_id=i, exercise_id=i, exercise_name=name, repetitions=reps, weight=weight)
|
||
for i, (name, reps, weight) in enumerate([
|
||
('30 degrees Smith Press', 7, 93.5),
|
||
('Seated Smith machine BTN press', 5, 61.5),
|
||
('Smith machine JM press', 12, 62.0),
|
||
('DB Lateral Raises', 20, 10.5),
|
||
('Smith machine Bulgarian split squat', 12, 96.0),
|
||
], 1)
|
||
]
|
||
self.distribution = [
|
||
dict(attribute_name=name, percentage=percent, color=color, count=1)
|
||
for name, percent, color in [('Shoulders', 40, '#477db5'), ('Chest', 20, '#299e9a'),
|
||
('Quads', 20, '#8b75bb'), ('Triceps', 20, '#c78565')]
|
||
]
|
||
self.view_model = dict(person_id=1, workout_id=1, person_name='Peter',
|
||
start_date=date(2026, 9, 5), note='', tags=[], top_sets=self.top_sets,
|
||
muscle_distribution=self.distribution, equipment_distribution=[], movement_distribution=[])
|
||
model = patch.object(workout, '_get_workout_view_model', lambda *args: self.view_model)
|
||
model.start()
|
||
self.addCleanup(model.stop)
|
||
self.db.execute.return_value = self.top_sets
|
||
self.db.get_topset.return_value = self.top_sets[0]
|
||
self.db.get_exercise.return_value = {'name': self.top_sets[0]['exercise_name']}
|
||
self.db.get_all_exercises.return_value = [{'exercise_id': 1, 'name': 'Smith Press'}]
|
||
self.db.create_topset.return_value = 6
|
||
self.db.get_topset_achievements.return_value = dict(is_pr_reps=True, rep_increase=1, weight_increase=0, stalled_sessions=0)
|
||
self.db.exercises.get_workout_attribute_distribution.return_value = self.distribution
|
||
self.base = '/person/1/workout/1'
|
||
|
||
def test_full_page_and_fragment_render(self):
|
||
for headers in ({}, {'HX-Request': 'true'}):
|
||
response = self.client.get(self.base, headers=headers)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertIn(b'workout-layout', response.data)
|
||
self.assertIn(b'Session exercises', response.data)
|
||
self.assertEqual(response.data.count(b'id=extra-target-1'), 1)
|
||
|
||
def test_summary_counts_unique_exercises_and_refreshes(self):
|
||
self.db.execute.return_value = self.top_sets + [self.top_sets[0]]
|
||
response = self.client.get(self.base + '/summary')
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertIn(b'<dt>Exercises<dd>5', response.data)
|
||
self.assertIn(b'<dt>Top sets<dd>6', response.data)
|
||
self.assertIn(b'<dt>Reps<dd>63', response.data)
|
||
self.assertIn(b'topsetChanged from:body', response.data)
|
||
|
||
def test_empty_distribution_keeps_refresh_target(self):
|
||
from flask import render_template
|
||
with self.app.test_request_context():
|
||
html = render_template('partials/workout_breakdown.html', person_id=1, workout_id=1,
|
||
distribution=[], category_name='Muscle Group')
|
||
self.assertIn('id="muscle-group-breakdown"', html)
|
||
self.assertIn('No categorized sets yet.', html)
|
||
self.assertIn('topsetAdded from:body', html)
|
||
|
||
def test_previous_performance_is_loaded_on_expansion(self):
|
||
self.db.get_most_recent_topset_for_exercise.return_value = (7, 93.5, 'Smith Press')
|
||
self.db.get_recent_topsets_for_exercise.return_value = [self.top_sets[0]]
|
||
response = self.client.get(self.base + '/exercise/most_recent_topset_for_exercise?exercise_id=1')
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertIn(b'toggle once from:closest details', response.data)
|
||
self.assertNotIn(b'hx-trigger=load', response.data)
|
||
self.assertIn(b'Previous performance', response.data)
|
||
self.assertNotIn(b'workout-stall-badge', response.data)
|
||
|
||
def test_previous_performance_shows_consecutive_stall_count(self):
|
||
self.db.get_most_recent_topset_for_exercise.return_value = (7, 93.5, 'Smith Press')
|
||
self.db.get_recent_topsets_for_exercise.return_value = [self.top_sets[0]]
|
||
for count, label in ((1, b'Stalled 1 time'), (3, b'Stalled 3 times')):
|
||
self.db.get_topset_achievements.return_value = {'stalled_sessions': count}
|
||
response = self.client.get(self.base + '/exercise/most_recent_topset_for_exercise?exercise_id=1')
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertIn(label, response.data)
|
||
self.assertIn('7 × 93.5 kg', response.get_data(as_text=True))
|
||
self.db.get_topset_achievements.assert_called_with(1)
|
||
|
||
def test_previous_performance_without_history_hides_stall_badge(self):
|
||
self.db.get_most_recent_topset_for_exercise.return_value = (None, None, 'Smith Press')
|
||
response = self.client.get(self.base + '/exercise/most_recent_topset_for_exercise?exercise_id=1')
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertNotIn(b'workout-stall-badge', response.data)
|
||
self.db.get_recent_topsets_for_exercise.assert_not_called()
|
||
|
||
def test_create_edit_cancel_update_and_delete_targets(self):
|
||
data = dict(exercise_id=1, repetitions=8, weight=93.5)
|
||
created = self.client.post(self.base + '/topset', data=data)
|
||
self.assertEqual(created.headers['HX-Trigger'], 'topsetAdded')
|
||
self.assertNotIn(b'hx-swap-oob', created.data)
|
||
for suffix in ('/topset/1/edit_form', '/topset/1'):
|
||
response = self.client.get(self.base + suffix)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertIn(b'hx-swap-oob=outerHTML', response.data)
|
||
updated = self.client.put(self.base + '/topset/1', data=data)
|
||
self.assertEqual(updated.headers['HX-Trigger'], 'topsetChanged')
|
||
self.assertIn(b'hx-swap-oob=outerHTML', updated.data)
|
||
deleted = self.client.delete(self.base + '/topset/1/delete')
|
||
self.assertEqual(deleted.headers['HX-Trigger'], 'topsetChanged')
|
||
self.assertIn(b'hx-swap-oob=delete', deleted.data)
|
||
|
||
def test_summary_requires_owner(self):
|
||
with self.client.session_transaction() as session:
|
||
session.clear()
|
||
response = self.client.get(self.base + '/summary')
|
||
self.assertEqual(response.status_code, 302)
|
||
self.db.execute.assert_not_called()
|
||
|
||
def test_session_note_editor_round_trip_keeps_integrated_card(self):
|
||
import routes.notes as notes
|
||
text = 'Good energy\nKeep <cues> simple'
|
||
with patch.object(notes, 'db', self.db), patch.object(notes, '_fetch_workout_note', return_value=text), patch.object(notes, '_update_workout_note_for_person') as save:
|
||
edit = self.client.get(self.base + '/note/edit?view=session')
|
||
self.assertIn(b'workout-note-editor', edit.data)
|
||
# The HTML minifier can unescape RCDATA inside a textarea.
|
||
self.assertIn(b'Keep <cues> simple</textarea>', edit.data)
|
||
self.assertIn(b'Save note', edit.data)
|
||
saved = self.client.put(self.base + '/note?view=session', data={'note': text})
|
||
save.assert_called_once_with(1, 1, text)
|
||
self.assertIn(b'workout-note-trigger', saved.data)
|
||
self.assertIn(b'Good energy<br>Keep <cues> simple', saved.data)
|
||
self.assertNotIn(b'<textarea', saved.data)
|
||
canceled = self.client.get(self.base + '/note?view=session')
|
||
self.assertIn(b'workout-note-preview', canceled.data)
|
||
legacy = self.client.get(self.base + '/note')
|
||
self.assertNotIn(b'workout-note-card', legacy.data)
|
||
|
||
def test_tags_return_updated_controls_after_select_clear_and_create(self):
|
||
import routes.tags as tags
|
||
tag = dict(tag_id=12, tag_name='Push', is_selected=True)
|
||
|
||
def execute(query, args=(), **kwargs):
|
||
if 'SELECT person_id' in query:
|
||
return {'person_id': 1}
|
||
if 'SELECT tag_id FROM Tag' in query:
|
||
return {'tag_id': 12}
|
||
if 'SELECT DISTINCT' in query:
|
||
return []
|
||
if 'FROM tag' in query:
|
||
return [tag]
|
||
return []
|
||
|
||
self.db.execute.side_effect = execute
|
||
with patch.object(tags, 'db', self.db):
|
||
for selected in ({'tag_id': '12'}, {}):
|
||
tag['is_selected'] = bool(selected)
|
||
response = self.client.post('/tag/workout/1/add?view=session', data=selected)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertIn(b'workout-tag-editor-1', response.data)
|
||
self.assertIn(b'type=checkbox', response.data)
|
||
self.assertEqual(b' checked' in response.data, bool(selected))
|
||
tag['is_selected'] = True
|
||
response = self.client.post('/tag/workout/1/new?view=session', data={'tag_name': 'Push'})
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertIn(b'workout-tag-editor-1', response.data)
|
||
self.assertIn(b' checked', response.data)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|