Unraid deployment fixes and generator improvements
- Add Next.js rewrites to proxy API calls through same origin (fixes login/media on werkout.treytartt.com) - Fix mediaUrl() in DayCard and ExerciseRow to use relative paths in production - Add proxyTimeout for long-running workout generation endpoints - Add CSRF trusted origin for treytartt.com - Split docker-compose into production (Unraid) and dev configs - Show display_name and descriptions on workout type cards - Generator: rules engine improvements, movement enforcement, exercise selector updates - Add new test files for rules drift, workout research generation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
430
generator/tests/test_workout_research_generation.py
Normal file
430
generator/tests/test_workout_research_generation.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
Integration tests for research-backed workout generation.
|
||||
|
||||
These tests validate generated workouts against the expectations encoded from
|
||||
workout_research.md in generator.rules_engine.
|
||||
"""
|
||||
|
||||
import random
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, timedelta
|
||||
from itertools import combinations
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from equipment.models import Equipment
|
||||
from equipment.models import WorkoutEquipment
|
||||
from exercise.models import Exercise
|
||||
from generator.models import UserPreference, WorkoutType
|
||||
from generator.rules_engine import DB_CALIBRATION, validate_workout
|
||||
from generator.services.workout_generator import WorkoutGenerator
|
||||
from muscle.models import ExerciseMuscle, Muscle
|
||||
from registered_user.models import RegisteredUser
|
||||
|
||||
|
||||
@contextmanager
|
||||
def seeded_random(seed):
|
||||
"""Use a deterministic random seed without leaking global random state."""
|
||||
state = random.getstate()
|
||||
random.seed(seed)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
random.setstate(state)
|
||||
|
||||
|
||||
class TestWorkoutResearchGeneration(TestCase):
|
||||
"""
|
||||
TDD coverage for end-to-end generated workout quality:
|
||||
1) One workout per workout type
|
||||
2) Workouts for deterministic random workout-type pairs
|
||||
"""
|
||||
|
||||
MUSCLE_NAMES = [
|
||||
'chest',
|
||||
'upper back',
|
||||
'lats',
|
||||
'deltoids',
|
||||
'quads',
|
||||
'hamstrings',
|
||||
'glutes',
|
||||
'core',
|
||||
'biceps',
|
||||
'triceps',
|
||||
'calves',
|
||||
'forearms',
|
||||
'abs',
|
||||
'obliques',
|
||||
]
|
||||
|
||||
SPLITS_BY_TYPE = {
|
||||
'traditional_strength_training': {
|
||||
'label': 'Strength Day',
|
||||
'muscles': ['quads', 'hamstrings', 'glutes', 'core'],
|
||||
'split_type': 'lower',
|
||||
},
|
||||
'hypertrophy': {
|
||||
'label': 'Hypertrophy Day',
|
||||
'muscles': ['chest', 'upper back', 'deltoids', 'biceps', 'triceps'],
|
||||
'split_type': 'upper',
|
||||
},
|
||||
'high_intensity_interval_training': {
|
||||
'label': 'HIIT Day',
|
||||
'muscles': ['chest', 'upper back', 'quads', 'core'],
|
||||
'split_type': 'full_body',
|
||||
},
|
||||
'functional_strength_training': {
|
||||
'label': 'Functional Day',
|
||||
'muscles': ['chest', 'upper back', 'quads', 'hamstrings', 'core'],
|
||||
'split_type': 'full_body',
|
||||
},
|
||||
'cross_training': {
|
||||
'label': 'Cross Day',
|
||||
'muscles': ['chest', 'upper back', 'quads', 'core'],
|
||||
'split_type': 'full_body',
|
||||
},
|
||||
'core_training': {
|
||||
'label': 'Core Day',
|
||||
'muscles': ['abs', 'obliques', 'core'],
|
||||
'split_type': 'core',
|
||||
},
|
||||
'flexibility': {
|
||||
'label': 'Mobility Day',
|
||||
'muscles': ['hamstrings', 'glutes', 'core'],
|
||||
'split_type': 'full_body',
|
||||
},
|
||||
'cardio': {
|
||||
'label': 'Cardio Day',
|
||||
'muscles': ['quads', 'calves', 'core'],
|
||||
'split_type': 'cardio',
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
User = get_user_model()
|
||||
auth_user = User.objects.create_user(
|
||||
username='research_gen',
|
||||
password='testpass123',
|
||||
)
|
||||
cls.registered_user = RegisteredUser.objects.create(
|
||||
first_name='Research',
|
||||
last_name='Generator',
|
||||
user=auth_user,
|
||||
)
|
||||
|
||||
# Keep equipment filtering permissive without triggering "no equipment" fallback warnings.
|
||||
cls.bodyweight = Equipment.objects.create(
|
||||
name='Bodyweight',
|
||||
category='none',
|
||||
is_weight=False,
|
||||
)
|
||||
|
||||
cls.preference = UserPreference.objects.create(
|
||||
registered_user=cls.registered_user,
|
||||
days_per_week=5,
|
||||
fitness_level=2,
|
||||
primary_goal='general_fitness',
|
||||
secondary_goal='',
|
||||
preferred_workout_duration=90,
|
||||
)
|
||||
cls.preference.available_equipment.add(cls.bodyweight)
|
||||
|
||||
cls.muscles = {}
|
||||
for name in cls.MUSCLE_NAMES:
|
||||
cls.muscles[name] = Muscle.objects.create(name=name)
|
||||
|
||||
cls.workout_types = {}
|
||||
for wt_name, fields in DB_CALIBRATION.items():
|
||||
wt, _ = WorkoutType.objects.get_or_create(
|
||||
name=wt_name,
|
||||
defaults={
|
||||
'display_name': wt_name.replace('_', ' ').title(),
|
||||
'description': f'Calibrated {wt_name}',
|
||||
**fields,
|
||||
},
|
||||
)
|
||||
# Keep DB values aligned with calibration regardless of fixtures/migrations.
|
||||
update_fields = []
|
||||
for field_name, field_value in fields.items():
|
||||
if getattr(wt, field_name) != field_value:
|
||||
setattr(wt, field_name, field_value)
|
||||
update_fields.append(field_name)
|
||||
if update_fields:
|
||||
wt.save(update_fields=update_fields)
|
||||
cls.workout_types[wt_name] = wt
|
||||
cls.preference.preferred_workout_types.add(wt)
|
||||
|
||||
# Populate all workout-structure expectations for all goals/sections.
|
||||
call_command('calibrate_structure_rules')
|
||||
|
||||
cls._seed_exercise_pool()
|
||||
|
||||
@classmethod
|
||||
def _create_exercise(
|
||||
cls,
|
||||
name,
|
||||
movement_patterns,
|
||||
*,
|
||||
is_weight,
|
||||
is_duration,
|
||||
is_reps,
|
||||
is_compound,
|
||||
exercise_tier='secondary',
|
||||
hr_elevation_rating=6,
|
||||
complexity_rating=3,
|
||||
difficulty_level='intermediate',
|
||||
stretch_position='mid',
|
||||
):
|
||||
ex = Exercise.objects.create(
|
||||
name=name,
|
||||
movement_patterns=movement_patterns,
|
||||
muscle_groups=', '.join(cls.MUSCLE_NAMES),
|
||||
is_weight=is_weight,
|
||||
is_duration=is_duration,
|
||||
is_reps=is_reps,
|
||||
is_compound=is_compound,
|
||||
exercise_tier=exercise_tier,
|
||||
hr_elevation_rating=hr_elevation_rating,
|
||||
complexity_rating=complexity_rating,
|
||||
difficulty_level=difficulty_level,
|
||||
stretch_position=stretch_position,
|
||||
estimated_rep_duration=3.0,
|
||||
)
|
||||
# Attach broad muscle mappings so split filtering has high coverage.
|
||||
for muscle in cls.muscles.values():
|
||||
ExerciseMuscle.objects.create(exercise=ex, muscle=muscle)
|
||||
return ex
|
||||
|
||||
@classmethod
|
||||
def _seed_exercise_pool(cls):
|
||||
working_patterns = [
|
||||
'lower push - squat, lower push, upper push, upper pull, core',
|
||||
'lower pull - hip hinge, lower pull, upper push, upper pull, core',
|
||||
'upper push - horizontal, upper push, upper pull, core',
|
||||
'upper pull - horizontal, upper pull, upper push, core',
|
||||
'upper push - vertical, upper push, upper pull, core',
|
||||
'upper pull - vertical, upper pull, upper push, core',
|
||||
'carry, core, lower push, upper pull',
|
||||
'cardio/locomotion, upper push, upper pull, core',
|
||||
'plyometric, lower push, upper pull, upper push, core',
|
||||
'arms, upper push, upper pull, core',
|
||||
]
|
||||
|
||||
duration_patterns = [
|
||||
'cardio/locomotion, upper push, upper pull, core',
|
||||
'plyometric, upper push, upper pull, lower push, core',
|
||||
'core - anti-extension, cardio/locomotion, upper push, upper pull',
|
||||
'core - anti-rotation, cardio/locomotion, upper push, upper pull',
|
||||
'core - anti-lateral flexion, cardio/locomotion, upper push, upper pull',
|
||||
]
|
||||
|
||||
for idx in range(60):
|
||||
cls._create_exercise(
|
||||
name=f'Engine Move {idx + 1:02d}',
|
||||
movement_patterns=working_patterns[idx % len(working_patterns)],
|
||||
is_weight=True,
|
||||
is_duration=False,
|
||||
is_reps=True,
|
||||
is_compound=True,
|
||||
exercise_tier='secondary',
|
||||
hr_elevation_rating=6,
|
||||
)
|
||||
|
||||
for idx in range(40):
|
||||
cls._create_exercise(
|
||||
name=f'Interval Move {idx + 1:02d}',
|
||||
movement_patterns=duration_patterns[idx % len(duration_patterns)],
|
||||
is_weight=False,
|
||||
is_duration=True,
|
||||
is_reps=True,
|
||||
is_compound=True,
|
||||
exercise_tier='secondary',
|
||||
hr_elevation_rating=8,
|
||||
)
|
||||
|
||||
for idx in range(14):
|
||||
cls._create_exercise(
|
||||
name=f'Warmup Flow {idx + 1:02d}',
|
||||
movement_patterns='dynamic stretch, activation, mobility, warm up',
|
||||
is_weight=False,
|
||||
is_duration=True,
|
||||
is_reps=False,
|
||||
is_compound=False,
|
||||
exercise_tier='accessory',
|
||||
hr_elevation_rating=3,
|
||||
complexity_rating=2,
|
||||
stretch_position='lengthened',
|
||||
)
|
||||
|
||||
for idx in range(14):
|
||||
cls._create_exercise(
|
||||
name=f'Cooldown Stretch {idx + 1:02d}',
|
||||
movement_patterns='static stretch, mobility, yoga, cool down',
|
||||
is_weight=False,
|
||||
is_duration=True,
|
||||
is_reps=False,
|
||||
is_compound=False,
|
||||
exercise_tier='accessory',
|
||||
hr_elevation_rating=2,
|
||||
complexity_rating=2,
|
||||
stretch_position='lengthened',
|
||||
)
|
||||
|
||||
def _set_goal(self, goal):
|
||||
self.preference.primary_goal = goal
|
||||
self.preference.secondary_goal = ''
|
||||
self.preference.save(update_fields=['primary_goal', 'secondary_goal'])
|
||||
|
||||
def _generate_workout_for_type(self, wt_name, *, seed, goal='general_fitness', day_offset=0):
|
||||
self._set_goal(goal)
|
||||
generator = WorkoutGenerator(self.preference, duration_override=90)
|
||||
split = dict(self.SPLITS_BY_TYPE[wt_name])
|
||||
with seeded_random(seed):
|
||||
workout = generator.generate_single_workout(
|
||||
muscle_split=split,
|
||||
workout_type=self.workout_types[wt_name],
|
||||
scheduled_date=date(2026, 3, 2) + timedelta(days=day_offset),
|
||||
)
|
||||
return workout, list(generator.warnings)
|
||||
|
||||
def _assert_research_alignment(self, workout_spec, wt_name, goal, context, generation_warnings=None):
|
||||
violations = validate_workout(workout_spec, wt_name, goal)
|
||||
blocking = [v for v in violations if v.severity in {'error', 'warning'}]
|
||||
|
||||
messages = [f'[{v.severity}] {v.rule_id}: {v.message}' for v in violations]
|
||||
self.assertEqual(
|
||||
len(blocking),
|
||||
0,
|
||||
(
|
||||
f'{context} failed strict research validation for {wt_name}/{goal}. '
|
||||
f'Violations: {messages}'
|
||||
),
|
||||
)
|
||||
|
||||
working = [
|
||||
ss for ss in workout_spec.get('supersets', [])
|
||||
if ss.get('name', '').startswith('Working')
|
||||
]
|
||||
self.assertGreaterEqual(
|
||||
len(working), 1,
|
||||
f'{context} should have at least one working superset.',
|
||||
)
|
||||
|
||||
if generation_warnings is not None:
|
||||
self.assertEqual(
|
||||
generation_warnings,
|
||||
[],
|
||||
f'{context} emitted generation warnings: {generation_warnings}',
|
||||
)
|
||||
|
||||
def test_generate_one_workout_for_each_type_matches_research(self):
|
||||
"""
|
||||
Generate one workout per workout type and ensure each passes
|
||||
research-backed rules validation.
|
||||
"""
|
||||
for idx, wt_name in enumerate(DB_CALIBRATION.keys(), start=1):
|
||||
workout, generation_warnings = self._generate_workout_for_type(
|
||||
wt_name,
|
||||
seed=7000 + idx,
|
||||
goal='general_fitness',
|
||||
day_offset=idx,
|
||||
)
|
||||
self._assert_research_alignment(
|
||||
workout,
|
||||
wt_name,
|
||||
'general_fitness',
|
||||
context='single-type generation',
|
||||
generation_warnings=generation_warnings,
|
||||
)
|
||||
|
||||
def test_generate_deterministic_random_workout_type_pairs(self):
|
||||
"""
|
||||
Generate workouts for deterministic random pairs of workout types.
|
||||
Each workout in every pair must satisfy research-backed rules.
|
||||
"""
|
||||
all_pairs = list(combinations(DB_CALIBRATION.keys(), 2))
|
||||
rng = random.Random(20260223)
|
||||
sampled_pairs = rng.sample(all_pairs, 8)
|
||||
|
||||
for pair_idx, (wt_a, wt_b) in enumerate(sampled_pairs):
|
||||
workout_a, warnings_a = self._generate_workout_for_type(
|
||||
wt_a,
|
||||
seed=8100 + pair_idx * 10,
|
||||
goal='general_fitness',
|
||||
day_offset=pair_idx * 2,
|
||||
)
|
||||
self._assert_research_alignment(
|
||||
workout_a,
|
||||
wt_a,
|
||||
'general_fitness',
|
||||
context=f'random-pair[{pair_idx}] first',
|
||||
generation_warnings=warnings_a,
|
||||
)
|
||||
|
||||
workout_b, warnings_b = self._generate_workout_for_type(
|
||||
wt_b,
|
||||
seed=8100 + pair_idx * 10 + 1,
|
||||
goal='general_fitness',
|
||||
day_offset=pair_idx * 2 + 1,
|
||||
)
|
||||
self._assert_research_alignment(
|
||||
workout_b,
|
||||
wt_b,
|
||||
'general_fitness',
|
||||
context=f'random-pair[{pair_idx}] second',
|
||||
generation_warnings=warnings_b,
|
||||
)
|
||||
|
||||
def test_generation_honors_exclusions_and_equipment_preferences(self):
|
||||
"""Generated workouts should not include excluded exercises or unavailable equipment."""
|
||||
wt_name = 'functional_strength_training'
|
||||
wt = self.workout_types[wt_name]
|
||||
|
||||
# Restrict user to only Bodyweight equipment and exclude one candidate exercise.
|
||||
self.preference.available_equipment.clear()
|
||||
self.preference.available_equipment.add(self.bodyweight)
|
||||
excluded = Exercise.objects.filter(name='Engine Move 01').first()
|
||||
self.assertIsNotNone(excluded)
|
||||
self.preference.excluded_exercises.add(excluded)
|
||||
|
||||
workout, generation_warnings = self._generate_workout_for_type(
|
||||
wt_name,
|
||||
seed=9401,
|
||||
goal='general_fitness',
|
||||
day_offset=10,
|
||||
)
|
||||
|
||||
all_exercises = []
|
||||
for ss in workout.get('supersets', []):
|
||||
for entry in ss.get('exercises', []):
|
||||
ex = entry.get('exercise')
|
||||
if ex is not None:
|
||||
all_exercises.append(ex)
|
||||
|
||||
self.assertTrue(all_exercises, 'Expected at least one exercise in generated workout.')
|
||||
self.assertNotIn(
|
||||
excluded.pk,
|
||||
{ex.pk for ex in all_exercises},
|
||||
'Excluded exercise was found in generated workout.',
|
||||
)
|
||||
|
||||
ex_ids = [ex.pk for ex in all_exercises]
|
||||
available_equipment_ids = {self.bodyweight.pk}
|
||||
requirements = {}
|
||||
for ex_id, eq_id in WorkoutEquipment.objects.filter(
|
||||
exercise_id__in=ex_ids,
|
||||
).values_list('exercise_id', 'equipment_id'):
|
||||
requirements.setdefault(ex_id, set()).add(eq_id)
|
||||
bad_equipment = [
|
||||
ex_id for ex_id, required_ids in requirements.items()
|
||||
if required_ids and not required_ids.issubset(available_equipment_ids)
|
||||
]
|
||||
self.assertEqual(
|
||||
bad_equipment,
|
||||
[],
|
||||
f'Found exercises requiring unavailable equipment: {bad_equipment}',
|
||||
)
|
||||
self.assertEqual(generation_warnings, [])
|
||||
Reference in New Issue
Block a user