Files
WerkoutAPI/generator/tests/test_structure_rules.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

251 lines
9.2 KiB
Python

"""
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}',
)