""" Tests for the rules engine: WORKOUT_TYPE_RULES coverage, validate_workout() error/warning detection, and quality gate retry logic. """ from unittest.mock import MagicMock, patch, PropertyMock from django.test import TestCase from generator.rules_engine import ( validate_workout, RuleViolation, WORKOUT_TYPE_RULES, UNIVERSAL_RULES, DB_CALIBRATION, _normalize_type_key, _classify_rep_weight, _has_warmup, _has_cooldown, _get_working_supersets, _count_push_pull, _check_compound_before_isolation, ) def _make_exercise(**kwargs): """Create a mock exercise object with the given attributes.""" defaults = { 'exercise_tier': 'accessory', 'is_reps': True, 'is_compound': False, 'is_weight': False, 'is_duration': False, 'movement_patterns': '', 'name': 'Test Exercise', 'stretch_position': None, 'difficulty_level': 'intermediate', 'complexity_rating': 3, 'hr_elevation_rating': 5, 'estimated_rep_duration': 3.0, } defaults.update(kwargs) ex = MagicMock() for k, v in defaults.items(): setattr(ex, k, v) return ex def _make_entry(exercise=None, reps=None, duration=None, order=1): """Create an exercise entry dict for a superset.""" entry = {'order': order} entry['exercise'] = exercise or _make_exercise() if reps is not None: entry['reps'] = reps if duration is not None: entry['duration'] = duration return entry def _make_superset(name='Working Set 1', exercises=None, rounds=3): """Create a superset dict.""" return { 'name': name, 'exercises': exercises or [], 'rounds': rounds, } class TestWorkoutTypeRulesCoverage(TestCase): """Verify that WORKOUT_TYPE_RULES covers all 8 workout types.""" def test_all_8_workout_types_have_rules(self): expected_types = [ 'traditional_strength_training', 'hypertrophy', 'hiit', 'functional_strength_training', 'cross_training', 'core_training', 'flexibility', 'cardio', ] for wt in expected_types: self.assertIn(wt, WORKOUT_TYPE_RULES, f"Missing rules for {wt}") def test_each_type_has_required_keys(self): required_keys = [ 'rep_ranges', 'rest_periods', 'duration_bias_range', 'superset_size_range', 'round_range', 'typical_rest', 'typical_intensity', ] for wt_name, rules in WORKOUT_TYPE_RULES.items(): for key in required_keys: self.assertIn( key, rules, f"Missing key '{key}' in rules for {wt_name}", ) def test_rep_ranges_have_all_tiers(self): for wt_name, rules in WORKOUT_TYPE_RULES.items(): rep_ranges = rules['rep_ranges'] for tier in ('primary', 'secondary', 'accessory'): self.assertIn( tier, rep_ranges, f"Missing rep range tier '{tier}' in {wt_name}", ) low, high = rep_ranges[tier] self.assertLessEqual( low, high, f"Invalid rep range ({low}, {high}) for {tier} in {wt_name}", ) class TestDBCalibrationCoverage(TestCase): """Verify DB_CALIBRATION has entries for all 8 types.""" def test_all_8_types_in_calibration(self): expected_names = [ 'Functional Strength Training', 'Traditional Strength Training', 'HIIT', 'Cross Training', 'Core Training', 'Flexibility', 'Cardio', 'Hypertrophy', ] for name in expected_names: self.assertIn(name, DB_CALIBRATION, f"Missing {name} in DB_CALIBRATION") class TestHelperFunctions(TestCase): """Test utility functions used by validate_workout.""" def test_normalize_type_key(self): self.assertEqual( _normalize_type_key('Traditional Strength Training'), 'traditional_strength_training', ) self.assertEqual(_normalize_type_key('HIIT'), 'hiit') self.assertEqual(_normalize_type_key('cardio'), 'cardio') def test_classify_rep_weight(self): self.assertEqual(_classify_rep_weight(3), 'heavy') self.assertEqual(_classify_rep_weight(5), 'heavy') self.assertEqual(_classify_rep_weight(8), 'moderate') self.assertEqual(_classify_rep_weight(12), 'light') def test_has_warmup(self): supersets = [ _make_superset(name='Warm Up'), _make_superset(name='Working Set 1'), ] self.assertTrue(_has_warmup(supersets)) self.assertFalse(_has_warmup([_make_superset(name='Working Set 1')])) def test_has_cooldown(self): supersets = [ _make_superset(name='Working Set 1'), _make_superset(name='Cool Down'), ] self.assertTrue(_has_cooldown(supersets)) self.assertFalse(_has_cooldown([_make_superset(name='Working Set 1')])) def test_get_working_supersets(self): supersets = [ _make_superset(name='Warm Up'), _make_superset(name='Working Set 1'), _make_superset(name='Working Set 2'), _make_superset(name='Cool Down'), ] working = _get_working_supersets(supersets) self.assertEqual(len(working), 2) self.assertEqual(working[0]['name'], 'Working Set 1') def test_count_push_pull(self): push_ex = _make_exercise(movement_patterns='upper push') pull_ex = _make_exercise(movement_patterns='upper pull') supersets = [ _make_superset( name='Working Set 1', exercises=[ _make_entry(exercise=push_ex, reps=8), _make_entry(exercise=pull_ex, reps=8), ], ), ] push_count, pull_count = _count_push_pull(supersets) self.assertEqual(push_count, 1) self.assertEqual(pull_count, 1) def test_compound_before_isolation_correct(self): compound = _make_exercise(is_compound=True, exercise_tier='primary') isolation = _make_exercise(is_compound=False, exercise_tier='accessory') supersets = [ _make_superset( name='Working Set 1', exercises=[ _make_entry(exercise=compound, reps=5, order=1), _make_entry(exercise=isolation, reps=12, order=2), ], ), ] self.assertTrue(_check_compound_before_isolation(supersets)) def test_compound_before_isolation_violated(self): compound = _make_exercise(is_compound=True, exercise_tier='primary') isolation = _make_exercise(is_compound=False, exercise_tier='accessory') supersets = [ _make_superset( name='Working Set 1', exercises=[ _make_entry(exercise=isolation, reps=12, order=1), ], ), _make_superset( name='Working Set 2', exercises=[ _make_entry(exercise=compound, reps=5, order=1), ], ), ] self.assertFalse(_check_compound_before_isolation(supersets)) class TestValidateWorkout(TestCase): """Test the main validate_workout function.""" def test_empty_workout_produces_error(self): violations = validate_workout({'supersets': []}, 'hiit', 'general_fitness') errors = [v for v in violations if v.severity == 'error'] self.assertTrue(len(errors) > 0) self.assertEqual(errors[0].rule_id, 'empty_workout') def test_validate_catches_rep_range_violation(self): """Strength workout with reps=20 on primary should produce error.""" workout_spec = { 'supersets': [ _make_superset( name='Working Set 1', exercises=[ _make_entry( exercise=_make_exercise( exercise_tier='primary', is_reps=True, ), reps=20, ), ], rounds=3, ), ], } violations = validate_workout( workout_spec, 'traditional_strength_training', 'strength', ) rep_errors = [ v for v in violations if v.severity == 'error' and 'rep_range' in v.rule_id ] self.assertTrue( len(rep_errors) > 0, f"Expected rep range error, got: {[v.rule_id for v in violations]}", ) def test_validate_passes_valid_strength_workout(self): """A well-formed strength workout with warmup + working + cooldown.""" workout_spec = { 'supersets': [ _make_superset( name='Warm Up', exercises=[ _make_entry( exercise=_make_exercise(is_reps=False), duration=30, ), ], rounds=1, ), _make_superset( name='Working Set 1', exercises=[ _make_entry( exercise=_make_exercise( exercise_tier='primary', is_reps=True, is_compound=True, is_weight=True, movement_patterns='upper push', ), reps=5, ), ], rounds=4, ), _make_superset( name='Cool Down', exercises=[ _make_entry( exercise=_make_exercise(is_reps=False), duration=30, ), ], rounds=1, ), ], } violations = validate_workout( workout_spec, 'traditional_strength_training', 'strength', ) errors = [v for v in violations if v.severity == 'error'] self.assertEqual( len(errors), 0, f"Unexpected errors: {[v.message for v in errors]}", ) def test_warmup_missing_produces_error(self): """Workout without warmup should produce an error.""" workout_spec = { 'supersets': [ _make_superset( name='Working Set 1', exercises=[ _make_entry( exercise=_make_exercise( exercise_tier='primary', is_reps=True, is_compound=True, is_weight=True, ), reps=5, ), ], rounds=4, ), ], } violations = validate_workout( workout_spec, 'traditional_strength_training', 'strength', ) warmup_errors = [ v for v in violations if v.rule_id == 'warmup_missing' ] self.assertEqual(len(warmup_errors), 1) def test_cooldown_missing_produces_warning(self): """Workout without cooldown should produce a warning.""" workout_spec = { 'supersets': [ _make_superset(name='Warm Up', exercises=[ _make_entry(exercise=_make_exercise(is_reps=False), duration=30), ], rounds=1), _make_superset( name='Working Set 1', exercises=[ _make_entry( exercise=_make_exercise( exercise_tier='primary', is_reps=True, is_compound=True, is_weight=True, ), reps=5, ), ], rounds=4, ), ], } violations = validate_workout( workout_spec, 'traditional_strength_training', 'strength', ) cooldown_warnings = [ v for v in violations if v.rule_id == 'cooldown_missing' ] self.assertEqual(len(cooldown_warnings), 1) self.assertEqual(cooldown_warnings[0].severity, 'warning') def test_push_pull_ratio_enforcement(self): """All push, no pull -> warning.""" push_exercises = [ _make_entry( exercise=_make_exercise( movement_patterns='upper push', is_compound=True, is_weight=True, exercise_tier='primary', ), reps=8, order=i + 1, ) for i in range(4) ] workout_spec = { 'supersets': [ _make_superset(name='Warm Up', exercises=[ _make_entry(exercise=_make_exercise(is_reps=False), duration=30), ], rounds=1), _make_superset( name='Working Set 1', exercises=push_exercises, rounds=3, ), _make_superset(name='Cool Down', exercises=[ _make_entry(exercise=_make_exercise(is_reps=False), duration=30), ], rounds=1), ], } violations = validate_workout( workout_spec, 'hypertrophy', 'hypertrophy', ) ratio_violations = [v for v in violations if v.rule_id == 'push_pull_ratio'] self.assertTrue( len(ratio_violations) > 0, "Expected push:pull ratio warning for all-push workout", ) def test_workout_type_match_violation(self): """Non-strength exercises in a strength workout should trigger match violation.""" # All duration-based, non-compound, non-weight exercises for strength workout_spec = { 'supersets': [ _make_superset(name='Warm Up', exercises=[ _make_entry(exercise=_make_exercise(is_reps=False), duration=30), ], rounds=1), _make_superset( name='Working Set 1', exercises=[ _make_entry( exercise=_make_exercise( exercise_tier='accessory', is_reps=True, is_compound=False, is_weight=False, ), reps=15, ) for _ in range(5) ], rounds=3, ), _make_superset(name='Cool Down', exercises=[ _make_entry(exercise=_make_exercise(is_reps=False), duration=30), ], rounds=1), ], } violations = validate_workout( workout_spec, 'traditional_strength_training', 'strength', ) match_violations = [ v for v in violations if v.rule_id == 'workout_type_match' ] self.assertTrue( len(match_violations) > 0, "Expected workout type match violation for non-strength exercises", ) def test_superset_size_warning(self): """Traditional strength with >5 exercises per superset should warn.""" many_exercises = [ _make_entry( exercise=_make_exercise( exercise_tier='accessory', is_reps=True, is_weight=True, is_compound=True, ), reps=5, order=i + 1, ) for i in range(8) ] workout_spec = { 'supersets': [ _make_superset(name='Warm Up', exercises=[ _make_entry(exercise=_make_exercise(is_reps=False), duration=30), ], rounds=1), _make_superset( name='Working Set 1', exercises=many_exercises, rounds=3, ), _make_superset(name='Cool Down', exercises=[ _make_entry(exercise=_make_exercise(is_reps=False), duration=30), ], rounds=1), ], } violations = validate_workout( workout_spec, 'traditional_strength_training', 'strength', ) size_violations = [ v for v in violations if v.rule_id == 'superset_size' ] self.assertTrue( len(size_violations) > 0, "Expected superset size warning for 8-exercise superset in strength", ) def test_compound_before_isolation_info(self): """Isolation before compound should produce info violation.""" isolation = _make_exercise( is_compound=False, exercise_tier='accessory', is_weight=True, is_reps=True, ) compound = _make_exercise( is_compound=True, exercise_tier='primary', is_weight=True, is_reps=True, ) workout_spec = { 'supersets': [ _make_superset(name='Warm Up', exercises=[ _make_entry(exercise=_make_exercise(is_reps=False), duration=30), ], rounds=1), _make_superset( name='Working Set 1', exercises=[ _make_entry(exercise=isolation, reps=12, order=1), ], rounds=3, ), _make_superset( name='Working Set 2', exercises=[ _make_entry(exercise=compound, reps=5, order=1), ], rounds=4, ), _make_superset(name='Cool Down', exercises=[ _make_entry(exercise=_make_exercise(is_reps=False), duration=30), ], rounds=1), ], } violations = validate_workout( workout_spec, 'hypertrophy', 'hypertrophy', ) order_violations = [ v for v in violations if v.rule_id == 'compound_before_isolation' ] self.assertTrue( len(order_violations) > 0, "Expected compound_before_isolation info for isolation-first order", ) def test_unknown_workout_type_does_not_crash(self): """An unknown workout type should not crash validation.""" workout_spec = { 'supersets': [ _make_superset(name='Warm Up', exercises=[ _make_entry(exercise=_make_exercise(is_reps=False), duration=30), ], rounds=1), _make_superset( name='Working Set 1', exercises=[_make_entry(reps=10)], rounds=3, ), _make_superset(name='Cool Down', exercises=[ _make_entry(exercise=_make_exercise(is_reps=False), duration=30), ], rounds=1), ], } violations = validate_workout( workout_spec, 'unknown_type', 'general_fitness', ) # Should not raise; may produce some violations but no crash self.assertIsInstance(violations, list) class TestRuleViolationDataclass(TestCase): """Test the RuleViolation dataclass.""" def test_basic_creation(self): v = RuleViolation( rule_id='test_rule', severity='error', message='Test message', ) self.assertEqual(v.rule_id, 'test_rule') self.assertEqual(v.severity, 'error') self.assertEqual(v.message, 'Test message') self.assertIsNone(v.actual_value) self.assertIsNone(v.expected_range) def test_with_values(self): v = RuleViolation( rule_id='rep_range_primary', severity='error', message='Reps out of range', actual_value=20, expected_range=(3, 6), ) self.assertEqual(v.actual_value, 20) self.assertEqual(v.expected_range, (3, 6)) class TestUniversalRules(TestCase): """Verify universal rules have expected values.""" def test_push_pull_ratio_min(self): self.assertEqual(UNIVERSAL_RULES['push_pull_ratio_min'], 1.0) def test_compound_before_isolation(self): self.assertTrue(UNIVERSAL_RULES['compound_before_isolation']) def test_warmup_mandatory(self): self.assertTrue(UNIVERSAL_RULES['warmup_mandatory']) def test_max_hiit_duration(self): self.assertEqual(UNIVERSAL_RULES['max_hiit_duration_min'], 30) def test_cooldown_stretch_only(self): self.assertTrue(UNIVERSAL_RULES['cooldown_stretch_only'])