""" 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, [])