- Add Next.js rewrites to proxy API calls through same origin (fixes login/media on werkout.treytartt.com) - Fix mediaUrl() in DayCard and ExerciseRow to use relative paths in production - Add proxyTimeout for long-running workout generation endpoints - Add CSRF trusted origin for treytartt.com - Split docker-compose into production (Unraid) and dev configs - Show display_name and descriptions on workout type cards - Generator: rules engine improvements, movement enforcement, exercise selector updates - Add new test files for rules drift, workout research generation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
252 lines
8.8 KiB
Python
252 lines
8.8 KiB
Python
"""
|
|
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()
|