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:
505
generator/tests/test_movement_enforcement.py
Normal file
505
generator/tests/test_movement_enforcement.py
Normal file
@@ -0,0 +1,505 @@
|
||||
"""
|
||||
Tests for _build_working_supersets() — Items #4, #6, #7:
|
||||
- Movement pattern enforcement (WorkoutStructureRule merging)
|
||||
- Modality consistency check (duration_bias warning)
|
||||
- Straight-set strength (first superset = single main lift)
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from unittest.mock import patch, MagicMock, PropertyMock
|
||||
|
||||
from generator.models import (
|
||||
MuscleGroupSplit,
|
||||
MovementPatternOrder,
|
||||
UserPreference,
|
||||
WorkoutStructureRule,
|
||||
WorkoutType,
|
||||
)
|
||||
from generator.services.workout_generator import (
|
||||
WorkoutGenerator,
|
||||
STRENGTH_WORKOUT_TYPES,
|
||||
WORKOUT_TYPE_DEFAULTS,
|
||||
)
|
||||
from registered_user.models import RegisteredUser
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class MovementEnforcementTestBase(TestCase):
|
||||
"""Shared setup for movement enforcement tests."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.auth_user = User.objects.create_user(
|
||||
username='testmove', password='testpass123',
|
||||
)
|
||||
cls.registered_user = RegisteredUser.objects.create(
|
||||
first_name='Test', last_name='Move', user=cls.auth_user,
|
||||
)
|
||||
|
||||
# Create workout types
|
||||
cls.strength_type = WorkoutType.objects.create(
|
||||
name='traditional strength',
|
||||
typical_rest_between_sets=120,
|
||||
typical_intensity='high',
|
||||
rep_range_min=3,
|
||||
rep_range_max=6,
|
||||
duration_bias=0.0,
|
||||
superset_size_min=1,
|
||||
superset_size_max=3,
|
||||
)
|
||||
cls.hiit_type = WorkoutType.objects.create(
|
||||
name='hiit',
|
||||
typical_rest_between_sets=30,
|
||||
typical_intensity='high',
|
||||
rep_range_min=10,
|
||||
rep_range_max=20,
|
||||
duration_bias=0.7,
|
||||
superset_size_min=3,
|
||||
superset_size_max=6,
|
||||
)
|
||||
|
||||
# Create MovementPatternOrder records
|
||||
MovementPatternOrder.objects.create(
|
||||
position='early', movement_pattern='lower push - squat',
|
||||
frequency=20, section_type='working',
|
||||
)
|
||||
MovementPatternOrder.objects.create(
|
||||
position='early', movement_pattern='upper push - horizontal',
|
||||
frequency=15, section_type='working',
|
||||
)
|
||||
MovementPatternOrder.objects.create(
|
||||
position='middle', movement_pattern='upper pull',
|
||||
frequency=18, section_type='working',
|
||||
)
|
||||
MovementPatternOrder.objects.create(
|
||||
position='late', movement_pattern='isolation',
|
||||
frequency=12, section_type='working',
|
||||
)
|
||||
|
||||
# Create WorkoutStructureRule for strength
|
||||
cls.strength_rule = WorkoutStructureRule.objects.create(
|
||||
workout_type=cls.strength_type,
|
||||
section_type='working',
|
||||
movement_patterns=['lower push - squat', 'hip hinge', 'upper push - horizontal'],
|
||||
typical_rounds=5,
|
||||
typical_exercises_per_superset=2,
|
||||
goal_type='general_fitness',
|
||||
)
|
||||
|
||||
def _make_preference(self, **kwargs):
|
||||
"""Create a UserPreference for testing."""
|
||||
defaults = {
|
||||
'registered_user': self.registered_user,
|
||||
'days_per_week': 3,
|
||||
'fitness_level': 2,
|
||||
'primary_goal': 'general_fitness',
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return UserPreference.objects.create(**defaults)
|
||||
|
||||
def _make_generator(self, pref):
|
||||
"""Create a WorkoutGenerator with mocked dependencies."""
|
||||
with patch('generator.services.workout_generator.ExerciseSelector') as MockSelector, \
|
||||
patch('generator.services.workout_generator.PlanBuilder'):
|
||||
gen = WorkoutGenerator(pref)
|
||||
# Make the exercise selector return mock exercises
|
||||
self.mock_selector = gen.exercise_selector
|
||||
return gen
|
||||
|
||||
def _create_mock_exercise(self, name='Mock Exercise', is_duration=False,
|
||||
is_weight=True, is_reps=True, is_compound=True,
|
||||
exercise_tier='primary', movement_patterns='lower push - squat',
|
||||
hr_elevation_rating=5):
|
||||
"""Create a mock Exercise object."""
|
||||
ex = MagicMock()
|
||||
ex.pk = id(ex) # unique pk
|
||||
ex.name = name
|
||||
ex.is_duration = is_duration
|
||||
ex.is_weight = is_weight
|
||||
ex.is_reps = is_reps
|
||||
ex.is_compound = is_compound
|
||||
ex.exercise_tier = exercise_tier
|
||||
ex.movement_patterns = movement_patterns
|
||||
ex.hr_elevation_rating = hr_elevation_rating
|
||||
ex.side = None
|
||||
ex.stretch_position = 'mid'
|
||||
return ex
|
||||
|
||||
|
||||
class TestMovementPatternEnforcement(MovementEnforcementTestBase):
|
||||
"""Item #4: WorkoutStructureRule patterns merged with position patterns."""
|
||||
|
||||
def test_movement_patterns_passed_to_selector(self):
|
||||
"""select_exercises should receive combined movement pattern preferences
|
||||
when both position patterns and structure rule patterns exist."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
# Setup mock exercises
|
||||
mock_exercises = [
|
||||
self._create_mock_exercise(f'Exercise {i}')
|
||||
for i in range(3)
|
||||
]
|
||||
gen.exercise_selector.select_exercises.return_value = mock_exercises
|
||||
gen.exercise_selector.balance_stretch_positions.return_value = mock_exercises
|
||||
|
||||
muscle_split = {
|
||||
'muscles': ['chest', 'back'],
|
||||
'split_type': 'full_body',
|
||||
'label': 'Full Body',
|
||||
}
|
||||
wt_params = dict(WORKOUT_TYPE_DEFAULTS['traditional strength'])
|
||||
|
||||
supersets = gen._build_working_supersets(
|
||||
muscle_split, self.strength_type, wt_params,
|
||||
)
|
||||
|
||||
# Verify select_exercises was called
|
||||
self.assertTrue(gen.exercise_selector.select_exercises.called)
|
||||
|
||||
# Check the movement_pattern_preference argument in the first call
|
||||
first_call_kwargs = gen.exercise_selector.select_exercises.call_args_list[0]
|
||||
# The call could be positional or keyword - check kwargs
|
||||
if first_call_kwargs.kwargs.get('movement_pattern_preference') is not None:
|
||||
patterns = first_call_kwargs.kwargs['movement_pattern_preference']
|
||||
# Should be combined patterns (intersection of position + rule, or rule[:3])
|
||||
self.assertIsInstance(patterns, list)
|
||||
self.assertTrue(len(patterns) > 0)
|
||||
|
||||
pref.delete()
|
||||
|
||||
|
||||
class TestStrengthStraightSets(MovementEnforcementTestBase):
|
||||
"""Item #7: First working superset in strength = single main lift."""
|
||||
|
||||
def test_strength_first_superset_single_exercise(self):
|
||||
"""For traditional strength, the first working superset should request
|
||||
exactly 1 exercise (straight set of a main lift)."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
mock_ex = self._create_mock_exercise('Barbell Squat')
|
||||
gen.exercise_selector.select_exercises.return_value = [mock_ex]
|
||||
gen.exercise_selector.balance_stretch_positions.return_value = [mock_ex]
|
||||
|
||||
muscle_split = {
|
||||
'muscles': ['quads', 'hamstrings'],
|
||||
'split_type': 'lower',
|
||||
'label': 'Lower',
|
||||
}
|
||||
wt_params = dict(WORKOUT_TYPE_DEFAULTS['traditional strength'])
|
||||
|
||||
supersets = gen._build_working_supersets(
|
||||
muscle_split, self.strength_type, wt_params,
|
||||
)
|
||||
|
||||
self.assertGreaterEqual(len(supersets), 1)
|
||||
|
||||
# First superset should have been requested with count=1
|
||||
first_call = gen.exercise_selector.select_exercises.call_args_list[0]
|
||||
self.assertEqual(first_call.kwargs.get('count', first_call.args[1] if len(first_call.args) > 1 else None), 1)
|
||||
|
||||
pref.delete()
|
||||
|
||||
def test_strength_first_superset_more_rounds(self):
|
||||
"""First superset of a strength workout should have 4-6 rounds."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
mock_ex = self._create_mock_exercise('Deadlift')
|
||||
gen.exercise_selector.select_exercises.return_value = [mock_ex]
|
||||
gen.exercise_selector.balance_stretch_positions.return_value = [mock_ex]
|
||||
|
||||
muscle_split = {
|
||||
'muscles': ['hamstrings', 'glutes'],
|
||||
'split_type': 'lower',
|
||||
'label': 'Lower',
|
||||
}
|
||||
wt_params = dict(WORKOUT_TYPE_DEFAULTS['traditional strength'])
|
||||
|
||||
# Run multiple times to check round ranges
|
||||
round_counts = set()
|
||||
for _ in range(50):
|
||||
gen.exercise_selector.select_exercises.return_value = [mock_ex]
|
||||
supersets = gen._build_working_supersets(
|
||||
muscle_split, self.strength_type, wt_params,
|
||||
)
|
||||
if supersets:
|
||||
round_counts.add(supersets[0]['rounds'])
|
||||
|
||||
# All first-superset round counts should be in [4, 6]
|
||||
for r in round_counts:
|
||||
self.assertGreaterEqual(r, 4, f"Rounds {r} below minimum 4")
|
||||
self.assertLessEqual(r, 6, f"Rounds {r} above maximum 6")
|
||||
|
||||
pref.delete()
|
||||
|
||||
def test_strength_first_superset_rest_period(self):
|
||||
"""First superset of a strength workout should use the workout type's
|
||||
typical_rest_between_sets for rest."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
mock_ex = self._create_mock_exercise('Bench Press')
|
||||
gen.exercise_selector.select_exercises.return_value = [mock_ex]
|
||||
gen.exercise_selector.balance_stretch_positions.return_value = [mock_ex]
|
||||
|
||||
muscle_split = {
|
||||
'muscles': ['chest', 'triceps'],
|
||||
'split_type': 'push',
|
||||
'label': 'Push',
|
||||
}
|
||||
wt_params = dict(WORKOUT_TYPE_DEFAULTS['traditional strength'])
|
||||
|
||||
supersets = gen._build_working_supersets(
|
||||
muscle_split, self.strength_type, wt_params,
|
||||
)
|
||||
|
||||
if supersets:
|
||||
# typical_rest_between_sets for our strength_type is 120
|
||||
self.assertEqual(supersets[0]['rest_between_rounds'], 120)
|
||||
|
||||
pref.delete()
|
||||
|
||||
def test_strength_accessories_still_superset(self):
|
||||
"""2nd+ supersets in strength workouts should still have 2+ exercises
|
||||
(the min_ex_per_ss rule still applies to non-first supersets)."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
mock_exercises = [
|
||||
self._create_mock_exercise(f'Accessory {i}', exercise_tier='accessory')
|
||||
for i in range(3)
|
||||
]
|
||||
gen.exercise_selector.select_exercises.return_value = mock_exercises
|
||||
gen.exercise_selector.balance_stretch_positions.return_value = mock_exercises
|
||||
|
||||
muscle_split = {
|
||||
'muscles': ['chest', 'back', 'shoulders'],
|
||||
'split_type': 'upper',
|
||||
'label': 'Upper',
|
||||
}
|
||||
wt_params = dict(WORKOUT_TYPE_DEFAULTS['traditional strength'])
|
||||
|
||||
supersets = gen._build_working_supersets(
|
||||
muscle_split, self.strength_type, wt_params,
|
||||
)
|
||||
|
||||
# Should have multiple supersets
|
||||
if len(supersets) >= 2:
|
||||
# Check that the second superset's select_exercises call
|
||||
# requested count >= 2 (min_ex_per_ss)
|
||||
second_call = gen.exercise_selector.select_exercises.call_args_list[1]
|
||||
count_arg = second_call.kwargs.get('count')
|
||||
if count_arg is None and len(second_call.args) > 1:
|
||||
count_arg = second_call.args[1]
|
||||
self.assertGreaterEqual(count_arg, 2)
|
||||
|
||||
pref.delete()
|
||||
|
||||
def test_non_strength_no_single_exercise_override(self):
|
||||
"""Non-strength workouts should NOT have the single-exercise first superset."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
mock_exercises = [
|
||||
self._create_mock_exercise(f'HIIT Move {i}', is_duration=True, is_weight=False)
|
||||
for i in range(5)
|
||||
]
|
||||
gen.exercise_selector.select_exercises.return_value = mock_exercises
|
||||
gen.exercise_selector.balance_stretch_positions.return_value = mock_exercises
|
||||
|
||||
muscle_split = {
|
||||
'muscles': ['chest', 'back', 'quads'],
|
||||
'split_type': 'full_body',
|
||||
'label': 'Full Body',
|
||||
}
|
||||
wt_params = dict(WORKOUT_TYPE_DEFAULTS['hiit'])
|
||||
|
||||
supersets = gen._build_working_supersets(
|
||||
muscle_split, self.hiit_type, wt_params,
|
||||
)
|
||||
|
||||
# First call to select_exercises should NOT have count=1
|
||||
first_call = gen.exercise_selector.select_exercises.call_args_list[0]
|
||||
count_arg = first_call.kwargs.get('count')
|
||||
if count_arg is None and len(first_call.args) > 1:
|
||||
count_arg = first_call.args[1]
|
||||
self.assertGreater(count_arg, 1, "Non-strength first superset should have > 1 exercise")
|
||||
|
||||
pref.delete()
|
||||
|
||||
|
||||
class TestModalityConsistency(MovementEnforcementTestBase):
|
||||
"""Item #6: Modality consistency warning for duration-dominant workouts."""
|
||||
|
||||
def test_duration_dominant_warns_on_low_ratio(self):
|
||||
"""When duration_bias >= 0.6 and most exercises are rep-based,
|
||||
a warning should be appended to self.warnings."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
# Create mostly rep-based (non-duration) exercises
|
||||
mock_exercises = [
|
||||
self._create_mock_exercise(f'Rep Exercise {i}', is_duration=False)
|
||||
for i in range(4)
|
||||
]
|
||||
gen.exercise_selector.select_exercises.return_value = mock_exercises
|
||||
gen.exercise_selector.balance_stretch_positions.return_value = mock_exercises
|
||||
|
||||
muscle_split = {
|
||||
'muscles': ['chest', 'back'],
|
||||
'split_type': 'full_body',
|
||||
'label': 'Full Body',
|
||||
}
|
||||
|
||||
# Use HIIT params (duration_bias = 0.7 >= 0.6)
|
||||
wt_params = dict(WORKOUT_TYPE_DEFAULTS['hiit'])
|
||||
# But force rep-based by setting duration_bias low in actual randomization
|
||||
# We need to make superset_is_duration = False for all supersets
|
||||
# Override duration_bias to be very low so random.random() > it
|
||||
# But wt_params['duration_bias'] stays at 0.7 for the post-check
|
||||
|
||||
# Actually, the modality check uses wt_params['duration_bias'] which is 0.7
|
||||
# The rep-based exercises come from select_exercises mock returning
|
||||
# exercises with is_duration=False
|
||||
|
||||
supersets = gen._build_working_supersets(
|
||||
muscle_split, self.hiit_type, wt_params,
|
||||
)
|
||||
|
||||
# The exercises are not is_duration so the check should fire
|
||||
# Look for the modality mismatch warning
|
||||
modality_warnings = [
|
||||
w for w in gen.warnings if 'Modality mismatch' in w
|
||||
]
|
||||
# Note: This depends on whether the supersets ended up rep-based
|
||||
# Since duration_bias is 0.7, most supersets will be duration-based
|
||||
# and our mock exercises don't have is_duration=True, so they'd be
|
||||
# skipped in the duration superset builder (the continue clause).
|
||||
# The modality check counts exercises that ARE is_duration.
|
||||
# With is_duration=False mocks in duration supersets, they'd be skipped.
|
||||
# So total_exercises could be 0 (if all were skipped).
|
||||
|
||||
# Let's verify differently: the test should check the logic directly.
|
||||
# Create a scenario where the duration check definitely triggers:
|
||||
# Set duration_bias high but exercises are rep-based
|
||||
gen.warnings = [] # Reset warnings
|
||||
|
||||
# Create supersets manually to test the post-check
|
||||
# Simulate: wt_params has high duration_bias, but exercises are rep-based
|
||||
wt_params_high_dur = dict(wt_params)
|
||||
wt_params_high_dur['duration_bias'] = 0.8
|
||||
|
||||
# Return exercises that won't be skipped (rep-based supersets with non-duration exercises)
|
||||
rep_exercises = [
|
||||
self._create_mock_exercise(f'Rep Ex {i}', is_duration=False)
|
||||
for i in range(3)
|
||||
]
|
||||
gen.exercise_selector.select_exercises.return_value = rep_exercises
|
||||
gen.exercise_selector.balance_stretch_positions.return_value = rep_exercises
|
||||
|
||||
# Force non-strength workout type with low actual random for duration
|
||||
# Use a non-strength type so is_strength_workout is False
|
||||
# Create a real WorkoutType to avoid MagicMock pk issues with Django ORM
|
||||
non_strength_type = WorkoutType.objects.create(
|
||||
name='circuit training',
|
||||
typical_rest_between_sets=30,
|
||||
duration_bias=0.7,
|
||||
)
|
||||
|
||||
# Patch random to make all supersets rep-based despite high duration_bias
|
||||
import random
|
||||
original_random = random.random
|
||||
random.random = lambda: 0.99 # Always > duration_bias, so rep-based
|
||||
|
||||
try:
|
||||
supersets = gen._build_working_supersets(
|
||||
muscle_split, non_strength_type, wt_params_high_dur,
|
||||
)
|
||||
finally:
|
||||
random.random = original_random
|
||||
|
||||
# Now check warnings
|
||||
modality_warnings = [
|
||||
w for w in gen.warnings if 'Modality mismatch' in w
|
||||
]
|
||||
if supersets and any(ss.get('exercises') for ss in supersets):
|
||||
self.assertTrue(
|
||||
len(modality_warnings) > 0,
|
||||
f"Expected modality mismatch warning but got: {gen.warnings}",
|
||||
)
|
||||
|
||||
pref.delete()
|
||||
|
||||
def test_no_warning_when_duration_bias_low(self):
|
||||
"""When duration_bias < 0.6, no modality consistency warning
|
||||
should be emitted even if exercises are all rep-based."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
mock_exercises = [
|
||||
self._create_mock_exercise(f'Rep Exercise {i}', is_duration=False)
|
||||
for i in range(3)
|
||||
]
|
||||
gen.exercise_selector.select_exercises.return_value = mock_exercises
|
||||
gen.exercise_selector.balance_stretch_positions.return_value = mock_exercises
|
||||
|
||||
muscle_split = {
|
||||
'muscles': ['chest', 'back'],
|
||||
'split_type': 'full_body',
|
||||
'label': 'Full Body',
|
||||
}
|
||||
|
||||
# Use strength params (duration_bias = 0.0 < 0.6)
|
||||
wt_params = dict(WORKOUT_TYPE_DEFAULTS['traditional strength'])
|
||||
|
||||
supersets = gen._build_working_supersets(
|
||||
muscle_split, self.strength_type, wt_params,
|
||||
)
|
||||
|
||||
modality_warnings = [
|
||||
w for w in gen.warnings if 'Modality mismatch' in w
|
||||
]
|
||||
self.assertEqual(
|
||||
len(modality_warnings), 0,
|
||||
f"Should not have modality warning for low duration_bias but got: {modality_warnings}",
|
||||
)
|
||||
|
||||
pref.delete()
|
||||
|
||||
def test_no_warning_when_duration_ratio_sufficient(self):
|
||||
"""When duration_bias >= 0.6 and duration exercises >= 50%,
|
||||
no warning should be emitted."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
# Create mostly duration exercises
|
||||
duration_exercises = [
|
||||
self._create_mock_exercise(f'Duration Ex {i}', is_duration=True, is_weight=False)
|
||||
for i in range(4)
|
||||
]
|
||||
gen.exercise_selector.select_exercises.return_value = duration_exercises
|
||||
gen.exercise_selector.balance_stretch_positions.return_value = duration_exercises
|
||||
|
||||
muscle_split = {
|
||||
'muscles': ['chest', 'back'],
|
||||
'split_type': 'full_body',
|
||||
'label': 'Full Body',
|
||||
}
|
||||
wt_params = dict(WORKOUT_TYPE_DEFAULTS['hiit'])
|
||||
|
||||
supersets = gen._build_working_supersets(
|
||||
muscle_split, self.hiit_type, wt_params,
|
||||
)
|
||||
|
||||
modality_warnings = [
|
||||
w for w in gen.warnings if 'Modality mismatch' in w
|
||||
]
|
||||
self.assertEqual(
|
||||
len(modality_warnings), 0,
|
||||
f"Should not have modality warning when duration ratio is sufficient but got: {modality_warnings}",
|
||||
)
|
||||
|
||||
pref.delete()
|
||||
Reference in New Issue
Block a user