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()