""" Tests for _pick_weekly_split() — Item #3: DB-backed WeeklySplitPattern selection. """ from collections import Counter 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, UserPreference, WeeklySplitPattern, WorkoutType, ) from generator.services.workout_generator import WorkoutGenerator, DEFAULT_SPLITS from registered_user.models import RegisteredUser User = get_user_model() class TestWeeklySplit(TestCase): """Tests for _pick_weekly_split() using DB-backed WeeklySplitPattern records.""" @classmethod def setUpTestData(cls): # Create Django auth user cls.auth_user = User.objects.create_user( username='testsplit', password='testpass123', ) cls.registered_user = RegisteredUser.objects.create( first_name='Test', last_name='Split', user=cls.auth_user, ) # Create MuscleGroupSplits cls.full_body = MuscleGroupSplit.objects.create( muscle_names=['chest', 'back', 'shoulders', 'quads', 'hamstrings'], label='Full Body', split_type='full_body', frequency=10, ) cls.upper = MuscleGroupSplit.objects.create( muscle_names=['chest', 'back', 'shoulders', 'biceps', 'triceps'], label='Upper', split_type='upper', frequency=8, ) cls.lower = MuscleGroupSplit.objects.create( muscle_names=['quads', 'hamstrings', 'glutes', 'calves'], label='Lower', split_type='lower', frequency=8, ) # Create patterns for 3 days/week cls.pattern_3day = WeeklySplitPattern.objects.create( days_per_week=3, pattern=[cls.full_body.pk, cls.upper.pk, cls.lower.pk], pattern_labels=['Full Body', 'Upper', 'Lower'], frequency=15, rest_day_positions=[3, 5, 6], ) cls.pattern_3day_low = WeeklySplitPattern.objects.create( days_per_week=3, pattern=[cls.upper.pk, cls.lower.pk, cls.full_body.pk], pattern_labels=['Upper', 'Lower', 'Full Body'], frequency=2, ) def _make_preference(self, days_per_week=3): """Create a UserPreference for testing.""" pref = UserPreference.objects.create( registered_user=self.registered_user, days_per_week=days_per_week, fitness_level=2, primary_goal='general_fitness', ) return pref def _make_generator(self, pref): """Create a WorkoutGenerator with mocked ExerciseSelector and PlanBuilder.""" with patch('generator.services.workout_generator.ExerciseSelector'), \ patch('generator.services.workout_generator.PlanBuilder'): gen = WorkoutGenerator(pref) return gen def test_uses_db_patterns_when_available(self): """When WeeklySplitPattern records exist for the days_per_week, _pick_weekly_split should return splits derived from them.""" pref = self._make_preference(days_per_week=3) gen = self._make_generator(pref) splits, rest_days = gen._pick_weekly_split() # Should have 3 splits (from the 3-day patterns) self.assertEqual(len(splits), 3) # Each split should have label, muscles, split_type for s in splits: self.assertIn('label', s) self.assertIn('muscles', s) self.assertIn('split_type', s) # Split types should come from our MuscleGroupSplit records split_types = {s['split_type'] for s in splits} self.assertTrue( split_types.issubset({'full_body', 'upper', 'lower'}), f"Unexpected split types: {split_types}", ) # Clean up pref.delete() def test_falls_back_to_defaults(self): """When no WeeklySplitPattern exists for the requested days_per_week, DEFAULT_SPLITS should be used.""" pref = self._make_preference(days_per_week=5) gen = self._make_generator(pref) splits, rest_days = gen._pick_weekly_split() # Should have 5 splits from DEFAULT_SPLITS[5] self.assertEqual(len(splits), len(DEFAULT_SPLITS[5])) # rest_days should be empty for default fallback self.assertEqual(rest_days, []) pref.delete() def test_frequency_weighting(self): """Higher-frequency patterns should be chosen more often.""" pref = self._make_preference(days_per_week=3) gen = self._make_generator(pref) first_pattern_count = 0 runs = 200 for _ in range(runs): splits, _ = gen._pick_weekly_split() # The high-frequency pattern starts with Full Body if splits[0]['label'] == 'Full Body': first_pattern_count += 1 # pattern_3day has frequency=15, pattern_3day_low has frequency=2 # Expected ratio: ~15/17 = ~88% # With 200 runs, high-freq pattern should be chosen at least 60% of the time ratio = first_pattern_count / runs self.assertGreater( ratio, 0.6, f"High-frequency pattern chosen only {ratio:.0%} of the time " f"(expected > 60%)", ) pref.delete() def test_rest_day_positions_propagated(self): """rest_day_positions from the chosen pattern should be returned.""" pref = self._make_preference(days_per_week=3) gen = self._make_generator(pref) # Run multiple times to ensure we eventually get the high-freq pattern found_rest_days = False for _ in range(50): splits, rest_days = gen._pick_weekly_split() if rest_days: found_rest_days = True # The high-freq pattern has rest_day_positions=[3, 5, 6] self.assertEqual(rest_days, [3, 5, 6]) break self.assertTrue( found_rest_days, "Expected rest_day_positions to be propagated from at least one run", ) pref.delete() def test_clamps_days_per_week(self): """days_per_week should be clamped to 1-7.""" pref = self._make_preference(days_per_week=10) gen = self._make_generator(pref) splits, _ = gen._pick_weekly_split() # clamped to 7, which uses DEFAULT_SPLITS[7] (no DB patterns for 7) self.assertEqual(len(splits), len(DEFAULT_SPLITS[7])) pref.delete() def test_handles_missing_muscle_group_split(self): """If a split_id in the pattern references a deleted MuscleGroupSplit, it should be gracefully skipped.""" # Create a pattern with one bogus ID bad_pattern = WeeklySplitPattern.objects.create( days_per_week=2, pattern=[self.full_body.pk, 99999], # 99999 doesn't exist pattern_labels=['Full Body', 'Missing'], frequency=10, ) pref = self._make_preference(days_per_week=2) gen = self._make_generator(pref) splits, _ = gen._pick_weekly_split() # Should get 1 split (the valid one) since the bad ID is skipped # But since we have 1 valid split, splits should be non-empty self.assertGreaterEqual(len(splits), 1) self.assertEqual(splits[0]['label'], 'Full Body') bad_pattern.delete() pref.delete() @patch('generator.services.workout_generator.random.random', return_value=0.0) def test_diversifies_repetitive_four_day_pattern(self, _mock_random): """ A 4-day DB pattern with 3 lower-body days should be diversified so split_type repetition does not dominate the week. """ lower_a = MuscleGroupSplit.objects.create( muscle_names=['glutes', 'hamstrings', 'core'], label='Lower A', split_type='lower', frequency=9, ) lower_b = MuscleGroupSplit.objects.create( muscle_names=['quads', 'glutes', 'calves'], label='Lower B', split_type='lower', frequency=9, ) WeeklySplitPattern.objects.create( days_per_week=4, pattern=[self.lower.pk, lower_a.pk, lower_b.pk, self.full_body.pk], pattern_labels=['Lower', 'Lower A', 'Lower B', 'Full Body'], frequency=50, ) pref = self._make_preference(days_per_week=4) gen = self._make_generator(pref) splits, _ = gen._pick_weekly_split() self.assertEqual(len(splits), 4) split_type_counts = Counter(s['split_type'] for s in splits) self.assertLessEqual( split_type_counts.get('lower', 0), 2, f"Expected diversification to avoid 3+ lower days, got: {split_type_counts}", ) pref.delete()