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