import unittest from pydantic import ValidationError from datetime import date from training_coach.models import ( CheckIn, CompletedSession, CompletedSet, ContextSignal, Exercise, FollowUpQuestion, LoggedExercise, LoggedSet, Muscle, PainIssue, ParsedCheckIn, PlannedExercise, PrescribedSet, SessionLog, SessionPlan, ) class CheckInTest(unittest.TestCase): def test_check_in_accepts_mvp_fields(self): check_in = CheckIn( raw_text="45 min, slept badly, low energy, back is tight", time_available_minutes=45, energy_level="low", sleep_quality="poor", sleep_hours=5.5, soreness="tight back", pain_or_injury="unsure", mood_stress="stressed", notes="Parser should flag the back note for review.", ) self.assertEqual(check_in.time_available_minutes, 45) self.assertEqual(check_in.energy_level, "low") self.assertEqual(check_in.model_dump()["sleep_hours"], 5.5) def test_check_in_rejects_unknown_energy_level(self): with self.assertRaises(ValidationError): CheckIn(energy_level="destroyed") def test_check_in_rejects_negative_time(self): with self.assertRaises(ValidationError): CheckIn(time_available_minutes=-10) def test_check_in_accepts_multiple_pain_issues(self): check_in = CheckIn( raw_text="Right tricep hurts and left hamstring feels tight.", pain_or_injury="yes", pain_issues=[ PainIssue( affected_muscle=Muscle.TRICEPS_BRACHII, severity="moderate", notes="Right tricep hurts.", ), PainIssue( affected_muscle=Muscle.HAMSTRINGS, severity="mild", notes="Left hamstring feels tight.", ), ], ) self.assertEqual(len(check_in.pain_issues), 2) self.assertEqual( check_in.model_dump(mode="json")["pain_issues"][0]["affected_muscle"], "triceps_brachii", ) def test_pain_issue_accepts_unclear_muscle(self): issue = PainIssue( affected_muscle=None, severity="unsure", notes="Arm feels weird, exact location unclear.", ) self.assertIsNone(issue.affected_muscle) def test_pain_issue_rejects_unknown_severity(self): with self.assertRaises(ValidationError): PainIssue( affected_muscle=Muscle.TRICEPS_BRACHII, severity="annoying", notes="Right tricep hurts.", ) def test_pain_issue_requires_notes(self): with self.assertRaises(ValidationError): PainIssue( affected_muscle=Muscle.TRICEPS_BRACHII, severity="mild", notes="", ) class ParsedCheckInTest(unittest.TestCase): def test_parsed_check_in_tracks_missing_details_and_follow_ups(self): parsed = ParsedCheckIn( check_in=CheckIn( raw_text="45 min, terrible night, low energy", time_available_minutes=45, energy_level="low", sleep_quality="poor", ), missing_fields=["sleep_hours"], follow_up_items=[ FollowUpQuestion( field="sleep_hours", question="How many hours did you sleep?", reason="Sleep quality is poor but duration is missing.", ) ], follow_up_questions=["How many hours did you sleep?"], ) self.assertEqual(parsed.check_in.sleep_quality, "poor") self.assertIn("sleep_hours", parsed.missing_fields) self.assertEqual(parsed.follow_up_items[0].field, "sleep_hours") def test_parsed_check_in_tracks_adjacent_context_signals(self): parsed = ParsedCheckIn( check_in=CheckIn(raw_text="Yesterday I ran a 10k."), context_signals=[ ContextSignal( label="recent_endurance_run", evidence="Yesterday I ran a 10k.", follow_up_question=( "How are your legs and energy after yesterday's run?" ), ) ], follow_up_questions=[ "How are your legs and energy after yesterday's run?" ], ) self.assertEqual(parsed.context_signals[0].label, "recent_endurance_run") self.assertIn("10k", parsed.context_signals[0].evidence) def test_context_signal_rejects_unknown_label(self): with self.assertRaises(ValidationError): ContextSignal( label="Recent Activity", evidence="Yesterday I ran a 10k.", ) def test_parsed_check_in_rejects_extra_fields(self): with self.assertRaises(ValidationError): ParsedCheckIn( check_in=CheckIn(raw_text="60 min"), invented_training_action="skip legs", ) class ExerciseTest(unittest.TestCase): def test_exercise_accepts_primary_and_secondary_muscle_groups(self): exercise = Exercise( id="decline-bench-press", name="Decline bench press", primary_muscle_group=Muscle.PECTORALIS_MAJOR, secondary_muscle_groups=[ Muscle.TRICEPS_BRACHII, Muscle.FRONT_DELTOID, ], is_compound=True, rep_range_low=6, rep_range_high=10, ) self.assertEqual(exercise.primary_muscle_group, Muscle.PECTORALIS_MAJOR) self.assertIn(Muscle.TRICEPS_BRACHII, exercise.secondary_muscle_groups) self.assertEqual( exercise.model_dump(mode="json")["secondary_muscle_groups"], ["triceps_brachii", "front_deltoid"], ) def test_exercise_rejects_empty_id(self): with self.assertRaises(ValidationError): Exercise( id="", name="Decline bench press", primary_muscle_group=Muscle.PECTORALIS_MAJOR, is_compound=True, rep_range_low=6, rep_range_high=10, ) def test_exercise_rejects_invalid_rep_range_numbers(self): with self.assertRaises(ValidationError): Exercise( id="decline-bench-press", name="Decline bench press", primary_muscle_group=Muscle.PECTORALIS_MAJOR, is_compound=True, rep_range_low=0, rep_range_high=10, ) def test_exercise_rejects_unknown_muscle(self): with self.assertRaises(ValidationError): Exercise( id="decline-bench-press", name="Decline bench press", primary_muscle_group="chest", is_compound=True, rep_range_low=6, rep_range_high=10, ) def test_exercise_rejects_inverted_rep_range(self): with self.assertRaises(ValidationError): Exercise( id="decline-bench-press", name="Decline bench press", primary_muscle_group=Muscle.PECTORALIS_MAJOR, is_compound=True, rep_range_low=12, rep_range_high=8, ) class SessionPlanTest(unittest.TestCase): def test_session_plan_accepts_ordered_planned_exercises(self): check_in = CheckIn( raw_text="60 min, decent sleep, medium energy", time_available_minutes=60, energy_level="medium", sleep_quality="okay", pain_or_injury="no", mood_stress="neutral", ) first_set = PrescribedSet( set_number=1, target_reps_low=8, target_reps_high=10, target_load=80, target_rir=2, ) planned_exercise = PlannedExercise( exercise_id="decline-bench-press", order=1, prescribed_sets=[first_set], rest_seconds=90, ) plan = SessionPlan( date=date(2026, 6, 10), check_in=check_in, planned_exercises=[planned_exercise], notes="Hardcoded test plan.", ) self.assertEqual(plan.date.isoformat(), "2026-06-10") self.assertEqual(plan.planned_exercises[0].exercise_id, "decline-bench-press") self.assertEqual(plan.planned_exercises[0].rest_seconds, 90) self.assertEqual( plan.model_dump(mode="json")["planned_exercises"][0]["prescribed_sets"][0][ "target_load" ], 80.0, ) def test_prescribed_set_rejects_inverted_target_rep_range(self): with self.assertRaises(ValidationError): PrescribedSet( set_number=1, target_reps_low=12, target_reps_high=8, ) def test_prescribed_set_rejects_target_reps_outside_range(self): with self.assertRaises(ValidationError): PrescribedSet( set_number=1, target_reps_low=8, target_reps_high=12, target_reps=14, ) def test_prescribed_set_rejects_extra_fields(self): with self.assertRaises(ValidationError): PrescribedSet( exercise_id="decline-bench-press", set_number=1, target_reps_low=8, target_reps_high=10, ) def test_planned_exercise_requires_at_least_one_prescribed_set(self): with self.assertRaises(ValidationError): PlannedExercise( exercise_id="decline-bench-press", order=1, prescribed_sets=[], ) def test_session_plan_requires_at_least_one_planned_exercise(self): with self.assertRaises(ValidationError): SessionPlan( date=date(2026, 6, 10), check_in=CheckIn(raw_text="60 min"), planned_exercises=[], ) class SessionLogTest(unittest.TestCase): def test_session_log_accepts_ordered_logged_exercises(self): planned_session = SessionPlan( date=date(2026, 6, 10), check_in=CheckIn(raw_text="60 min", time_available_minutes=60), planned_exercises=[ PlannedExercise( exercise_id="bench-press", order=1, prescribed_sets=[ PrescribedSet( set_number=1, target_reps_low=8, target_reps_high=10, target_load=80, target_rir=2, ) ], ) ], ) logged_set = LoggedSet( set_number=1, actual_reps=9, actual_load=80, rpe=8.5, rest_seconds_before=180, notes="Longer rest than usual.", ) session_log = SessionLog( date=date(2026, 6, 10), planned_session=planned_session, logged_exercises=[ LoggedExercise( exercise_id="bench-press", order=1, logged_sets=[logged_set], ) ], notes="Classic straight sets only.", ) self.assertEqual(session_log.logged_exercises[0].logged_sets[0].rpe, 8.5) self.assertEqual( session_log.model_dump(mode="json")["logged_exercises"][0]["logged_sets"][0][ "rest_seconds_before" ], 180, ) def test_logged_set_rejects_invalid_rpe(self): with self.assertRaises(ValidationError): LoggedSet( set_number=1, actual_reps=9, actual_load=80, rpe=12, ) def test_logged_exercise_requires_at_least_one_logged_set(self): with self.assertRaises(ValidationError): LoggedExercise( exercise_id="bench-press", order=1, logged_sets=[], ) def test_session_log_requires_at_least_one_logged_exercise(self): planned_session = SessionPlan( date=date(2026, 6, 10), check_in=CheckIn(raw_text="60 min"), planned_exercises=[ PlannedExercise( exercise_id="bench-press", order=1, prescribed_sets=[ PrescribedSet( set_number=1, target_reps_low=8, target_reps_high=10, ) ], ) ], ) with self.assertRaises(ValidationError): SessionLog( date=date(2026, 6, 10), planned_session=planned_session, logged_exercises=[], ) class CompletedSessionTest(unittest.TestCase): def test_completed_session_accepts_minimal_mvp_history(self): completed_set = CompletedSet( exercise_id="dumbbell-row", set_number=1, actual_reps=16, actual_load=32.5, rpe=8, ) completed_session = CompletedSession( date=date(2026, 6, 11), day_number=1, completed_sets=[completed_set], notes="Minimal MVP log.", ) self.assertEqual(completed_session.day_number, 1) self.assertEqual(completed_session.completed_sets[0].actual_load, 32.5) self.assertEqual( completed_session.model_dump(mode="json")["completed_sets"][0]["exercise_id"], "dumbbell-row", ) def test_completed_session_rejects_unknown_day_number(self): with self.assertRaises(ValidationError): CompletedSession( date=date(2026, 6, 11), day_number=5, completed_sets=[ CompletedSet( exercise_id="dumbbell-row", set_number=1, actual_reps=16, actual_load=32.5, ) ], ) def test_completed_session_requires_at_least_one_completed_set(self): with self.assertRaises(ValidationError): CompletedSession( date=date(2026, 6, 11), day_number=1, completed_sets=[], ) if __name__ == "__main__": unittest.main()