- 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>
431 lines
16 KiB
Python
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)
|