| import unittest |
| from unittest.mock import patch |
|
|
| from app import ( |
| LOG_HEADERS, |
| build_preview, |
| parse_check_in, |
| persist_logged_sets, |
| render_current_hero, |
| reset_check_in_conversation, |
| save_completed_session, |
| send_check_in_message, |
| ) |
| from training_coach.models import ( |
| CheckIn, |
| CompletedSession, |
| CompletedSet, |
| ContextSignal, |
| PainIssue, |
| ParsedCheckIn, |
| ) |
|
|
|
|
| class AppTest(unittest.TestCase): |
| def test_parse_check_in_requires_text(self): |
| result = parse_check_in("") |
|
|
| self.assertEqual(result[-1], "Write a check-in first.") |
|
|
| def test_parse_check_in_populates_fields_and_summary(self): |
| parsed = ParsedCheckIn( |
| check_in=CheckIn( |
| raw_text="45 min, terrible sleep, right tricep hurts", |
| time_available_minutes=45, |
| energy_level="low", |
| sleep_quality="poor", |
| sleep_hours=None, |
| soreness="right tricep hurts", |
| pain_or_injury="yes", |
| pain_issues=[ |
| PainIssue( |
| affected_muscle="triceps_brachii", |
| severity="moderate", |
| notes="Right tricep hurts.", |
| ) |
| ], |
| mood_stress="stressed", |
| ), |
| follow_up_questions=["How many hours did you sleep?"], |
| context_signals=[ |
| ContextSignal( |
| label="recent_endurance_run", |
| evidence="Yesterday I ran.", |
| follow_up_question="How are your legs?", |
| ) |
| ], |
| ) |
|
|
| with patch("app.parse_check_in_with_configured_backend", return_value=parsed): |
| result = parse_check_in("45 min, terrible sleep, right tricep hurts") |
|
|
| self.assertEqual(result[0], 45) |
| self.assertEqual(result[1], "low") |
| self.assertEqual(result[2], "poor") |
| self.assertEqual(result[5], "yes") |
| self.assertEqual(result[6], "stressed") |
| self.assertEqual(result[7][0]["affected_muscle"], "triceps_brachii") |
| self.assertNotIn("How many hours", result[8]) |
| self.assertIn("recent_endurance_run", result[8]) |
| self.assertIn("triceps_brachii", result[8]) |
|
|
| def test_parse_check_in_reports_parser_failure(self): |
| with patch("app.parse_check_in_with_configured_backend", side_effect=RuntimeError("no ollama")): |
| result = parse_check_in("45 min") |
|
|
| self.assertIn("Parser failed: no ollama", result[-1]) |
|
|
| def test_send_check_in_message_appends_user_text_and_updates_chat(self): |
| parsed = ParsedCheckIn( |
| check_in=CheckIn( |
| raw_text="45 min today\nI slept 5 hours", |
| time_available_minutes=45, |
| sleep_quality="okay", |
| sleep_hours=5, |
| pain_or_injury="no", |
| ) |
| ) |
|
|
| with patch("app.parse_check_in_with_configured_backend", return_value=parsed) as parse: |
| result = send_check_in_message( |
| "I slept 5 hours", |
| [{"role": "user", "content": "45 min today"}], |
| "45 min today", |
| ) |
|
|
| self.assertEqual(result[0], "") |
| self.assertEqual(result[2], "45 min today\nI slept 5 hours") |
| self.assertEqual(result[3], 45) |
| self.assertEqual(result[6], 5) |
| self.assertEqual(result[1][-2]["role"], "user") |
| self.assertEqual(result[1][-1]["role"], "assistant") |
| parse.assert_called_once_with("45 min today\nI slept 5 hours") |
|
|
| def test_send_check_in_message_puts_follow_up_questions_only_in_chat(self): |
| parsed = ParsedCheckIn( |
| check_in=CheckIn( |
| raw_text="I feel tired.", |
| energy_level="low", |
| pain_or_injury="unsure", |
| ), |
| follow_up_questions=["How many minutes do you have today?"], |
| ) |
|
|
| with patch("app.parse_check_in_with_configured_backend", return_value=parsed): |
| result = send_check_in_message("I feel tired.", [], "") |
|
|
| self.assertIn("How many minutes", result[1][-1]["content"]) |
| self.assertNotIn("How many minutes", result[-1]) |
| self.assertEqual(result[-1], "No extra parser notes.") |
|
|
| def test_send_check_in_message_preserves_previous_pain_issues(self): |
| previous_pain_issues = [ |
| PainIssue( |
| affected_muscle="biceps_brachii", |
| severity="mild", |
| notes="Bicep is hurting.", |
| ).model_dump(mode="json") |
| ] |
| parsed = ParsedCheckIn( |
| check_in=CheckIn( |
| raw_text="bicep hurts\nand my left leg also\nhamstring, mild but sharp", |
| pain_or_injury="yes", |
| pain_issues=[ |
| PainIssue( |
| affected_muscle="hamstrings", |
| severity="mild", |
| notes="Left hamstring pain, mild but sharp.", |
| ) |
| ], |
| ) |
| ) |
|
|
| with patch("app.parse_check_in_with_configured_backend", return_value=parsed): |
| result = send_check_in_message( |
| "hamstring, mild but sharp", |
| [], |
| "bicep hurts\nand my left leg also", |
| previous_pain_issues, |
| ) |
|
|
| pain_issues = result[10] |
| self.assertEqual( |
| [issue["affected_muscle"] for issue in pain_issues], |
| ["biceps_brachii", "hamstrings"], |
| ) |
| self.assertIn("biceps_brachii", result[-1]) |
| self.assertIn("hamstrings", result[-1]) |
|
|
| def test_parse_check_in_omits_unsure_pain_severity_from_panel(self): |
| parsed = ParsedCheckIn( |
| check_in=CheckIn( |
| raw_text="yes gluteus_maximus not recovered from last training round", |
| pain_or_injury="yes", |
| pain_issues=[ |
| PainIssue( |
| affected_muscle="gluteus_maximus", |
| severity="unsure", |
| notes="gluteus_maximus not recovered from last training round", |
| ) |
| ], |
| ), |
| ) |
|
|
| with patch("app.parse_check_in_with_configured_backend", return_value=parsed): |
| result = parse_check_in( |
| "yes gluteus_maximus not recovered from last training round" |
| ) |
|
|
| self.assertIn( |
| "- gluteus_maximus: gluteus_maximus not recovered", |
| result[-1], |
| ) |
| self.assertNotIn("(unsure)", result[-1]) |
|
|
| def test_reset_check_in_conversation_clears_chat_and_fields(self): |
| result = reset_check_in_conversation() |
|
|
| self.assertEqual(result[0], "") |
| self.assertEqual(result[1], []) |
| self.assertEqual(result[2], "") |
| self.assertEqual(result[3], 60) |
| self.assertEqual(result[-1], "Write a check-in first.") |
|
|
| def test_build_preview_uses_next_day_from_completed_history(self): |
| completed_set = CompletedSet( |
| exercise_id="dumbbell-row", |
| set_number=1, |
| actual_reps=16, |
| actual_load=32.5, |
| ) |
| completed_session = CompletedSession( |
| date="2026-06-11", |
| day_number=1, |
| completed_sets=[completed_set], |
| ) |
|
|
| with patch( |
| "app.history_store.load_completed_sessions", |
| return_value=[completed_session], |
| ): |
| preview, log_rows, day_number = build_preview( |
| check_in="60 min, medium energy", |
| time_minutes=60, |
| energy="medium", |
| sleep="okay", |
| sleep_hours=None, |
| soreness="", |
| pain_or_injury="no", |
| mood="neutral", |
| ) |
|
|
| self.assertEqual(day_number, 2) |
| self.assertIn("Day 2", preview) |
| self.assertIn("Barbell Skullcrusher", preview) |
| self.assertNotIn("Hardcoded session preview", preview) |
| self.assertEqual(log_rows[0][LOG_HEADERS.index("exercise_id")], "barbell-skullcrusher") |
| self.assertEqual(log_rows[0][LOG_HEADERS.index("set_number")], "1") |
| self.assertEqual(log_rows[0][LOG_HEADERS.index("target_reps")], "8 (range 6-10)") |
| self.assertEqual(log_rows[0][LOG_HEADERS.index("actual_reps")], "8") |
| self.assertEqual(log_rows[0][LOG_HEADERS.index("actual_load")], "") |
|
|
| def test_render_current_hero_uses_next_day_from_current_history(self): |
| completed_session = CompletedSession( |
| date="2026-06-11", |
| day_number=2, |
| completed_sets=[ |
| CompletedSet( |
| exercise_id="barbell-skullcrusher", |
| set_number=1, |
| actual_reps=8, |
| actual_load=20, |
| ) |
| ], |
| ) |
|
|
| with patch( |
| "app.history_store.load_completed_sessions", |
| return_value=[completed_session], |
| ): |
| hero_html = render_current_hero() |
|
|
| self.assertIn("Day 3", hero_html) |
|
|
| def test_build_preview_prefills_recommended_load_from_progression(self): |
| completed_set = CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=1, |
| actual_reps=15, |
| actual_load=10, |
| ) |
| completed_sessions = [ |
| CompletedSession( |
| date="2026-06-11", |
| day_number=1, |
| completed_sets=[ |
| completed_set, |
| completed_set.model_copy(update={"set_number": 2}), |
| completed_set.model_copy(update={"set_number": 3}), |
| ], |
| ), |
| CompletedSession( |
| date="2026-06-12", |
| day_number=4, |
| completed_sets=[ |
| CompletedSet( |
| exercise_id="goblet-squat", |
| set_number=1, |
| actual_reps=12, |
| actual_load=10, |
| ) |
| ], |
| ), |
| ] |
|
|
| with patch( |
| "app.history_store.load_completed_sessions", |
| return_value=completed_sessions, |
| ): |
| preview, log_rows, day_number = build_preview( |
| check_in="60 min, medium energy", |
| time_minutes=60, |
| energy="medium", |
| sleep="okay", |
| sleep_hours=None, |
| soreness="", |
| pain_or_injury="no", |
| mood="neutral", |
| ) |
|
|
| self.assertEqual(day_number, 1) |
| self.assertIn("Target load: 11 kg", preview) |
| self.assertEqual(log_rows[0][LOG_HEADERS.index("actual_load")], "11") |
|
|
| def test_build_preview_filters_exercises_for_parsed_pain_issue(self): |
| pain_issues_state = [ |
| { |
| "affected_muscle": "triceps_brachii", |
| "severity": "moderate", |
| "notes": "Right tricep hurts.", |
| } |
| ] |
|
|
| with patch("app.history_store.load_completed_sessions", return_value=[]): |
| preview, log_rows, day_number = build_preview( |
| check_in="Right tricep hurts.", |
| time_minutes=60, |
| energy="medium", |
| sleep="okay", |
| sleep_hours=None, |
| soreness="right tricep hurts", |
| pain_or_injury="yes", |
| mood="neutral", |
| pain_issues_state=pain_issues_state, |
| ) |
|
|
| self.assertEqual(day_number, 1) |
| self.assertNotIn("Barbell Incline Bench Press", preview) |
| self.assertNotIn("Dumbbell Pullover", preview) |
| self.assertIn("Pain filter removed", preview) |
| exercise_ids = [row[LOG_HEADERS.index("exercise_id")] for row in log_rows] |
| self.assertNotIn("barbell-incline-bench-press", exercise_ids) |
|
|
| def test_save_completed_session_requires_completed_sets(self): |
| result = save_completed_session(1, []) |
|
|
| self.assertIn("Add at least one completed set", result) |
|
|
| def test_save_completed_session_appends_minimal_history(self): |
| log_rows = [ |
| ["dumbbell-row", "1", "16", "16", "32.5", "8", "Good set."], |
| ["dumbbell-row", "2", "16", "16", "", "", ""], |
| ] |
|
|
| with patch("app.history_store.append_completed_session") as append_completed: |
| result = save_completed_session(1, log_rows) |
|
|
| self.assertIn("Saved Day 1 with 1 completed sets", result) |
| saved_session = append_completed.call_args.args[0] |
| self.assertEqual(saved_session.day_number, 1) |
| self.assertEqual(len(saved_session.completed_sets), 1) |
| self.assertEqual(saved_session.completed_sets[0].actual_reps, 16) |
| self.assertEqual(saved_session.completed_sets[0].actual_load, 32.5) |
|
|
| def test_persist_logged_sets_reports_same_next_day_as_header(self): |
| planned_rows = [["dumbbell-row", "1", "16", "16", "32.5", "", ""]] |
|
|
| with patch("app.history_store.append_completed_session"), patch( |
| "app._safe_next_day", |
| return_value=3, |
| ): |
| message, hero_html = persist_logged_sets( |
| 2, |
| planned_rows, |
| [16], |
| [32.5], |
| [8], |
| ["Good set."], |
| [True], |
| ) |
|
|
| self.assertIn("Saved Day 2", message) |
| self.assertIn("Next up: Day 3.", message) |
| self.assertIn("Day 3", hero_html) |
|
|
|
|
| if __name__ == "__main__": |
| unittest.main() |
|
|