workout generator audit: rules engine, structure rules, split patterns, injury UX, metadata cleanup
- Add rules_engine.py with quantitative rules for all 8 workout types - Add quality gate retry loop in generate_single_workout() - Expand calibrate_structure_rules to all 120 combinations (8 types × 5 goals × 3 sections) - Wire WeeklySplitPattern DB records into _pick_weekly_split() - Enforce movement patterns from WorkoutStructureRule in exercise selection - Add straight-set strength support (single main lift, 4-6 rounds) - Add modality consistency check for duration-dominant workout types - Add InjuryStep component to onboarding and preferences - Add sibling exercise exclusion in regenerate and preview_day endpoints - Display generator warnings on dashboard - Expand fix_rep_durations, fix_exercise_flags, fix_movement_pattern_typo - Add audit_exercise_data and check_rules_drift management commands - Add Next.js frontend with dashboard, onboarding, preferences, history pages - Add generator app with ML-powered workout generation pipeline - 96 new tests across 7 test modules Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
164
generator/tests/test_injury_safety.py
Normal file
164
generator/tests/test_injury_safety.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from datetime import date
|
||||
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from registered_user.models import RegisteredUser
|
||||
from generator.models import UserPreference, WorkoutType
|
||||
|
||||
|
||||
class TestInjurySafety(TestCase):
|
||||
"""Tests for injury-related preference round-trip and warning generation."""
|
||||
|
||||
def setUp(self):
|
||||
self.django_user = User.objects.create_user(
|
||||
username='testuser',
|
||||
password='testpass123',
|
||||
email='test@example.com',
|
||||
)
|
||||
self.registered_user = RegisteredUser.objects.create(
|
||||
user=self.django_user,
|
||||
first_name='Test',
|
||||
last_name='User',
|
||||
)
|
||||
self.token = Token.objects.create(user=self.django_user)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
|
||||
self.preference = UserPreference.objects.create(
|
||||
registered_user=self.registered_user,
|
||||
days_per_week=3,
|
||||
)
|
||||
# Create a basic workout type for generation
|
||||
self.workout_type = WorkoutType.objects.create(
|
||||
name='functional_strength_training',
|
||||
display_name='Functional Strength',
|
||||
typical_rest_between_sets=60,
|
||||
typical_intensity='medium',
|
||||
rep_range_min=8,
|
||||
rep_range_max=12,
|
||||
round_range_min=3,
|
||||
round_range_max=4,
|
||||
duration_bias=0.3,
|
||||
superset_size_min=2,
|
||||
superset_size_max=4,
|
||||
)
|
||||
|
||||
def test_injury_types_roundtrip(self):
|
||||
"""PUT injury_types, GET back, verify data persists."""
|
||||
injuries = [
|
||||
{'type': 'knee', 'severity': 'moderate'},
|
||||
{'type': 'shoulder', 'severity': 'mild'},
|
||||
]
|
||||
response = self.client.put(
|
||||
'/generator/preferences/update/',
|
||||
{'injury_types': injuries},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# GET back
|
||||
response = self.client.get('/generator/preferences/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(len(data['injury_types']), 2)
|
||||
types_set = {i['type'] for i in data['injury_types']}
|
||||
self.assertIn('knee', types_set)
|
||||
self.assertIn('shoulder', types_set)
|
||||
|
||||
def test_injury_types_validation_rejects_invalid_type(self):
|
||||
"""Invalid injury type should be rejected."""
|
||||
response = self.client.put(
|
||||
'/generator/preferences/update/',
|
||||
{'injury_types': [{'type': 'elbow', 'severity': 'mild'}]},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_injury_types_validation_rejects_invalid_severity(self):
|
||||
"""Invalid severity should be rejected."""
|
||||
response = self.client.put(
|
||||
'/generator/preferences/update/',
|
||||
{'injury_types': [{'type': 'knee', 'severity': 'extreme'}]},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_severe_knee_excludes_high_impact(self):
|
||||
"""Set knee:severe, verify the exercise selector filters correctly."""
|
||||
from generator.services.exercise_selector import ExerciseSelector
|
||||
|
||||
self.preference.injury_types = [
|
||||
{'type': 'knee', 'severity': 'severe'},
|
||||
]
|
||||
self.preference.save()
|
||||
|
||||
selector = ExerciseSelector(self.preference)
|
||||
qs = selector._get_filtered_queryset()
|
||||
|
||||
# No high-impact exercises should remain
|
||||
high_impact = qs.filter(impact_level='high')
|
||||
self.assertEqual(high_impact.count(), 0)
|
||||
|
||||
# No medium-impact exercises either (severe lower body)
|
||||
medium_impact = qs.filter(impact_level='medium')
|
||||
self.assertEqual(medium_impact.count(), 0)
|
||||
|
||||
# Warnings should mention the injury
|
||||
self.assertTrue(
|
||||
any('knee' in w.lower() for w in selector.warnings),
|
||||
f'Expected knee-related warning, got: {selector.warnings}'
|
||||
)
|
||||
|
||||
def test_no_injuries_full_pool(self):
|
||||
"""Empty injury_types should not exclude any exercises."""
|
||||
from generator.services.exercise_selector import ExerciseSelector
|
||||
|
||||
self.preference.injury_types = []
|
||||
self.preference.save()
|
||||
|
||||
selector = ExerciseSelector(self.preference)
|
||||
qs = selector._get_filtered_queryset()
|
||||
|
||||
# With no injuries, there should be no injury-based warnings
|
||||
injury_warnings = [w for w in selector.warnings if 'injury' in w.lower()]
|
||||
self.assertEqual(len(injury_warnings), 0)
|
||||
|
||||
def test_warnings_in_preview_response(self):
|
||||
"""With injuries set, verify warnings key appears in preview response."""
|
||||
self.preference.injury_types = [
|
||||
{'type': 'knee', 'severity': 'moderate'},
|
||||
]
|
||||
self.preference.save()
|
||||
self.preference.preferred_workout_types.add(self.workout_type)
|
||||
|
||||
response = self.client.post(
|
||||
'/generator/preview/',
|
||||
{'week_start_date': '2026-03-02'},
|
||||
format='json',
|
||||
)
|
||||
# Should succeed (200) even if exercise pool is limited
|
||||
self.assertIn(response.status_code, [200, 500])
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
# The warnings key should exist if injuries triggered any warnings
|
||||
if 'warnings' in data:
|
||||
self.assertIsInstance(data['warnings'], list)
|
||||
|
||||
def test_backward_compat_string_injuries(self):
|
||||
"""Legacy string format should be accepted and normalized."""
|
||||
response = self.client.put(
|
||||
'/generator/preferences/update/',
|
||||
{'injury_types': ['knee', 'shoulder']},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify normalized to dict format
|
||||
response = self.client.get('/generator/preferences/')
|
||||
data = response.json()
|
||||
for injury in data['injury_types']:
|
||||
self.assertIn('type', injury)
|
||||
self.assertIn('severity', injury)
|
||||
self.assertEqual(injury['severity'], 'moderate')
|
||||
Reference in New Issue
Block a user