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>
This commit is contained in:
250
generator/tests/test_structure_rules.py
Normal file
250
generator/tests/test_structure_rules.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
Tests for the calibrate_structure_rules management command.
|
||||
|
||||
Verifies the full 120-rule matrix (8 types x 5 goals x 3 sections)
|
||||
is correctly populated, all values are sane, and the command is
|
||||
idempotent (running it twice doesn't create duplicates).
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from django.core.management import call_command
|
||||
|
||||
from generator.models import WorkoutStructureRule, WorkoutType
|
||||
|
||||
|
||||
WORKOUT_TYPE_NAMES = [
|
||||
'traditional_strength_training',
|
||||
'hypertrophy',
|
||||
'high_intensity_interval_training',
|
||||
'functional_strength_training',
|
||||
'cross_training',
|
||||
'core_training',
|
||||
'flexibility',
|
||||
'cardio',
|
||||
]
|
||||
|
||||
GOAL_TYPES = [
|
||||
'strength', 'hypertrophy', 'endurance', 'weight_loss', 'general_fitness',
|
||||
]
|
||||
|
||||
SECTION_TYPES = ['warm_up', 'working', 'cool_down']
|
||||
|
||||
|
||||
class TestStructureRules(TestCase):
|
||||
"""Verify calibrate_structure_rules produces the correct 120-rule matrix."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
# Create all 8 workout types so the command can find them.
|
||||
cls.workout_types = []
|
||||
for name in WORKOUT_TYPE_NAMES:
|
||||
wt, _ = WorkoutType.objects.get_or_create(name=name)
|
||||
cls.workout_types.append(wt)
|
||||
|
||||
# Run the calibration command.
|
||||
call_command('calibrate_structure_rules')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Coverage tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_all_120_combinations_exist(self):
|
||||
"""8 types x 5 goals x 3 sections = 120 rules."""
|
||||
count = WorkoutStructureRule.objects.count()
|
||||
self.assertEqual(count, 120, f'Expected 120 rules, got {count}')
|
||||
|
||||
def test_each_type_has_15_rules(self):
|
||||
"""Each workout type should have 5 goals x 3 sections = 15 rules."""
|
||||
for wt in self.workout_types:
|
||||
count = WorkoutStructureRule.objects.filter(
|
||||
workout_type=wt,
|
||||
).count()
|
||||
self.assertEqual(
|
||||
count, 15,
|
||||
f'{wt.name} has {count} rules, expected 15',
|
||||
)
|
||||
|
||||
def test_each_type_has_all_sections(self):
|
||||
"""Every type must cover warm_up, working, and cool_down."""
|
||||
for wt in self.workout_types:
|
||||
sections = set(
|
||||
WorkoutStructureRule.objects.filter(
|
||||
workout_type=wt,
|
||||
).values_list('section_type', flat=True)
|
||||
)
|
||||
self.assertEqual(
|
||||
sections,
|
||||
{'warm_up', 'working', 'cool_down'},
|
||||
f'{wt.name} missing sections: '
|
||||
f'{{"warm_up", "working", "cool_down"}} - {sections}',
|
||||
)
|
||||
|
||||
def test_each_type_has_all_goals(self):
|
||||
"""Every type must have all 5 goal types."""
|
||||
for wt in self.workout_types:
|
||||
goals = set(
|
||||
WorkoutStructureRule.objects.filter(
|
||||
workout_type=wt,
|
||||
).values_list('goal_type', flat=True)
|
||||
)
|
||||
expected = set(GOAL_TYPES)
|
||||
self.assertEqual(
|
||||
goals, expected,
|
||||
f'{wt.name} goals mismatch: expected {expected}, got {goals}',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Value sanity tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_working_rules_have_movement_patterns(self):
|
||||
"""All working-section rules must have at least one pattern."""
|
||||
working_rules = WorkoutStructureRule.objects.filter(
|
||||
section_type='working',
|
||||
)
|
||||
for rule in working_rules:
|
||||
self.assertTrue(
|
||||
len(rule.movement_patterns) > 0,
|
||||
f'Working rule {rule} has empty movement_patterns',
|
||||
)
|
||||
|
||||
def test_warmup_and_cooldown_have_patterns(self):
|
||||
"""Warm-up and cool-down rules should also have patterns."""
|
||||
for section in ('warm_up', 'cool_down'):
|
||||
rules = WorkoutStructureRule.objects.filter(section_type=section)
|
||||
for rule in rules:
|
||||
self.assertTrue(
|
||||
len(rule.movement_patterns) > 0,
|
||||
f'{section} rule {rule} has empty movement_patterns',
|
||||
)
|
||||
|
||||
def test_rep_ranges_valid(self):
|
||||
"""rep_min <= rep_max, and working rep_min >= 1."""
|
||||
for rule in WorkoutStructureRule.objects.all():
|
||||
self.assertLessEqual(
|
||||
rule.typical_rep_range_min,
|
||||
rule.typical_rep_range_max,
|
||||
f'Rule {rule}: rep_min ({rule.typical_rep_range_min}) '
|
||||
f'> rep_max ({rule.typical_rep_range_max})',
|
||||
)
|
||||
if rule.section_type == 'working':
|
||||
self.assertGreaterEqual(
|
||||
rule.typical_rep_range_min, 1,
|
||||
f'Rule {rule}: working rep_min below floor',
|
||||
)
|
||||
|
||||
def test_duration_ranges_valid(self):
|
||||
"""dur_min <= dur_max for every rule."""
|
||||
for rule in WorkoutStructureRule.objects.all():
|
||||
self.assertLessEqual(
|
||||
rule.typical_duration_range_min,
|
||||
rule.typical_duration_range_max,
|
||||
f'Rule {rule}: dur_min ({rule.typical_duration_range_min}) '
|
||||
f'> dur_max ({rule.typical_duration_range_max})',
|
||||
)
|
||||
|
||||
def test_warm_up_rounds_are_one(self):
|
||||
"""All warm_up sections must have exactly 1 round."""
|
||||
warmup_rules = WorkoutStructureRule.objects.filter(
|
||||
section_type='warm_up',
|
||||
)
|
||||
for rule in warmup_rules:
|
||||
self.assertEqual(
|
||||
rule.typical_rounds, 1,
|
||||
f'Warm-up rule {rule} has rounds={rule.typical_rounds}, '
|
||||
f'expected 1',
|
||||
)
|
||||
|
||||
def test_cool_down_rounds_are_one(self):
|
||||
"""All cool_down sections must have exactly 1 round."""
|
||||
cooldown_rules = WorkoutStructureRule.objects.filter(
|
||||
section_type='cool_down',
|
||||
)
|
||||
for rule in cooldown_rules:
|
||||
self.assertEqual(
|
||||
rule.typical_rounds, 1,
|
||||
f'Cool-down rule {rule} has rounds={rule.typical_rounds}, '
|
||||
f'expected 1',
|
||||
)
|
||||
|
||||
def test_cardio_rounds_not_absurd(self):
|
||||
"""Cardio working rounds should be 2-3, not 23-25 (ML artifact)."""
|
||||
cardio_wt = WorkoutType.objects.get(name='cardio')
|
||||
cardio_working = WorkoutStructureRule.objects.filter(
|
||||
workout_type=cardio_wt,
|
||||
section_type='working',
|
||||
)
|
||||
for rule in cardio_working:
|
||||
self.assertLessEqual(
|
||||
rule.typical_rounds, 5,
|
||||
f'Cardio working {rule.goal_type} has '
|
||||
f'rounds={rule.typical_rounds}, expected <= 5',
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
rule.typical_rounds, 2,
|
||||
f'Cardio working {rule.goal_type} has '
|
||||
f'rounds={rule.typical_rounds}, expected >= 2',
|
||||
)
|
||||
|
||||
def test_cool_down_has_stretch_or_mobility(self):
|
||||
"""Cool-down patterns should focus on stretch/mobility."""
|
||||
cooldown_rules = WorkoutStructureRule.objects.filter(
|
||||
section_type='cool_down',
|
||||
)
|
||||
stretch_mobility_patterns = {
|
||||
'mobility', 'mobility - static', 'yoga',
|
||||
'lower pull - hip hinge', 'cardio/locomotion',
|
||||
}
|
||||
for rule in cooldown_rules:
|
||||
patterns = set(rule.movement_patterns)
|
||||
overlap = patterns & stretch_mobility_patterns
|
||||
self.assertTrue(
|
||||
len(overlap) > 0,
|
||||
f'Cool-down rule {rule} has no stretch/mobility patterns: '
|
||||
f'{rule.movement_patterns}',
|
||||
)
|
||||
|
||||
def test_no_rep_min_below_global_floor(self):
|
||||
"""After calibration, no rule should have rep_min < 6 (the floor)."""
|
||||
below_floor = WorkoutStructureRule.objects.filter(
|
||||
typical_rep_range_min__lt=6,
|
||||
typical_rep_range_min__gt=0,
|
||||
)
|
||||
self.assertEqual(
|
||||
below_floor.count(), 0,
|
||||
f'{below_floor.count()} rules have rep_min below 6',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Idempotency test
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_calibrate_is_idempotent(self):
|
||||
"""Running the command again must not create duplicates."""
|
||||
# Run calibration a second time.
|
||||
call_command('calibrate_structure_rules')
|
||||
count = WorkoutStructureRule.objects.count()
|
||||
self.assertEqual(
|
||||
count, 120,
|
||||
f'After re-run, expected 120 rules, got {count}',
|
||||
)
|
||||
|
||||
def test_calibrate_updates_existing_values(self):
|
||||
"""If a rule value is changed in DB, re-running restores it."""
|
||||
# Pick a rule and mutate it.
|
||||
rule = WorkoutStructureRule.objects.filter(
|
||||
section_type='working',
|
||||
goal_type='strength',
|
||||
).first()
|
||||
original_rounds = rule.typical_rounds
|
||||
rule.typical_rounds = 99
|
||||
rule.save()
|
||||
|
||||
# Re-run calibration.
|
||||
call_command('calibrate_structure_rules')
|
||||
|
||||
rule.refresh_from_db()
|
||||
self.assertEqual(
|
||||
rule.typical_rounds, original_rounds,
|
||||
f'Expected rounds to be restored to {original_rounds}, '
|
||||
f'got {rule.typical_rounds}',
|
||||
)
|
||||
Reference in New Issue
Block a user