| from datetime import date |
| import unittest |
|
|
| from training_coach.engine import ( |
| EXERCISE_MUSCLE_MAP, |
| MVP_LOAD_INCREMENT_KG, |
| apply_pain_filter, |
| apply_time_compression, |
| apply_readiness_modifier, |
| apply_double_progression, |
| build_session_for_day, |
| next_day_after, |
| readiness_score, |
| suggest_next_training_day, |
| ) |
| from training_coach.models import CheckIn, CompletedSession, CompletedSet, Muscle, PainIssue |
|
|
|
|
| class EngineTemplateTest(unittest.TestCase): |
| def test_day_1_preserves_exercise_order_and_prescription(self): |
| plan = build_session_for_day( |
| 1, |
| date(2026, 6, 11), |
| CheckIn(raw_text="60 min, medium energy"), |
| ) |
|
|
| self.assertEqual(len(plan.planned_exercises), 5) |
| self.assertEqual(plan.planned_exercises[0].exercise_id, "dumbbell-pullover") |
| self.assertEqual(plan.planned_exercises[0].order, 1) |
| self.assertEqual(plan.planned_exercises[0].rest_seconds, 60) |
| self.assertEqual(len(plan.planned_exercises[0].prescribed_sets), 3) |
| self.assertEqual(plan.planned_exercises[0].prescribed_sets[0].target_reps_low, 11) |
| self.assertEqual(plan.planned_exercises[0].prescribed_sets[0].target_reps_high, 15) |
| self.assertEqual(plan.planned_exercises[0].prescribed_sets[0].target_reps, 13) |
| self.assertEqual(plan.planned_exercises[-1].exercise_id, "goblet-squat") |
| self.assertEqual(len(plan.planned_exercises[-1].prescribed_sets), 4) |
|
|
| def test_day_2_preserves_rep_range_and_notes_with_mvp_rest(self): |
| plan = build_session_for_day(2, date(2026, 6, 11), CheckIn(raw_text="")) |
|
|
| triceps_extension = plan.planned_exercises[2] |
| self.assertEqual(triceps_extension.exercise_id, "barbell-triceps-extension") |
| self.assertEqual(triceps_extension.rest_seconds, 60) |
| self.assertEqual(triceps_extension.prescribed_sets[0].target_reps_low, 10) |
| self.assertEqual(triceps_extension.prescribed_sets[0].target_reps_high, 12) |
| self.assertIn("Lean-away", plan.planned_exercises[1].notes) |
|
|
| def test_day_3_preserves_row_reps_with_mvp_rest(self): |
| plan = build_session_for_day(3, date(2026, 6, 11), CheckIn(raw_text="")) |
|
|
| dumbbell_row = plan.planned_exercises[2] |
| self.assertEqual(dumbbell_row.exercise_id, "dumbbell-row") |
| self.assertEqual(dumbbell_row.rest_seconds, 60) |
| self.assertEqual(dumbbell_row.prescribed_sets[0].target_reps_low, 22) |
| self.assertEqual(dumbbell_row.prescribed_sets[0].target_reps_high, 26) |
| self.assertEqual(dumbbell_row.prescribed_sets[0].target_reps, 24) |
|
|
| def test_day_4_uses_mvp_rest_for_goblet_squat(self): |
| plan = build_session_for_day(4, date(2026, 6, 11), CheckIn(raw_text="")) |
|
|
| goblet_squat = plan.planned_exercises[5] |
| self.assertEqual(goblet_squat.exercise_id, "goblet-squat") |
| self.assertEqual(goblet_squat.rest_seconds, 60) |
|
|
| def test_all_template_exercises_use_one_minute_rest_for_mvp(self): |
| for day_number in range(1, 5): |
| plan = build_session_for_day( |
| day_number, |
| date(2026, 6, 11), |
| CheckIn(raw_text=""), |
| ) |
| self.assertTrue( |
| all(exercise.rest_seconds == 60 for exercise in plan.planned_exercises) |
| ) |
|
|
| def test_rejects_unknown_day(self): |
| with self.assertRaises(ValueError): |
| build_session_for_day(5, date(2026, 6, 11), CheckIn(raw_text="")) |
|
|
| def test_next_day_rotates_through_four_day_template(self): |
| self.assertEqual(next_day_after(1), 2) |
| self.assertEqual(next_day_after(2), 3) |
| self.assertEqual(next_day_after(3), 4) |
| self.assertEqual(next_day_after(4), 1) |
|
|
| def test_next_day_rejects_unknown_day(self): |
| with self.assertRaises(ValueError): |
| next_day_after(0) |
|
|
| def test_suggest_next_training_day_starts_at_day_1_without_history(self): |
| self.assertEqual(suggest_next_training_day([]), 1) |
|
|
| def test_suggest_next_training_day_uses_last_completed_session(self): |
| completed_set = CompletedSet( |
| exercise_id="dumbbell-row", |
| set_number=1, |
| actual_reps=16, |
| actual_load=32.5, |
| ) |
| history = [ |
| CompletedSession( |
| date=date(2026, 6, 10), |
| day_number=1, |
| completed_sets=[completed_set], |
| ), |
| CompletedSession( |
| date=date(2026, 6, 11), |
| day_number=4, |
| completed_sets=[completed_set], |
| ), |
| ] |
|
|
| self.assertEqual(suggest_next_training_day(history), 1) |
|
|
|
|
| class DoubleProgressionTest(unittest.TestCase): |
| def test_mvp_load_increment_is_one_kg(self): |
| self.assertEqual(MVP_LOAD_INCREMENT_KG, 1.0) |
|
|
| def test_no_history_leaves_target_load_empty(self): |
| plan = build_session_for_day(1, date(2026, 6, 11), CheckIn(raw_text="")) |
|
|
| progressed = apply_double_progression(plan.planned_exercises[0], []) |
|
|
| self.assertIsNone(progressed.prescribed_sets[0].target_load) |
|
|
| def test_hitting_top_of_range_on_all_sets_adds_one_kg_and_resets_reps(self): |
| previous_session = CompletedSession( |
| date=date(2026, 6, 10), |
| day_number=1, |
| completed_sets=[ |
| CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=1, |
| actual_reps=15, |
| actual_load=20, |
| ), |
| CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=2, |
| actual_reps=15, |
| actual_load=20, |
| ), |
| CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=3, |
| actual_reps=15, |
| actual_load=20, |
| ), |
| ], |
| ) |
|
|
| plan = build_session_for_day( |
| 1, |
| date(2026, 6, 11), |
| CheckIn(raw_text=""), |
| completed_sessions=[previous_session], |
| ) |
|
|
| target_loads = [ |
| prescribed_set.target_load |
| for prescribed_set in plan.planned_exercises[0].prescribed_sets |
| ] |
| self.assertEqual(target_loads, [21, 21, 21]) |
| target_reps = [ |
| prescribed_set.target_reps |
| for prescribed_set in plan.planned_exercises[0].prescribed_sets |
| ] |
| self.assertEqual(target_reps, [11, 11, 11]) |
| self.assertIn("add 1 kg", plan.planned_exercises[0].notes) |
|
|
| def test_missing_top_of_range_repeats_latest_load_and_adds_two_reps(self): |
| previous_session = CompletedSession( |
| date=date(2026, 6, 10), |
| day_number=1, |
| completed_sets=[ |
| CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=1, |
| actual_reps=13, |
| actual_load=20, |
| ), |
| CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=2, |
| actual_reps=12, |
| actual_load=20, |
| ), |
| CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=3, |
| actual_reps=13, |
| actual_load=20, |
| ), |
| ], |
| ) |
|
|
| plan = build_session_for_day( |
| 1, |
| date(2026, 6, 11), |
| CheckIn(raw_text=""), |
| completed_sessions=[previous_session], |
| ) |
|
|
| target_loads = [ |
| prescribed_set.target_load |
| for prescribed_set in plan.planned_exercises[0].prescribed_sets |
| ] |
| self.assertEqual(target_loads, [20, 20, 20]) |
| target_reps = [ |
| prescribed_set.target_reps |
| for prescribed_set in plan.planned_exercises[0].prescribed_sets |
| ] |
| self.assertEqual(target_reps, [15, 14, 15]) |
| self.assertIn("repeat latest load", plan.planned_exercises[0].notes) |
|
|
| def test_progression_uses_latest_matching_exercise_exposure(self): |
| older_session = CompletedSession( |
| date=date(2026, 6, 9), |
| day_number=1, |
| completed_sets=[ |
| CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=1, |
| actual_reps=13, |
| actual_load=20, |
| ), |
| CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=2, |
| actual_reps=13, |
| actual_load=20, |
| ), |
| CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=3, |
| actual_reps=13, |
| actual_load=20, |
| ), |
| ], |
| ) |
| latest_session = CompletedSession( |
| date=date(2026, 6, 10), |
| day_number=1, |
| completed_sets=[ |
| CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=1, |
| actual_reps=12, |
| actual_load=22, |
| ), |
| CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=2, |
| actual_reps=12, |
| actual_load=22, |
| ), |
| CompletedSet( |
| exercise_id="dumbbell-pullover", |
| set_number=3, |
| actual_reps=12, |
| actual_load=22, |
| ), |
| ], |
| ) |
|
|
| plan = build_session_for_day( |
| 1, |
| date(2026, 6, 11), |
| CheckIn(raw_text=""), |
| completed_sessions=[older_session, latest_session], |
| ) |
|
|
| target_loads = [ |
| prescribed_set.target_load |
| for prescribed_set in plan.planned_exercises[0].prescribed_sets |
| ] |
| self.assertEqual(target_loads, [22, 22, 22]) |
|
|
|
|
| class PainFilterTest(unittest.TestCase): |
| def test_exercise_muscle_map_tags_incline_bench_with_triceps(self): |
| self.assertIn( |
| Muscle.TRICEPS_BRACHII, |
| EXERCISE_MUSCLE_MAP["barbell-incline-bench-press"], |
| ) |
|
|
| def test_triceps_pain_removes_pressing_and_triceps_exercises(self): |
| check_in = CheckIn( |
| raw_text="My tricep hurts.", |
| pain_or_injury="yes", |
| pain_issues=[ |
| PainIssue( |
| affected_muscle=Muscle.TRICEPS_BRACHII, |
| severity="moderate", |
| notes="Tricep hurts.", |
| ) |
| ], |
| ) |
|
|
| plan = build_session_for_day(1, date(2026, 6, 11), check_in) |
| exercise_ids = [exercise.exercise_id for exercise in plan.planned_exercises] |
|
|
| self.assertNotIn("barbell-incline-bench-press", exercise_ids) |
| self.assertNotIn("dumbbell-pullover", exercise_ids) |
| self.assertIn("dumbbell-row", exercise_ids) |
| self.assertIn("Pain filter removed", plan.notes) |
| self.assertEqual([exercise.order for exercise in plan.planned_exercises], [1, 2, 3]) |
|
|
| def test_biceps_pain_removes_bicep_curl_exercises(self): |
| check_in = CheckIn( |
| raw_text="My bicep tore.", |
| pain_or_injury="yes", |
| pain_issues=[ |
| PainIssue( |
| affected_muscle=Muscle.BICEPS_BRACHII, |
| severity="severe", |
| notes="Bicep tore.", |
| ) |
| ], |
| ) |
|
|
| plan = build_session_for_day(4, date(2026, 6, 11), check_in) |
| exercise_ids = [exercise.exercise_id for exercise in plan.planned_exercises] |
|
|
| self.assertNotIn("ez-bar-biceps-curl", exercise_ids) |
| self.assertNotIn("cross-body-hammer-curl", exercise_ids) |
| self.assertIn("dumbbell-lateral-raise", exercise_ids) |
| self.assertIn("Pain filter removed", plan.notes) |
|
|
| def test_manual_soreness_constraints_bicep_tore_filters_bicep_curls(self): |
| check_in = CheckIn( |
| raw_text="", |
| soreness="bicep tore", |
| pain_or_injury="unsure", |
| ) |
|
|
| plan = build_session_for_day(4, date(2026, 6, 11), check_in) |
| exercise_ids = [exercise.exercise_id for exercise in plan.planned_exercises] |
|
|
| self.assertNotIn("ez-bar-biceps-curl", exercise_ids) |
| self.assertNotIn("cross-body-hammer-curl", exercise_ids) |
| self.assertIn("Pain filter removed", plan.notes) |
|
|
| def test_manual_soreness_constraints_calves_pain_filters_calf_raises(self): |
| check_in = CheckIn( |
| raw_text="", |
| soreness="calves are cooked and in pain", |
| pain_or_injury="unsure", |
| ) |
|
|
| plan = build_session_for_day(4, date(2026, 6, 11), check_in) |
| exercise_ids = [exercise.exercise_id for exercise in plan.planned_exercises] |
|
|
| self.assertNotIn("barbell-standing-calf-raise", exercise_ids) |
| self.assertIn("ez-bar-biceps-curl", exercise_ids) |
| self.assertIn("Pain filter removed", plan.notes) |
|
|
| def test_manual_pain_yes_with_bicep_text_filters_bicep_curls(self): |
| check_in = CheckIn( |
| raw_text="", |
| soreness="bicep feels bad", |
| pain_or_injury="yes", |
| ) |
|
|
| plan = build_session_for_day(4, date(2026, 6, 11), check_in) |
| exercise_ids = [exercise.exercise_id for exercise in plan.planned_exercises] |
|
|
| self.assertNotIn("ez-bar-biceps-curl", exercise_ids) |
| self.assertNotIn("cross-body-hammer-curl", exercise_ids) |
|
|
| def test_unclear_or_unsure_pain_does_not_filter_until_clarified(self): |
| check_in = CheckIn( |
| raw_text="My arm feels weird.", |
| pain_or_injury="unsure", |
| pain_issues=[ |
| PainIssue( |
| affected_muscle=Muscle.TRICEPS_BRACHII, |
| severity="unsure", |
| notes="Arm feels weird.", |
| ) |
| ], |
| ) |
| plan = build_session_for_day(1, date(2026, 6, 11), check_in) |
|
|
| exercise_ids = [exercise.exercise_id for exercise in plan.planned_exercises] |
| self.assertIn("barbell-incline-bench-press", exercise_ids) |
|
|
| def test_apply_pain_filter_returns_original_plan_when_no_known_pain_muscle(self): |
| plan = build_session_for_day(1, date(2026, 6, 11), CheckIn(raw_text="")) |
|
|
| filtered, notes = apply_pain_filter(plan.planned_exercises, CheckIn(raw_text="")) |
|
|
| self.assertEqual(len(filtered), len(plan.planned_exercises)) |
| self.assertEqual(notes, []) |
|
|
|
|
| class TimeCompressionTest(unittest.TestCase): |
| def test_sixty_minutes_keeps_full_plan(self): |
| plan = build_session_for_day( |
| 1, |
| date(2026, 6, 11), |
| CheckIn(raw_text="60 min", time_available_minutes=60), |
| ) |
|
|
| self.assertEqual(len(plan.planned_exercises), 5) |
| self.assertEqual( |
| sum(len(exercise.prescribed_sets) for exercise in plan.planned_exercises), |
| 16, |
| ) |
| self.assertNotIn("Time compression", plan.notes) |
|
|
| def test_fifty_minutes_cuts_to_about_seventy_five_percent_sets(self): |
| plan = build_session_for_day( |
| 1, |
| date(2026, 6, 11), |
| CheckIn(raw_text="50 min", time_available_minutes=50), |
| ) |
|
|
| set_counts = [len(exercise.prescribed_sets) for exercise in plan.planned_exercises] |
| self.assertEqual(sum(set_counts), 12) |
| self.assertEqual(set_counts, [3, 3, 2, 2, 2]) |
| self.assertIn("Time compression", plan.notes) |
|
|
| def test_thirty_five_minutes_cuts_to_about_sixty_percent_sets(self): |
| plan = build_session_for_day( |
| 1, |
| date(2026, 6, 11), |
| CheckIn(raw_text="35 min", time_available_minutes=35), |
| ) |
|
|
| set_counts = [len(exercise.prescribed_sets) for exercise in plan.planned_exercises] |
| self.assertEqual(sum(set_counts), 10) |
| self.assertEqual(set_counts[0], 3) |
| self.assertEqual(set_counts[-1], 1) |
| self.assertIn("35 minutes", plan.notes) |
|
|
| def test_under_thirty_minutes_keeps_first_four_exercises_and_caps_sets(self): |
| plan = build_session_for_day( |
| 1, |
| date(2026, 6, 11), |
| CheckIn(raw_text="25 min", time_available_minutes=25), |
| ) |
|
|
| exercise_ids = [exercise.exercise_id for exercise in plan.planned_exercises] |
| self.assertEqual( |
| exercise_ids, |
| [ |
| "dumbbell-pullover", |
| "dumbbell-row", |
| "barbell-incline-bench-press", |
| "dumbbell-incline-chest-fly", |
| ], |
| ) |
| self.assertEqual( |
| sum(len(exercise.prescribed_sets) for exercise in plan.planned_exercises), |
| 8, |
| ) |
| self.assertIn("under 30 minutes", plan.notes) |
|
|
| def test_time_compression_runs_after_pain_filter(self): |
| check_in = CheckIn( |
| raw_text="25 min, tricep hurts", |
| time_available_minutes=25, |
| pain_or_injury="yes", |
| pain_issues=[ |
| PainIssue( |
| affected_muscle=Muscle.TRICEPS_BRACHII, |
| severity="moderate", |
| notes="Tricep hurts.", |
| ) |
| ], |
| ) |
|
|
| plan = build_session_for_day(1, date(2026, 6, 11), check_in) |
| exercise_ids = [exercise.exercise_id for exercise in plan.planned_exercises] |
|
|
| self.assertNotIn("dumbbell-pullover", exercise_ids) |
| self.assertNotIn("barbell-incline-bench-press", exercise_ids) |
| self.assertLessEqual( |
| sum(len(exercise.prescribed_sets) for exercise in plan.planned_exercises), |
| 8, |
| ) |
| self.assertIn("Pain filter removed", plan.notes) |
| self.assertIn("Time compression", plan.notes) |
|
|
| def test_apply_time_compression_can_be_called_directly(self): |
| plan = build_session_for_day(1, date(2026, 6, 11), CheckIn(raw_text="")) |
|
|
| compressed, notes = apply_time_compression( |
| plan.planned_exercises, |
| CheckIn(raw_text="45 min", time_available_minutes=45), |
| ) |
|
|
| self.assertLess( |
| sum(len(exercise.prescribed_sets) for exercise in compressed), |
| sum(len(exercise.prescribed_sets) for exercise in plan.planned_exercises), |
| ) |
| self.assertTrue(notes) |
|
|
|
|
| class ReadinessModifierTest(unittest.TestCase): |
| def test_readiness_score_uses_neutral_defaults(self): |
| score = readiness_score(CheckIn(raw_text="")) |
|
|
| self.assertEqual(score, 3.0) |
|
|
| def test_normal_readiness_sets_base_target_rir_without_cutting_sets(self): |
| plan = build_session_for_day( |
| 1, |
| date(2026, 6, 11), |
| CheckIn( |
| raw_text="60 min, okay sleep, medium energy", |
| time_available_minutes=60, |
| sleep_quality="okay", |
| energy_level="medium", |
| mood_stress="neutral", |
| ), |
| ) |
|
|
| self.assertEqual( |
| sum(len(exercise.prescribed_sets) for exercise in plan.planned_exercises), |
| 16, |
| ) |
| self.assertEqual(plan.planned_exercises[0].prescribed_sets[0].target_rir, 2) |
| self.assertIn("Readiness: normal", plan.notes) |
|
|
| def test_low_readiness_reduces_sets_and_adds_one_rir(self): |
| plan = build_session_for_day( |
| 1, |
| date(2026, 6, 11), |
| CheckIn( |
| raw_text="60 min, okay sleep, low energy", |
| time_available_minutes=60, |
| sleep_quality="okay", |
| sleep_hours=6.5, |
| energy_level="low", |
| mood_stress="neutral", |
| ), |
| ) |
|
|
| self.assertEqual( |
| sum(len(exercise.prescribed_sets) for exercise in plan.planned_exercises), |
| 13, |
| ) |
| self.assertEqual(plan.planned_exercises[0].prescribed_sets[0].target_rir, 3) |
| self.assertIn("Readiness: low", plan.notes) |
|
|
| def test_very_low_readiness_reduces_sets_and_adds_two_rir(self): |
| plan = build_session_for_day( |
| 1, |
| date(2026, 6, 11), |
| CheckIn( |
| raw_text="60 min, poor sleep, low energy, sore and stressed", |
| time_available_minutes=60, |
| sleep_quality="poor", |
| sleep_hours=4.5, |
| energy_level="low", |
| soreness="very sore", |
| mood_stress="stressed", |
| ), |
| ) |
|
|
| self.assertEqual( |
| sum(len(exercise.prescribed_sets) for exercise in plan.planned_exercises), |
| 8, |
| ) |
| self.assertEqual(plan.planned_exercises[0].prescribed_sets[0].target_rir, 4) |
| self.assertIn("Readiness: very low", plan.notes) |
| self.assertIn("Sleep override", plan.notes) |
|
|
| def test_high_readiness_does_not_auto_add_work_for_mvp(self): |
| plan = build_session_for_day( |
| 1, |
| date(2026, 6, 11), |
| CheckIn( |
| raw_text="90 min, good sleep, high energy, ready", |
| time_available_minutes=90, |
| sleep_quality="good", |
| sleep_hours=8, |
| energy_level="high", |
| soreness="no soreness", |
| mood_stress="ready", |
| ), |
| ) |
|
|
| self.assertEqual( |
| sum(len(exercise.prescribed_sets) for exercise in plan.planned_exercises), |
| 16, |
| ) |
| self.assertEqual(plan.planned_exercises[0].prescribed_sets[0].target_rir, 2) |
| self.assertIn("Readiness: high", plan.notes) |
|
|
| def test_apply_readiness_modifier_can_be_called_directly(self): |
| plan = build_session_for_day(1, date(2026, 6, 11), CheckIn(raw_text="")) |
|
|
| modified, notes = apply_readiness_modifier( |
| plan.planned_exercises, |
| CheckIn( |
| raw_text="low energy", |
| sleep_quality="okay", |
| sleep_hours=6.5, |
| energy_level="low", |
| mood_stress="neutral", |
| ), |
| ) |
|
|
| self.assertLess( |
| sum(len(exercise.prescribed_sets) for exercise in modified), |
| sum(len(exercise.prescribed_sets) for exercise in plan.planned_exercises), |
| ) |
| self.assertTrue(notes) |
|
|
|
|
| if __name__ == "__main__": |
| unittest.main() |
|
|