Files
WerkoutAPI/generator/tests/test_exercise_metadata.py
Trey t 1c61b80731 workout generator audit: rules engine, structure rules, split patterns, injury UX, metadata cleanup
- Add rules_engine.py with quantitative rules for all 8 workout types
- Add quality gate retry loop in generate_single_workout()
- Expand calibrate_structure_rules to all 120 combinations (8 types × 5 goals × 3 sections)
- Wire WeeklySplitPattern DB records into _pick_weekly_split()
- Enforce movement patterns from WorkoutStructureRule in exercise selection
- Add straight-set strength support (single main lift, 4-6 rounds)
- Add modality consistency check for duration-dominant workout types
- Add InjuryStep component to onboarding and preferences
- Add sibling exercise exclusion in regenerate and preview_day endpoints
- Display generator warnings on dashboard
- Expand fix_rep_durations, fix_exercise_flags, fix_movement_pattern_typo
- Add audit_exercise_data and check_rules_drift management commands
- Add Next.js frontend with dashboard, onboarding, preferences, history pages
- Add generator app with ML-powered workout generation pipeline
- 96 new tests across 7 test modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:07:40 -06:00

431 lines
16 KiB
Python

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