""" Tests for exercise metadata cleanup management commands. Tests: - fix_rep_durations: fills null estimated_rep_duration using pattern/category lookup - fix_exercise_flags: fixes is_weight false positives and assigns missing muscles - fix_movement_pattern_typo: corrects "horizonal" -> "horizontal" - audit_exercise_data: reports data quality issues, exits 1 on critical """ from django.test import TestCase from django.core.management import call_command from io import StringIO from exercise.models import Exercise from muscle.models import Muscle, ExerciseMuscle class TestFixRepDurations(TestCase): """Tests for the fix_rep_durations management command.""" @classmethod def setUpTestData(cls): # Exercise with null duration and a known movement pattern cls.ex_compound_push = Exercise.objects.create( name='Test Bench Press', estimated_rep_duration=None, is_reps=True, is_duration=False, is_weight=True, movement_patterns='compound_push', ) # Exercise with null duration and a category default pattern cls.ex_upper_pull = Exercise.objects.create( name='Test Barbell Row', estimated_rep_duration=None, is_reps=True, is_duration=False, is_weight=True, movement_patterns='upper pull - horizontal', ) # Duration-only exercise (should be skipped) cls.ex_duration_only = Exercise.objects.create( name='Test Plank Hold', estimated_rep_duration=None, is_reps=False, is_duration=True, is_weight=False, movement_patterns='core - anti-extension', ) # Exercise with no movement patterns (should get DEFAULT_DURATION) cls.ex_no_patterns = Exercise.objects.create( name='Test Mystery Exercise', estimated_rep_duration=None, is_reps=True, is_duration=False, is_weight=False, movement_patterns='', ) # Exercise that already has a duration (should be updated) cls.ex_has_duration = Exercise.objects.create( name='Test Curl', estimated_rep_duration=2.5, is_reps=True, is_duration=False, is_weight=True, movement_patterns='isolation', ) def test_no_null_rep_durations_after_fix(self): """After running fix_rep_durations, no rep-based exercises should have null duration.""" call_command('fix_rep_durations') count = Exercise.objects.filter( estimated_rep_duration__isnull=True, is_reps=True, ).exclude( is_duration=True, is_reps=False ).count() self.assertEqual(count, 0) def test_duration_only_skipped(self): """Duration-only exercises should remain null.""" call_command('fix_rep_durations') self.ex_duration_only.refresh_from_db() self.assertIsNone(self.ex_duration_only.estimated_rep_duration) def test_compound_push_gets_pattern_duration(self): """Exercise with compound_push pattern should get 3.0s.""" call_command('fix_rep_durations') self.ex_compound_push.refresh_from_db() self.assertIsNotNone(self.ex_compound_push.estimated_rep_duration) # Could be from pattern (3.0) or category default -- either is acceptable self.assertGreater(self.ex_compound_push.estimated_rep_duration, 0) def test_no_patterns_gets_default(self): """Exercise with empty movement_patterns should get DEFAULT_DURATION (3.0).""" call_command('fix_rep_durations') self.ex_no_patterns.refresh_from_db() self.assertEqual(self.ex_no_patterns.estimated_rep_duration, 3.0) def test_fixes_idempotent(self): """Running fix_rep_durations twice should produce the same result.""" call_command('fix_rep_durations') # Capture state after first run first_run_vals = { ex.pk: ex.estimated_rep_duration for ex in Exercise.objects.all() } call_command('fix_rep_durations') # Capture state after second run for ex in Exercise.objects.all(): self.assertEqual( ex.estimated_rep_duration, first_run_vals[ex.pk], f'Value changed for {ex.name} on second run' ) def test_dry_run_does_not_modify(self): """Dry run should not change any values.""" out = StringIO() call_command('fix_rep_durations', '--dry-run', stdout=out) self.ex_compound_push.refresh_from_db() self.assertIsNone(self.ex_compound_push.estimated_rep_duration) class TestFixExerciseFlags(TestCase): """Tests for the fix_exercise_flags management command.""" @classmethod def setUpTestData(cls): # Bodyweight exercise incorrectly marked as weighted cls.ex_wall_sit = Exercise.objects.create( name='Wall Sit Hold', estimated_rep_duration=3.0, is_reps=False, is_duration=True, is_weight=True, # false positive movement_patterns='isometric', ) cls.ex_plank = Exercise.objects.create( name='High Plank', estimated_rep_duration=None, is_reps=False, is_duration=True, is_weight=True, # false positive movement_patterns='core', ) cls.ex_burpee = Exercise.objects.create( name='Burpee', estimated_rep_duration=2.0, is_reps=True, is_duration=False, is_weight=True, # false positive movement_patterns='plyometric', ) # Legitimately weighted exercise -- should NOT be changed cls.ex_barbell = Exercise.objects.create( name='Barbell Bench Press', estimated_rep_duration=3.0, is_reps=True, is_duration=False, is_weight=True, movement_patterns='upper push - horizontal', ) # Exercise with no muscles (for muscle assignment test) cls.ex_no_muscle = Exercise.objects.create( name='Chest Press Machine', estimated_rep_duration=2.5, is_reps=True, is_duration=False, is_weight=True, movement_patterns='compound_push', ) # Exercise that already has muscles (should not be affected) cls.ex_with_muscle = Exercise.objects.create( name='Bicep Curl', estimated_rep_duration=2.5, is_reps=True, is_duration=False, is_weight=True, movement_patterns='arms', ) # Create test muscles cls.chest = Muscle.objects.create(name='chest') cls.biceps = Muscle.objects.create(name='biceps') cls.core = Muscle.objects.create(name='core') # Assign muscle to ex_with_muscle ExerciseMuscle.objects.create( exercise=cls.ex_with_muscle, muscle=cls.biceps, ) def test_bodyweight_not_marked_weighted(self): """Bodyweight exercises should have is_weight=False after fix.""" call_command('fix_exercise_flags') self.ex_wall_sit.refresh_from_db() self.assertFalse(self.ex_wall_sit.is_weight) def test_plank_not_marked_weighted(self): """Plank should have is_weight=False after fix.""" call_command('fix_exercise_flags') self.ex_plank.refresh_from_db() self.assertFalse(self.ex_plank.is_weight) def test_burpee_not_marked_weighted(self): """Burpee should have is_weight=False after fix.""" call_command('fix_exercise_flags') self.ex_burpee.refresh_from_db() self.assertFalse(self.ex_burpee.is_weight) def test_weighted_exercise_stays_weighted(self): """Barbell Bench Press should stay is_weight=True.""" call_command('fix_exercise_flags') self.ex_barbell.refresh_from_db() self.assertTrue(self.ex_barbell.is_weight) def test_all_exercises_have_muscles(self): """After fix, exercises that matched keywords should have muscles assigned.""" call_command('fix_exercise_flags') # 'Chest Press Machine' should now have chest muscle orphans = Exercise.objects.exclude( pk__in=ExerciseMuscle.objects.values_list('exercise_id', flat=True) ) self.assertNotIn( self.ex_no_muscle.pk, list(orphans.values_list('pk', flat=True)) ) def test_chest_press_gets_chest_muscle(self): """Chest Press Machine should get the 'chest' muscle assigned.""" call_command('fix_exercise_flags') has_chest = ExerciseMuscle.objects.filter( exercise=self.ex_no_muscle, muscle=self.chest, ).exists() self.assertTrue(has_chest) def test_existing_muscle_assignments_preserved(self): """Exercises that already have muscles should not be affected.""" call_command('fix_exercise_flags') muscle_count = ExerciseMuscle.objects.filter( exercise=self.ex_with_muscle, ).count() self.assertEqual(muscle_count, 1) def test_word_boundary_no_false_match(self): """'l sit' pattern should not match 'wall sit' (word boundary test).""" # Create an exercise named "L Sit" to test word boundary matching l_sit = Exercise.objects.create( name='L Sit Hold', is_reps=False, is_duration=True, is_weight=True, movement_patterns='isometric', ) call_command('fix_exercise_flags') l_sit.refresh_from_db() # L sit is in our bodyweight patterns and has no equipment, so should be fixed self.assertFalse(l_sit.is_weight) def test_fix_idempotent(self): """Running fix_exercise_flags twice should produce the same result.""" call_command('fix_exercise_flags') call_command('fix_exercise_flags') self.ex_wall_sit.refresh_from_db() self.assertFalse(self.ex_wall_sit.is_weight) # Muscle assignments should not duplicate chest_count = ExerciseMuscle.objects.filter( exercise=self.ex_no_muscle, muscle=self.chest, ).count() self.assertEqual(chest_count, 1) def test_dry_run_does_not_modify(self): """Dry run should not change any values.""" out = StringIO() call_command('fix_exercise_flags', '--dry-run', stdout=out) self.ex_wall_sit.refresh_from_db() self.assertTrue(self.ex_wall_sit.is_weight) # should still be True class TestFixMovementPatternTypo(TestCase): """Tests for the fix_movement_pattern_typo management command.""" @classmethod def setUpTestData(cls): cls.ex_typo = Exercise.objects.create( name='Horizontal Row', estimated_rep_duration=3.0, is_reps=True, is_duration=False, movement_patterns='upper pull - horizonal', ) cls.ex_no_typo = Exercise.objects.create( name='Barbell Squat', estimated_rep_duration=4.0, is_reps=True, is_duration=False, movement_patterns='lower push - squat', ) def test_no_horizonal_typo(self): """After fix, no exercises should have 'horizonal' in movement_patterns.""" call_command('fix_movement_pattern_typo') count = Exercise.objects.filter( movement_patterns__icontains='horizonal' ).count() self.assertEqual(count, 0) def test_typo_replaced_with_correct(self): """The typo should be replaced with 'horizontal'.""" call_command('fix_movement_pattern_typo') self.ex_typo.refresh_from_db() self.assertIn('horizontal', self.ex_typo.movement_patterns) self.assertNotIn('horizonal', self.ex_typo.movement_patterns) def test_non_typo_unchanged(self): """Exercises without the typo should not be modified.""" call_command('fix_movement_pattern_typo') self.ex_no_typo.refresh_from_db() self.assertEqual(self.ex_no_typo.movement_patterns, 'lower push - squat') def test_idempotent(self): """Running the fix twice should be safe and produce same result.""" call_command('fix_movement_pattern_typo') call_command('fix_movement_pattern_typo') self.ex_typo.refresh_from_db() self.assertIn('horizontal', self.ex_typo.movement_patterns) self.assertNotIn('horizonal', self.ex_typo.movement_patterns) def test_already_fixed_message(self): """When no typos exist, it should print a 'already fixed' message.""" call_command('fix_movement_pattern_typo') # fix first out = StringIO() call_command('fix_movement_pattern_typo', stdout=out) # run again self.assertIn('already fixed', out.getvalue()) class TestAuditExerciseData(TestCase): """Tests for the audit_exercise_data management command.""" def test_audit_reports_critical_null_duration(self): """Audit should exit 1 when rep-based exercises have null duration.""" Exercise.objects.create( name='Test Bench Press', estimated_rep_duration=None, is_reps=True, is_duration=False, movement_patterns='compound_push', ) out = StringIO() with self.assertRaises(SystemExit) as cm: call_command('audit_exercise_data', stdout=out) self.assertEqual(cm.exception.code, 1) def test_audit_reports_critical_no_muscles(self): """Audit should exit 1 when exercises have no muscle assignments.""" Exercise.objects.create( name='Test Orphan Exercise', estimated_rep_duration=3.0, is_reps=True, is_duration=False, movement_patterns='compound_push', ) out = StringIO() with self.assertRaises(SystemExit) as cm: call_command('audit_exercise_data', stdout=out) self.assertEqual(cm.exception.code, 1) def test_audit_passes_when_clean(self): """Audit should pass (no SystemExit) when no critical issues exist.""" # Create a clean exercise with muscle assignment muscle = Muscle.objects.create(name='chest') ex = Exercise.objects.create( name='Clean Bench Press', estimated_rep_duration=3.0, is_reps=True, is_duration=False, is_weight=True, movement_patterns='upper push - horizontal', ) ExerciseMuscle.objects.create(exercise=ex, muscle=muscle) out = StringIO() # Should not raise SystemExit (no critical issues) call_command('audit_exercise_data', stdout=out) output = out.getvalue() self.assertNotIn('CRITICAL', output) def test_audit_warns_on_typo(self): """Audit should warn (not critical) about horizonal typo.""" muscle = Muscle.objects.create(name='back') ex = Exercise.objects.create( name='Test Row', estimated_rep_duration=3.0, is_reps=True, is_duration=False, movement_patterns='upper pull - horizonal', ) ExerciseMuscle.objects.create(exercise=ex, muscle=muscle) out = StringIO() # Typo is only a WARNING, not CRITICAL -- should not exit 1 call_command('audit_exercise_data', stdout=out) self.assertIn('horizonal', out.getvalue()) def test_audit_after_all_fixes(self): """Audit should have no critical issues after running all fix commands.""" # Create exercises with all known issues muscle = Muscle.objects.create(name='chest') ex1 = Exercise.objects.create( name='Bench Press', estimated_rep_duration=None, is_reps=True, is_duration=False, movement_patterns='upper push - horizonal', ) # This exercise has a muscle, so no orphan issue after we assign to ex1 ExerciseMuscle.objects.create(exercise=ex1, muscle=muscle) # Run all fix commands call_command('fix_rep_durations') call_command('fix_exercise_flags') call_command('fix_movement_pattern_typo') out = StringIO() call_command('audit_exercise_data', stdout=out) output = out.getvalue() self.assertNotIn('CRITICAL', output)