""" 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 datetime import date 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 ( FINAL_CONFORMANCE_MAX_RETRIES, WorkoutGenerator, STRENGTH_WORKOUT_TYPES, WORKOUT_TYPE_DEFAULTS, ) from generator.rules_engine import RuleViolation, validate_workout 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, ) cls.functional_type, _ = WorkoutType.objects.get_or_create( name='functional_strength_training', defaults={ 'typical_rest_between_sets': 60, 'typical_intensity': 'high', 'rep_range_min': 6, 'rep_range_max': 12, 'duration_bias': 0.2, 'superset_size_min': 2, 'superset_size_max': 4, }, ) cls.core_type = WorkoutType.objects.filter(name='core_training').first() if cls.core_type is None: cls.core_type = WorkoutType.objects.create( name='core_training', typical_rest_between_sets=30, typical_intensity='medium', rep_range_min=10, rep_range_max=20, duration_bias=0.5, superset_size_min=3, superset_size_max=5, ) # 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() def test_working_superset_filters_stretch_entries_and_keeps_positive_rest(self): """Working supersets should never keep static stretch entries.""" pref = self._make_preference() gen = self._make_generator(pref) valid = self._create_mock_exercise( name='Barbell Clean Pull', movement_patterns='upper pull,hip hinge', is_duration=False, is_reps=True, is_weight=True, is_compound=True, ) stretch = self._create_mock_exercise( name='Supine Pec Stretch - T', movement_patterns='mobility - static, static stretch, cool down', is_duration=True, is_reps=False, is_weight=False, is_compound=False, exercise_tier='accessory', ) gen.exercise_selector.select_exercises.return_value = [valid, stretch] gen.exercise_selector.balance_stretch_positions.return_value = [valid, stretch] muscle_split = { 'muscles': ['chest', 'upper back'], 'split_type': 'upper', 'label': 'Upper', } wt_params = dict(WORKOUT_TYPE_DEFAULTS['functional strength']) wt_params['num_supersets'] = (1, 1) wt_params['exercises_per_superset'] = (2, 2) wt_params['rounds'] = (4, 4) supersets = gen._build_working_supersets( muscle_split, self.functional_type, wt_params, ) self.assertTrue(supersets, 'Expected at least one working superset.') ss = supersets[0] exercise_names = [e['exercise'].name for e in ss.get('exercises', [])] self.assertIn('Barbell Clean Pull', exercise_names) self.assertNotIn('Supine Pec Stretch - T', exercise_names) self.assertGreater(ss.get('rest_between_rounds', 0), 0) pref.delete() def test_retries_when_superset_has_duplicate_focus(self): """Generator should retry when a working superset repeats focus family.""" pref = self._make_preference() gen = self._make_generator(pref) curl_a = self._create_mock_exercise( 'Alternating Bicep Curls', movement_patterns='upper pull', is_compound=False, exercise_tier='accessory', ) curl_b = self._create_mock_exercise( 'Bicep Curls', movement_patterns='upper pull', is_compound=False, exercise_tier='accessory', ) pull = self._create_mock_exercise('Bent Over Row', movement_patterns='upper pull') hinge = self._create_mock_exercise('Romanian Deadlift', movement_patterns='hip hinge') gen.exercise_selector.select_exercises.side_effect = [ [curl_a, curl_b], # rejected: duplicate focus [pull, hinge], # accepted ] gen.exercise_selector.balance_stretch_positions.side_effect = lambda exs, **_: exs muscle_split = { 'muscles': ['upper back', 'biceps'], 'split_type': 'pull', 'label': 'Pull', } wt_params = dict(WORKOUT_TYPE_DEFAULTS['hiit']) wt_params['num_supersets'] = (1, 1) wt_params['exercises_per_superset'] = (2, 2) wt_params['duration_bias'] = 0.0 supersets = gen._build_working_supersets(muscle_split, self.hiit_type, wt_params) self.assertEqual(len(supersets), 1) self.assertGreaterEqual(gen.exercise_selector.select_exercises.call_count, 2) names = [ entry['exercise'].name for entry in supersets[0].get('exercises', []) ] self.assertNotEqual( set(names), {'Alternating Bicep Curls', 'Bicep Curls'}, f'Expected duplicate-focus superset to be retried, got {names}', ) 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: # Retries may add extra calls; assert at least one non-first # working-superset request asks for 2+ exercises. observed_counts = [] for call in gen.exercise_selector.select_exercises.call_args_list: count_arg = call.kwargs.get('count') if count_arg is None and len(call.args) > 1: count_arg = call.args[1] if count_arg is not None: observed_counts.append(count_arg) self.assertTrue( any(c >= 2 for c in observed_counts), f"Expected at least one accessory superset request >=2 exercises, got {observed_counts}", ) 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() def test_strength_first_superset_survives_post_processing(self): """generate_single_workout should preserve first strength straight set.""" pref = self._make_preference(primary_goal='strength') gen = self._make_generator(pref) main_lift = self._create_mock_exercise('Back Squat', exercise_tier='primary') accessory_1 = self._create_mock_exercise('DB Row', exercise_tier='secondary') accessory_2 = self._create_mock_exercise('RDL', exercise_tier='secondary') accessory_3 = self._create_mock_exercise('Lat Pulldown', exercise_tier='accessory') gen._build_warmup = MagicMock(return_value=None) gen._build_cooldown = MagicMock(return_value=None) gen._check_quality_gates = MagicMock(return_value=[]) gen._get_final_conformance_violations = MagicMock(return_value=[]) gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec) gen._build_working_supersets = MagicMock(return_value=[ { 'name': 'Working Set 1', 'rounds': 5, 'rest_between_rounds': 120, 'modality': 'reps', 'exercises': [ {'exercise': main_lift, 'reps': 5, 'order': 1}, ], }, { 'name': 'Working Set 2', 'rounds': 3, 'rest_between_rounds': 90, 'modality': 'reps', 'exercises': [ {'exercise': accessory_1, 'reps': 10, 'order': 1}, {'exercise': accessory_2, 'reps': 10, 'order': 2}, {'exercise': accessory_3, 'reps': 12, 'order': 3}, ], }, ]) muscle_split = { 'muscles': ['quads', 'hamstrings'], 'split_type': 'lower', 'label': 'Lower', } workout_spec = gen.generate_single_workout( muscle_split=muscle_split, workout_type=self.strength_type, scheduled_date=date(2026, 3, 2), ) working = [ ss for ss in workout_spec.get('supersets', []) if ss.get('name', '').startswith('Working') ] self.assertGreaterEqual(len(working), 1) self.assertEqual( len(working[0].get('exercises', [])), 1, f'Expected first strength working set to stay at 1 exercise, got: {working[0]}', ) 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() class TestFinalConformance(MovementEnforcementTestBase): """Strict final conformance enforcement for assembled workouts.""" def test_core_workout_respects_type_max_exercise_cap(self): """Core workouts should be trimmed to the calibrated max (8 working exercises).""" pref = self._make_preference(primary_goal='general_fitness') gen = self._make_generator(pref) gen._build_warmup = MagicMock(return_value=None) gen._build_cooldown = MagicMock(return_value=None) gen._check_quality_gates = MagicMock(return_value=[]) gen._get_final_conformance_violations = MagicMock(return_value=[]) gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec) working_exercises = [ {'exercise': self._create_mock_exercise(f'Core Push {i}', movement_patterns='upper push, core'), 'reps': 12, 'order': i + 1} for i in range(6) ] more_working_exercises = [ {'exercise': self._create_mock_exercise(f'Core Pull {i}', movement_patterns='upper pull, core'), 'reps': 12, 'order': i + 1} for i in range(6) ] gen._build_working_supersets = MagicMock(return_value=[ { 'name': 'Working Set 1', 'rounds': 3, 'rest_between_rounds': 30, 'modality': 'reps', 'exercises': working_exercises, }, { 'name': 'Working Set 2', 'rounds': 3, 'rest_between_rounds': 30, 'modality': 'reps', 'exercises': more_working_exercises, }, ]) workout_spec = gen.generate_single_workout( muscle_split={ 'muscles': ['core', 'abs', 'obliques'], 'split_type': 'core', 'label': 'Core Day', }, workout_type=self.core_type, scheduled_date=date(2026, 3, 2), ) working = [ ss for ss in workout_spec.get('supersets', []) if ss.get('name', '').startswith('Working') ] total_working = sum(len(ss.get('exercises', [])) for ss in working) self.assertLessEqual( total_working, 8, f'Expected core workout to cap at 8 working exercises, got {total_working}', ) pref.delete() def test_core_cap_removes_extra_minimum_supersets(self): """When all sets are already at minimum size, remove trailing sets to hit cap.""" pref = self._make_preference(primary_goal='general_fitness') gen = self._make_generator(pref) gen._build_warmup = MagicMock(return_value=None) gen._build_cooldown = MagicMock(return_value=None) gen._check_quality_gates = MagicMock(return_value=[]) gen._get_final_conformance_violations = MagicMock(return_value=[]) gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec) working_supersets = [] for idx in range(6): push = self._create_mock_exercise( f'Push {idx}', movement_patterns='upper push', ) pull = self._create_mock_exercise( f'Pull {idx}', movement_patterns='upper pull', ) working_supersets.append({ 'name': f'Working Set {idx + 1}', 'rounds': 3, 'rest_between_rounds': 30, 'modality': 'reps', 'exercises': [ {'exercise': push, 'reps': 12, 'order': 1}, {'exercise': pull, 'reps': 12, 'order': 2}, ], }) gen._build_working_supersets = MagicMock(return_value=working_supersets) workout_spec = gen.generate_single_workout( muscle_split={ 'muscles': ['core', 'abs', 'obliques'], 'split_type': 'core', 'label': 'Core Day', }, workout_type=self.core_type, scheduled_date=date(2026, 3, 2), ) working = [ ss for ss in workout_spec.get('supersets', []) if ss.get('name', '').startswith('Working') ] total_working = sum(len(ss.get('exercises', [])) for ss in working) self.assertLessEqual(total_working, 8) self.assertLessEqual(len(working), 4) pref.delete() def test_pad_to_fill_respects_type_cap(self): """Padding should stop when workout-type max working-exercise cap is reached.""" pref = self._make_preference(primary_goal='general_fitness') gen = self._make_generator(pref) gen._estimate_total_time = MagicMock(return_value=0) gen.exercise_selector.select_exercises.return_value = [ self._create_mock_exercise('Pad Exercise', movement_patterns='upper pull') ] base_ex_a = self._create_mock_exercise('Base A', movement_patterns='upper push') base_ex_b = self._create_mock_exercise('Base B', movement_patterns='upper pull') workout_spec = { 'supersets': [ { 'name': 'Working Set 1', 'rounds': 3, 'rest_between_rounds': 30, 'modality': 'reps', 'exercises': [ {'exercise': base_ex_a, 'reps': 12, 'order': 1}, {'exercise': base_ex_b, 'reps': 12, 'order': 2}, {'exercise': base_ex_a, 'reps': 12, 'order': 3}, ], }, { 'name': 'Working Set 2', 'rounds': 3, 'rest_between_rounds': 30, 'modality': 'reps', 'exercises': [ {'exercise': base_ex_b, 'reps': 12, 'order': 1}, {'exercise': base_ex_a, 'reps': 12, 'order': 2}, {'exercise': base_ex_b, 'reps': 12, 'order': 3}, ], }, ], } wt_params = dict(WORKOUT_TYPE_DEFAULTS['core']) wt_params['duration_bias'] = 0.0 padded = gen._pad_to_fill( workout_spec=workout_spec, max_duration_sec=3600, muscle_split={ 'muscles': ['core', 'abs'], 'split_type': 'core', 'label': 'Core Day', }, wt_params=wt_params, workout_type=self.core_type, ) total_working = sum( len(ss.get('exercises', [])) for ss in padded.get('supersets', []) if ss.get('name', '').startswith('Working') ) self.assertLessEqual(total_working, 8) pref.delete() def test_compound_ordering_uses_validator_definition(self): """Accessory-tagged entries should not be treated as compounds in ordering.""" pref = self._make_preference(primary_goal='general_fitness') gen = self._make_generator(pref) accessory_flagged_compound = self._create_mock_exercise( 'Accessory Marked Compound', is_compound=True, exercise_tier='accessory', movement_patterns='upper push', ) true_compound = self._create_mock_exercise( 'Primary Compound', is_compound=True, exercise_tier='secondary', movement_patterns='upper pull', ) workout_spec = { 'supersets': [ { 'name': 'Working Set 1', 'rounds': 3, 'rest_between_rounds': 45, 'modality': 'reps', 'exercises': [ {'exercise': accessory_flagged_compound, 'reps': 10, 'order': 1}, {'exercise': true_compound, 'reps': 8, 'order': 2}, ], }, ], } gen._enforce_compound_first_order(workout_spec, is_strength_workout=False) violations = validate_workout(workout_spec, 'hiit', 'general_fitness') compound_order_violations = [ v for v in violations if v.rule_id == 'compound_before_isolation' ] self.assertEqual(len(compound_order_violations), 0) pref.delete() def test_final_warning_triggers_regeneration(self): """A final warning should trigger full regeneration before returning.""" pref = self._make_preference() gen = self._make_generator(pref) gen._build_warmup = MagicMock(return_value=None) gen._build_cooldown = MagicMock(return_value=None) gen._check_quality_gates = MagicMock(return_value=[]) gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec) ex = self._create_mock_exercise('Balanced Pull', movement_patterns='upper pull') gen._build_working_supersets = MagicMock(return_value=[ { 'name': 'Working Set 1', 'rounds': 3, 'rest_between_rounds': 45, 'modality': 'reps', 'exercises': [{'exercise': ex, 'reps': 10, 'order': 1}], }, ]) gen._get_final_conformance_violations = MagicMock(side_effect=[ [RuleViolation( rule_id='exercise_count_cap', severity='warning', message='Too many exercises', )], [], ]) gen.generate_single_workout( muscle_split={ 'muscles': ['upper back', 'lats'], 'split_type': 'pull', 'label': 'Pull Day', }, workout_type=self.hiit_type, scheduled_date=date(2026, 3, 3), ) self.assertEqual( gen._build_working_supersets.call_count, 2, 'Expected regeneration after final warning.', ) pref.delete() def test_unresolved_final_violations_raise_error(self): """Generator should fail fast when conformance cannot be achieved.""" pref = self._make_preference() gen = self._make_generator(pref) gen._build_warmup = MagicMock(return_value=None) gen._build_cooldown = MagicMock(return_value=None) gen._check_quality_gates = MagicMock(return_value=[]) gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec) ex = self._create_mock_exercise('Push Only', movement_patterns='upper push') gen._build_working_supersets = MagicMock(return_value=[ { 'name': 'Working Set 1', 'rounds': 3, 'rest_between_rounds': 45, 'modality': 'reps', 'exercises': [{'exercise': ex, 'reps': 10, 'order': 1}], }, ]) gen._get_final_conformance_violations = MagicMock(return_value=[ RuleViolation( rule_id='push_pull_ratio', severity='warning', message='Pull:push ratio too low', ), ]) with self.assertRaises(ValueError): gen.generate_single_workout( muscle_split={ 'muscles': ['chest', 'triceps'], 'split_type': 'push', 'label': 'Push Day', }, workout_type=self.hiit_type, scheduled_date=date(2026, 3, 4), ) self.assertEqual( gen._build_working_supersets.call_count, FINAL_CONFORMANCE_MAX_RETRIES + 1, ) pref.delete() def test_info_violation_is_not_blocking(self): """Info-level rules should not fail generation in strict mode.""" pref = self._make_preference() gen = self._make_generator(pref) gen._build_warmup = MagicMock(return_value=None) gen._build_cooldown = MagicMock(return_value=None) gen._check_quality_gates = MagicMock(return_value=[]) gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec) ex = self._create_mock_exercise('Compound Lift', movement_patterns='upper pull') gen._build_working_supersets = MagicMock(return_value=[ { 'name': 'Working Set 1', 'rounds': 3, 'rest_between_rounds': 45, 'modality': 'reps', 'exercises': [{'exercise': ex, 'reps': 8, 'order': 1}], }, ]) gen._get_final_conformance_violations = MagicMock(return_value=[ RuleViolation( rule_id='compound_before_isolation', severity='info', message='Compound exercises should generally appear before isolation.', ), ]) workout = gen.generate_single_workout( muscle_split={ 'muscles': ['upper back'], 'split_type': 'pull', 'label': 'Pull Day', }, workout_type=self.strength_type, scheduled_date=date(2026, 3, 5), ) self.assertIsInstance(workout, dict) self.assertEqual(gen._build_working_supersets.call_count, 1) pref.delete() def test_side_pair_warning_filtered_when_final_workout_has_no_side_entries(self): """Do not surface side-pair warnings when final workout has no sided exercises.""" pref = self._make_preference() gen = self._make_generator(pref) gen._build_warmup = MagicMock(return_value=None) gen._build_cooldown = MagicMock(return_value=None) gen._check_quality_gates = MagicMock(return_value=[]) gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec) ex = self._create_mock_exercise('Compound Lift', movement_patterns='upper pull') ex.side = '' gen._build_working_supersets = MagicMock(return_value=[ { 'name': 'Working Set 1', 'rounds': 3, 'rest_between_rounds': 45, 'modality': 'reps', 'exercises': [{'exercise': ex, 'reps': 8, 'order': 1}], }, ]) gen._get_final_conformance_violations = MagicMock(return_value=[]) gen.exercise_selector.warnings = [ 'Added 2 missing opposite-side exercise partners.', 'Removed 1 unpaired side-specific exercises to enforce left/right pairing.', 'Could only find 3/5 exercises for deltoids.', ] gen.generate_single_workout( muscle_split={ 'muscles': ['upper back'], 'split_type': 'pull', 'label': 'Pull Day', }, workout_type=self.strength_type, scheduled_date=date(2026, 3, 6), ) self.assertTrue( any('Could only find 3/5 exercises for deltoids.' in w for w in gen.warnings) ) self.assertFalse( any('opposite-side' in w.lower() or 'side-specific' in w.lower() for w in gen.warnings) ) pref.delete()