""" 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()