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:
Trey t
2026-02-22 20:07:40 -06:00
parent 2a16b75c4b
commit 1c61b80731
111 changed files with 28108 additions and 30 deletions

View File

@@ -0,0 +1,616 @@
"""
Tests for the rules engine: WORKOUT_TYPE_RULES coverage,
validate_workout() error/warning detection, and quality gate retry logic.
"""
from unittest.mock import MagicMock, patch, PropertyMock
from django.test import TestCase
from generator.rules_engine import (
validate_workout,
RuleViolation,
WORKOUT_TYPE_RULES,
UNIVERSAL_RULES,
DB_CALIBRATION,
_normalize_type_key,
_classify_rep_weight,
_has_warmup,
_has_cooldown,
_get_working_supersets,
_count_push_pull,
_check_compound_before_isolation,
)
def _make_exercise(**kwargs):
"""Create a mock exercise object with the given attributes."""
defaults = {
'exercise_tier': 'accessory',
'is_reps': True,
'is_compound': False,
'is_weight': False,
'is_duration': False,
'movement_patterns': '',
'name': 'Test Exercise',
'stretch_position': None,
'difficulty_level': 'intermediate',
'complexity_rating': 3,
'hr_elevation_rating': 5,
'estimated_rep_duration': 3.0,
}
defaults.update(kwargs)
ex = MagicMock()
for k, v in defaults.items():
setattr(ex, k, v)
return ex
def _make_entry(exercise=None, reps=None, duration=None, order=1):
"""Create an exercise entry dict for a superset."""
entry = {'order': order}
entry['exercise'] = exercise or _make_exercise()
if reps is not None:
entry['reps'] = reps
if duration is not None:
entry['duration'] = duration
return entry
def _make_superset(name='Working Set 1', exercises=None, rounds=3):
"""Create a superset dict."""
return {
'name': name,
'exercises': exercises or [],
'rounds': rounds,
}
class TestWorkoutTypeRulesCoverage(TestCase):
"""Verify that WORKOUT_TYPE_RULES covers all 8 workout types."""
def test_all_8_workout_types_have_rules(self):
expected_types = [
'traditional_strength_training',
'hypertrophy',
'hiit',
'functional_strength_training',
'cross_training',
'core_training',
'flexibility',
'cardio',
]
for wt in expected_types:
self.assertIn(wt, WORKOUT_TYPE_RULES, f"Missing rules for {wt}")
def test_each_type_has_required_keys(self):
required_keys = [
'rep_ranges', 'rest_periods', 'duration_bias_range',
'superset_size_range', 'round_range', 'typical_rest',
'typical_intensity',
]
for wt_name, rules in WORKOUT_TYPE_RULES.items():
for key in required_keys:
self.assertIn(
key, rules,
f"Missing key '{key}' in rules for {wt_name}",
)
def test_rep_ranges_have_all_tiers(self):
for wt_name, rules in WORKOUT_TYPE_RULES.items():
rep_ranges = rules['rep_ranges']
for tier in ('primary', 'secondary', 'accessory'):
self.assertIn(
tier, rep_ranges,
f"Missing rep range tier '{tier}' in {wt_name}",
)
low, high = rep_ranges[tier]
self.assertLessEqual(
low, high,
f"Invalid rep range ({low}, {high}) for {tier} in {wt_name}",
)
class TestDBCalibrationCoverage(TestCase):
"""Verify DB_CALIBRATION has entries for all 8 types."""
def test_all_8_types_in_calibration(self):
expected_names = [
'Functional Strength Training',
'Traditional Strength Training',
'HIIT',
'Cross Training',
'Core Training',
'Flexibility',
'Cardio',
'Hypertrophy',
]
for name in expected_names:
self.assertIn(name, DB_CALIBRATION, f"Missing {name} in DB_CALIBRATION")
class TestHelperFunctions(TestCase):
"""Test utility functions used by validate_workout."""
def test_normalize_type_key(self):
self.assertEqual(
_normalize_type_key('Traditional Strength Training'),
'traditional_strength_training',
)
self.assertEqual(_normalize_type_key('HIIT'), 'hiit')
self.assertEqual(_normalize_type_key('cardio'), 'cardio')
def test_classify_rep_weight(self):
self.assertEqual(_classify_rep_weight(3), 'heavy')
self.assertEqual(_classify_rep_weight(5), 'heavy')
self.assertEqual(_classify_rep_weight(8), 'moderate')
self.assertEqual(_classify_rep_weight(12), 'light')
def test_has_warmup(self):
supersets = [
_make_superset(name='Warm Up'),
_make_superset(name='Working Set 1'),
]
self.assertTrue(_has_warmup(supersets))
self.assertFalse(_has_warmup([_make_superset(name='Working Set 1')]))
def test_has_cooldown(self):
supersets = [
_make_superset(name='Working Set 1'),
_make_superset(name='Cool Down'),
]
self.assertTrue(_has_cooldown(supersets))
self.assertFalse(_has_cooldown([_make_superset(name='Working Set 1')]))
def test_get_working_supersets(self):
supersets = [
_make_superset(name='Warm Up'),
_make_superset(name='Working Set 1'),
_make_superset(name='Working Set 2'),
_make_superset(name='Cool Down'),
]
working = _get_working_supersets(supersets)
self.assertEqual(len(working), 2)
self.assertEqual(working[0]['name'], 'Working Set 1')
def test_count_push_pull(self):
push_ex = _make_exercise(movement_patterns='upper push')
pull_ex = _make_exercise(movement_patterns='upper pull')
supersets = [
_make_superset(
name='Working Set 1',
exercises=[
_make_entry(exercise=push_ex, reps=8),
_make_entry(exercise=pull_ex, reps=8),
],
),
]
push_count, pull_count = _count_push_pull(supersets)
self.assertEqual(push_count, 1)
self.assertEqual(pull_count, 1)
def test_compound_before_isolation_correct(self):
compound = _make_exercise(is_compound=True, exercise_tier='primary')
isolation = _make_exercise(is_compound=False, exercise_tier='accessory')
supersets = [
_make_superset(
name='Working Set 1',
exercises=[
_make_entry(exercise=compound, reps=5, order=1),
_make_entry(exercise=isolation, reps=12, order=2),
],
),
]
self.assertTrue(_check_compound_before_isolation(supersets))
def test_compound_before_isolation_violated(self):
compound = _make_exercise(is_compound=True, exercise_tier='primary')
isolation = _make_exercise(is_compound=False, exercise_tier='accessory')
supersets = [
_make_superset(
name='Working Set 1',
exercises=[
_make_entry(exercise=isolation, reps=12, order=1),
],
),
_make_superset(
name='Working Set 2',
exercises=[
_make_entry(exercise=compound, reps=5, order=1),
],
),
]
self.assertFalse(_check_compound_before_isolation(supersets))
class TestValidateWorkout(TestCase):
"""Test the main validate_workout function."""
def test_empty_workout_produces_error(self):
violations = validate_workout({'supersets': []}, 'hiit', 'general_fitness')
errors = [v for v in violations if v.severity == 'error']
self.assertTrue(len(errors) > 0)
self.assertEqual(errors[0].rule_id, 'empty_workout')
def test_validate_catches_rep_range_violation(self):
"""Strength workout with reps=20 on primary should produce error."""
workout_spec = {
'supersets': [
_make_superset(
name='Working Set 1',
exercises=[
_make_entry(
exercise=_make_exercise(
exercise_tier='primary',
is_reps=True,
),
reps=20,
),
],
rounds=3,
),
],
}
violations = validate_workout(
workout_spec, 'traditional_strength_training', 'strength',
)
rep_errors = [
v for v in violations
if v.severity == 'error' and 'rep_range' in v.rule_id
]
self.assertTrue(
len(rep_errors) > 0,
f"Expected rep range error, got: {[v.rule_id for v in violations]}",
)
def test_validate_passes_valid_strength_workout(self):
"""A well-formed strength workout with warmup + working + cooldown."""
workout_spec = {
'supersets': [
_make_superset(
name='Warm Up',
exercises=[
_make_entry(
exercise=_make_exercise(is_reps=False),
duration=30,
),
],
rounds=1,
),
_make_superset(
name='Working Set 1',
exercises=[
_make_entry(
exercise=_make_exercise(
exercise_tier='primary',
is_reps=True,
is_compound=True,
is_weight=True,
movement_patterns='upper push',
),
reps=5,
),
],
rounds=4,
),
_make_superset(
name='Cool Down',
exercises=[
_make_entry(
exercise=_make_exercise(is_reps=False),
duration=30,
),
],
rounds=1,
),
],
}
violations = validate_workout(
workout_spec, 'traditional_strength_training', 'strength',
)
errors = [v for v in violations if v.severity == 'error']
self.assertEqual(
len(errors), 0,
f"Unexpected errors: {[v.message for v in errors]}",
)
def test_warmup_missing_produces_error(self):
"""Workout without warmup should produce an error."""
workout_spec = {
'supersets': [
_make_superset(
name='Working Set 1',
exercises=[
_make_entry(
exercise=_make_exercise(
exercise_tier='primary',
is_reps=True,
is_compound=True,
is_weight=True,
),
reps=5,
),
],
rounds=4,
),
],
}
violations = validate_workout(
workout_spec, 'traditional_strength_training', 'strength',
)
warmup_errors = [
v for v in violations
if v.rule_id == 'warmup_missing'
]
self.assertEqual(len(warmup_errors), 1)
def test_cooldown_missing_produces_warning(self):
"""Workout without cooldown should produce a warning."""
workout_spec = {
'supersets': [
_make_superset(name='Warm Up', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
_make_superset(
name='Working Set 1',
exercises=[
_make_entry(
exercise=_make_exercise(
exercise_tier='primary',
is_reps=True,
is_compound=True,
is_weight=True,
),
reps=5,
),
],
rounds=4,
),
],
}
violations = validate_workout(
workout_spec, 'traditional_strength_training', 'strength',
)
cooldown_warnings = [
v for v in violations
if v.rule_id == 'cooldown_missing'
]
self.assertEqual(len(cooldown_warnings), 1)
self.assertEqual(cooldown_warnings[0].severity, 'warning')
def test_push_pull_ratio_enforcement(self):
"""All push, no pull -> warning."""
push_exercises = [
_make_entry(
exercise=_make_exercise(
movement_patterns='upper push',
is_compound=True,
is_weight=True,
exercise_tier='primary',
),
reps=8,
order=i + 1,
)
for i in range(4)
]
workout_spec = {
'supersets': [
_make_superset(name='Warm Up', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
_make_superset(
name='Working Set 1',
exercises=push_exercises,
rounds=3,
),
_make_superset(name='Cool Down', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
],
}
violations = validate_workout(
workout_spec, 'hypertrophy', 'hypertrophy',
)
ratio_violations = [v for v in violations if v.rule_id == 'push_pull_ratio']
self.assertTrue(
len(ratio_violations) > 0,
"Expected push:pull ratio warning for all-push workout",
)
def test_workout_type_match_violation(self):
"""Non-strength exercises in a strength workout should trigger match violation."""
# All duration-based, non-compound, non-weight exercises for strength
workout_spec = {
'supersets': [
_make_superset(name='Warm Up', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
_make_superset(
name='Working Set 1',
exercises=[
_make_entry(
exercise=_make_exercise(
exercise_tier='accessory',
is_reps=True,
is_compound=False,
is_weight=False,
),
reps=15,
)
for _ in range(5)
],
rounds=3,
),
_make_superset(name='Cool Down', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
],
}
violations = validate_workout(
workout_spec, 'traditional_strength_training', 'strength',
)
match_violations = [
v for v in violations
if v.rule_id == 'workout_type_match'
]
self.assertTrue(
len(match_violations) > 0,
"Expected workout type match violation for non-strength exercises",
)
def test_superset_size_warning(self):
"""Traditional strength with >5 exercises per superset should warn."""
many_exercises = [
_make_entry(
exercise=_make_exercise(
exercise_tier='accessory',
is_reps=True,
is_weight=True,
is_compound=True,
),
reps=5,
order=i + 1,
)
for i in range(8)
]
workout_spec = {
'supersets': [
_make_superset(name='Warm Up', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
_make_superset(
name='Working Set 1',
exercises=many_exercises,
rounds=3,
),
_make_superset(name='Cool Down', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
],
}
violations = validate_workout(
workout_spec, 'traditional_strength_training', 'strength',
)
size_violations = [
v for v in violations
if v.rule_id == 'superset_size'
]
self.assertTrue(
len(size_violations) > 0,
"Expected superset size warning for 8-exercise superset in strength",
)
def test_compound_before_isolation_info(self):
"""Isolation before compound should produce info violation."""
isolation = _make_exercise(
is_compound=False, exercise_tier='accessory',
is_weight=True, is_reps=True,
)
compound = _make_exercise(
is_compound=True, exercise_tier='primary',
is_weight=True, is_reps=True,
)
workout_spec = {
'supersets': [
_make_superset(name='Warm Up', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
_make_superset(
name='Working Set 1',
exercises=[
_make_entry(exercise=isolation, reps=12, order=1),
],
rounds=3,
),
_make_superset(
name='Working Set 2',
exercises=[
_make_entry(exercise=compound, reps=5, order=1),
],
rounds=4,
),
_make_superset(name='Cool Down', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
],
}
violations = validate_workout(
workout_spec, 'hypertrophy', 'hypertrophy',
)
order_violations = [
v for v in violations
if v.rule_id == 'compound_before_isolation'
]
self.assertTrue(
len(order_violations) > 0,
"Expected compound_before_isolation info for isolation-first order",
)
def test_unknown_workout_type_does_not_crash(self):
"""An unknown workout type should not crash validation."""
workout_spec = {
'supersets': [
_make_superset(name='Warm Up', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
_make_superset(
name='Working Set 1',
exercises=[_make_entry(reps=10)],
rounds=3,
),
_make_superset(name='Cool Down', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
],
}
violations = validate_workout(
workout_spec, 'unknown_type', 'general_fitness',
)
# Should not raise; may produce some violations but no crash
self.assertIsInstance(violations, list)
class TestRuleViolationDataclass(TestCase):
"""Test the RuleViolation dataclass."""
def test_basic_creation(self):
v = RuleViolation(
rule_id='test_rule',
severity='error',
message='Test message',
)
self.assertEqual(v.rule_id, 'test_rule')
self.assertEqual(v.severity, 'error')
self.assertEqual(v.message, 'Test message')
self.assertIsNone(v.actual_value)
self.assertIsNone(v.expected_range)
def test_with_values(self):
v = RuleViolation(
rule_id='rep_range_primary',
severity='error',
message='Reps out of range',
actual_value=20,
expected_range=(3, 6),
)
self.assertEqual(v.actual_value, 20)
self.assertEqual(v.expected_range, (3, 6))
class TestUniversalRules(TestCase):
"""Verify universal rules have expected values."""
def test_push_pull_ratio_min(self):
self.assertEqual(UNIVERSAL_RULES['push_pull_ratio_min'], 1.0)
def test_compound_before_isolation(self):
self.assertTrue(UNIVERSAL_RULES['compound_before_isolation'])
def test_warmup_mandatory(self):
self.assertTrue(UNIVERSAL_RULES['warmup_mandatory'])
def test_max_hiit_duration(self):
self.assertEqual(UNIVERSAL_RULES['max_hiit_duration_min'], 30)
def test_cooldown_stretch_only(self):
self.assertTrue(UNIVERSAL_RULES['cooldown_stretch_only'])