Unraid deployment fixes and generator improvements
- 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>
This commit is contained in:
@@ -18,7 +18,8 @@ from generator.rules_engine import DB_CALIBRATION
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
'Check for drift between research doc calibration values '
|
||||
'and WorkoutType DB records. Exits 1 if mismatches found.'
|
||||
'and WorkoutType DB records. Exits 1 if mismatches, missing '
|
||||
'types, or zero fields checked.'
|
||||
)
|
||||
|
||||
# Fields to compare between DB_CALIBRATION and WorkoutType model
|
||||
@@ -73,14 +74,32 @@ class Command(BaseCommand):
|
||||
self.stdout.write('')
|
||||
|
||||
if missing_in_db:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f'Missing from DB ({len(missing_in_db)}):'
|
||||
))
|
||||
for name in missing_in_db:
|
||||
self.stdout.write(f' - {name}')
|
||||
self.stdout.write('')
|
||||
|
||||
has_errors = False
|
||||
|
||||
if checked == 0:
|
||||
has_errors = True
|
||||
self.stdout.write(self.style.ERROR(
|
||||
'No calibration fields were checked. '
|
||||
'DB_CALIBRATION keys likely do not match WorkoutType.name values.'
|
||||
))
|
||||
self.stdout.write('')
|
||||
|
||||
if missing_in_db:
|
||||
has_errors = True
|
||||
self.stdout.write(self.style.ERROR(
|
||||
'Missing workout types prevent full drift validation.'
|
||||
))
|
||||
self.stdout.write('')
|
||||
|
||||
if mismatches:
|
||||
has_errors = True
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f'DRIFT DETECTED: {len(mismatches)} mismatch(es)'
|
||||
))
|
||||
@@ -98,8 +117,9 @@ class Command(BaseCommand):
|
||||
'To fix: update WorkoutType records in the DB or '
|
||||
'update DB_CALIBRATION in generator/rules_engine.py.'
|
||||
))
|
||||
if has_errors:
|
||||
sys.exit(1)
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
'No drift detected. DB values match research calibration.'
|
||||
))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
'No drift detected. DB values match research calibration.'
|
||||
))
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
from django.core.management import call_command
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
WORKOUT_TYPE_CALIBRATION = {
|
||||
'functional_strength_training': {
|
||||
'typical_rest_between_sets': 60,
|
||||
'typical_intensity': 'medium',
|
||||
'rep_range_min': 8,
|
||||
'rep_range_max': 15,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 4,
|
||||
'duration_bias': 0.15,
|
||||
'superset_size_min': 2,
|
||||
'superset_size_max': 4,
|
||||
},
|
||||
'traditional_strength_training': {
|
||||
'typical_rest_between_sets': 120,
|
||||
'typical_intensity': 'high',
|
||||
'rep_range_min': 4,
|
||||
'rep_range_max': 8,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 5,
|
||||
'duration_bias': 0.1,
|
||||
'superset_size_min': 1,
|
||||
'superset_size_max': 3,
|
||||
},
|
||||
'high_intensity_interval_training': {
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'high',
|
||||
'rep_range_min': 10,
|
||||
'rep_range_max': 20,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 5,
|
||||
'duration_bias': 0.7,
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 6,
|
||||
},
|
||||
'cross_training': {
|
||||
'typical_rest_between_sets': 45,
|
||||
'typical_intensity': 'high',
|
||||
'rep_range_min': 8,
|
||||
'rep_range_max': 15,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 5,
|
||||
'duration_bias': 0.4,
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 5,
|
||||
},
|
||||
'core_training': {
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'medium',
|
||||
'rep_range_min': 10,
|
||||
'rep_range_max': 20,
|
||||
'round_range_min': 2,
|
||||
'round_range_max': 4,
|
||||
'duration_bias': 0.5,
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 5,
|
||||
},
|
||||
'flexibility': {
|
||||
'typical_rest_between_sets': 15,
|
||||
'typical_intensity': 'low',
|
||||
'rep_range_min': 1,
|
||||
'rep_range_max': 5,
|
||||
'round_range_min': 1,
|
||||
'round_range_max': 2,
|
||||
'duration_bias': 0.9,
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 6,
|
||||
},
|
||||
'cardio': {
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'medium',
|
||||
'rep_range_min': 1,
|
||||
'rep_range_max': 1,
|
||||
'round_range_min': 1,
|
||||
'round_range_max': 3,
|
||||
'duration_bias': 1.0,
|
||||
'superset_size_min': 1,
|
||||
'superset_size_max': 3,
|
||||
},
|
||||
'hypertrophy': {
|
||||
'typical_rest_between_sets': 90,
|
||||
'typical_intensity': 'high',
|
||||
'rep_range_min': 8,
|
||||
'rep_range_max': 15,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 4,
|
||||
'duration_bias': 0.2,
|
||||
'superset_size_min': 2,
|
||||
'superset_size_max': 4,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def apply_calibration(apps, schema_editor):
|
||||
WorkoutType = apps.get_model('generator', 'WorkoutType')
|
||||
|
||||
for type_name, fields in WORKOUT_TYPE_CALIBRATION.items():
|
||||
defaults = dict(fields)
|
||||
defaults.setdefault('display_name', type_name.replace('_', ' ').title())
|
||||
defaults.setdefault('description', '')
|
||||
WorkoutType.objects.update_or_create(name=type_name, defaults=defaults)
|
||||
|
||||
# Ensure the full 8 x 5 x 3 = 120 structure-rule matrix is present and calibrated.
|
||||
call_command('calibrate_structure_rules')
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
# Intentionally no-op: this migration normalizes live calibration data.
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generator', '0005_add_periodization_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(apply_calibration, noop_reverse),
|
||||
]
|
||||
121
generator/migrations/0007_force_recalibration.py
Normal file
121
generator/migrations/0007_force_recalibration.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from django.core.management import call_command
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
WORKOUT_TYPE_CALIBRATION = {
|
||||
'functional_strength_training': {
|
||||
'typical_rest_between_sets': 60,
|
||||
'typical_intensity': 'medium',
|
||||
'rep_range_min': 8,
|
||||
'rep_range_max': 15,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 4,
|
||||
'duration_bias': 0.15,
|
||||
'superset_size_min': 2,
|
||||
'superset_size_max': 4,
|
||||
},
|
||||
'traditional_strength_training': {
|
||||
'typical_rest_between_sets': 120,
|
||||
'typical_intensity': 'high',
|
||||
'rep_range_min': 4,
|
||||
'rep_range_max': 8,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 5,
|
||||
'duration_bias': 0.1,
|
||||
'superset_size_min': 1,
|
||||
'superset_size_max': 3,
|
||||
},
|
||||
'high_intensity_interval_training': {
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'high',
|
||||
'rep_range_min': 10,
|
||||
'rep_range_max': 20,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 5,
|
||||
'duration_bias': 0.7,
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 6,
|
||||
},
|
||||
'cross_training': {
|
||||
'typical_rest_between_sets': 45,
|
||||
'typical_intensity': 'high',
|
||||
'rep_range_min': 8,
|
||||
'rep_range_max': 15,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 5,
|
||||
'duration_bias': 0.4,
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 5,
|
||||
},
|
||||
'core_training': {
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'medium',
|
||||
'rep_range_min': 10,
|
||||
'rep_range_max': 20,
|
||||
'round_range_min': 2,
|
||||
'round_range_max': 4,
|
||||
'duration_bias': 0.5,
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 5,
|
||||
},
|
||||
'flexibility': {
|
||||
'typical_rest_between_sets': 15,
|
||||
'typical_intensity': 'low',
|
||||
'rep_range_min': 1,
|
||||
'rep_range_max': 5,
|
||||
'round_range_min': 1,
|
||||
'round_range_max': 2,
|
||||
'duration_bias': 0.9,
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 6,
|
||||
},
|
||||
'cardio': {
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'medium',
|
||||
'rep_range_min': 1,
|
||||
'rep_range_max': 1,
|
||||
'round_range_min': 1,
|
||||
'round_range_max': 3,
|
||||
'duration_bias': 1.0,
|
||||
'superset_size_min': 1,
|
||||
'superset_size_max': 3,
|
||||
},
|
||||
'hypertrophy': {
|
||||
'typical_rest_between_sets': 90,
|
||||
'typical_intensity': 'high',
|
||||
'rep_range_min': 8,
|
||||
'rep_range_max': 15,
|
||||
'round_range_min': 3,
|
||||
'round_range_max': 4,
|
||||
'duration_bias': 0.2,
|
||||
'superset_size_min': 2,
|
||||
'superset_size_max': 4,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def apply_calibration(apps, schema_editor):
|
||||
WorkoutType = apps.get_model('generator', 'WorkoutType')
|
||||
|
||||
for type_name, fields in WORKOUT_TYPE_CALIBRATION.items():
|
||||
defaults = dict(fields)
|
||||
defaults.setdefault('display_name', type_name.replace('_', ' ').title())
|
||||
defaults.setdefault('description', '')
|
||||
WorkoutType.objects.update_or_create(name=type_name, defaults=defaults)
|
||||
|
||||
call_command('calibrate_structure_rules')
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generator', '0006_calibrate_workout_types_and_structure_rules'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(apply_calibration, noop_reverse),
|
||||
]
|
||||
@@ -11,6 +11,8 @@ from typing import List, Optional, Dict, Any, Tuple
|
||||
|
||||
import logging
|
||||
|
||||
from generator.services.exercise_selector import extract_movement_families
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -88,7 +90,7 @@ WORKOUT_TYPE_RULES: Dict[str, Dict[str, Any]] = {
|
||||
# ------------------------------------------------------------------
|
||||
# 3. HIIT
|
||||
# ------------------------------------------------------------------
|
||||
'hiit': {
|
||||
'high_intensity_interval_training': {
|
||||
'rep_ranges': {
|
||||
'primary': (10, 20),
|
||||
'secondary': (10, 20),
|
||||
@@ -275,7 +277,7 @@ UNIVERSAL_RULES: Dict[str, Any] = {
|
||||
# ======================================================================
|
||||
|
||||
DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'Functional Strength Training': {
|
||||
'functional_strength_training': {
|
||||
'duration_bias': 0.15,
|
||||
'typical_rest_between_sets': 60,
|
||||
'typical_intensity': 'medium',
|
||||
@@ -286,7 +288,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 2,
|
||||
'superset_size_max': 4,
|
||||
},
|
||||
'Traditional Strength Training': {
|
||||
'traditional_strength_training': {
|
||||
'duration_bias': 0.1,
|
||||
'typical_rest_between_sets': 120,
|
||||
'typical_intensity': 'high',
|
||||
@@ -297,7 +299,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 1,
|
||||
'superset_size_max': 3,
|
||||
},
|
||||
'HIIT': {
|
||||
'high_intensity_interval_training': {
|
||||
'duration_bias': 0.7,
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'high',
|
||||
@@ -308,7 +310,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 6,
|
||||
},
|
||||
'Cross Training': {
|
||||
'cross_training': {
|
||||
'duration_bias': 0.4,
|
||||
'typical_rest_between_sets': 45,
|
||||
'typical_intensity': 'high',
|
||||
@@ -319,7 +321,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 5,
|
||||
},
|
||||
'Core Training': {
|
||||
'core_training': {
|
||||
'duration_bias': 0.5,
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'medium',
|
||||
@@ -330,7 +332,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 5,
|
||||
},
|
||||
'Flexibility': {
|
||||
'flexibility': {
|
||||
'duration_bias': 0.9,
|
||||
'typical_rest_between_sets': 15,
|
||||
'typical_intensity': 'low',
|
||||
@@ -341,7 +343,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 6,
|
||||
},
|
||||
'Cardio': {
|
||||
'cardio': {
|
||||
'duration_bias': 1.0,
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'medium',
|
||||
@@ -352,7 +354,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 1,
|
||||
'superset_size_max': 3,
|
||||
},
|
||||
'Hypertrophy': {
|
||||
'hypertrophy': {
|
||||
'duration_bias': 0.2,
|
||||
'typical_rest_between_sets': 90,
|
||||
'typical_intensity': 'high',
|
||||
@@ -366,13 +368,24 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
# Canonical key aliases for workout type names. This lets callers pass
|
||||
# legacy/short names while still resolving to DB-style identifiers.
|
||||
WORKOUT_TYPE_KEY_ALIASES: Dict[str, str] = {
|
||||
'hiit': 'high_intensity_interval_training',
|
||||
}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Validation helpers
|
||||
# ======================================================================
|
||||
|
||||
def _normalize_type_key(name: str) -> str:
|
||||
"""Convert a workout type name to the underscore key used in WORKOUT_TYPE_RULES."""
|
||||
return name.strip().lower().replace(' ', '_')
|
||||
"""Convert a workout type name to the canonical key in WORKOUT_TYPE_RULES."""
|
||||
if not name:
|
||||
return ''
|
||||
normalized = name.strip().lower().replace('-', '_').replace(' ', '_')
|
||||
normalized = '_'.join(part for part in normalized.split('_') if part)
|
||||
return WORKOUT_TYPE_KEY_ALIASES.get(normalized, normalized)
|
||||
|
||||
|
||||
def _classify_rep_weight(reps: int) -> str:
|
||||
@@ -457,6 +470,21 @@ def _check_compound_before_isolation(supersets: list) -> bool:
|
||||
return not compound_after_isolation
|
||||
|
||||
|
||||
def _focus_key_for_entry(entry: dict) -> Optional[str]:
|
||||
"""Derive a coarse focus key from an entry's exercise."""
|
||||
ex = entry.get('exercise')
|
||||
if ex is None:
|
||||
return None
|
||||
families = sorted(extract_movement_families(getattr(ex, 'name', '') or ''))
|
||||
if families:
|
||||
return families[0]
|
||||
patterns = (getattr(ex, 'movement_patterns', '') or '').lower()
|
||||
for token in ('upper pull', 'upper push', 'hip hinge', 'squat', 'lunge', 'core', 'carry'):
|
||||
if token in patterns:
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Main validation function
|
||||
# ======================================================================
|
||||
@@ -623,7 +651,53 @@ def validate_workout(
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Compound before isolation ordering
|
||||
# 5. Focus spread across working supersets
|
||||
# ------------------------------------------------------------------
|
||||
if working:
|
||||
for ss in working:
|
||||
seen_focus = set()
|
||||
duplicate_focus = set()
|
||||
for entry in ss.get('exercises', []):
|
||||
focus_key = _focus_key_for_entry(entry)
|
||||
if not focus_key:
|
||||
continue
|
||||
if focus_key in seen_focus:
|
||||
duplicate_focus.add(focus_key)
|
||||
seen_focus.add(focus_key)
|
||||
if duplicate_focus:
|
||||
violations.append(RuleViolation(
|
||||
rule_id='superset_focus_repetition',
|
||||
severity='error',
|
||||
message=(
|
||||
f"Superset '{ss.get('name')}' repeats focus area(s): "
|
||||
f"{', '.join(sorted(duplicate_focus))}."
|
||||
),
|
||||
actual_value=sorted(duplicate_focus),
|
||||
))
|
||||
|
||||
# Advisory: same dominant focus in adjacent working supersets.
|
||||
previous_focus = None
|
||||
for ss in working:
|
||||
focus_keys = {
|
||||
_focus_key_for_entry(entry)
|
||||
for entry in ss.get('exercises', [])
|
||||
}
|
||||
focus_keys.discard(None)
|
||||
if previous_focus is not None and focus_keys and focus_keys == previous_focus:
|
||||
violations.append(RuleViolation(
|
||||
rule_id='adjacent_superset_focus_repetition',
|
||||
severity='info',
|
||||
message=(
|
||||
f"Adjacent supersets reuse the same focus profile "
|
||||
f"({', '.join(sorted(focus_keys))}); spread focus when possible."
|
||||
),
|
||||
actual_value=sorted(focus_keys),
|
||||
))
|
||||
if focus_keys:
|
||||
previous_focus = focus_keys
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Compound before isolation ordering
|
||||
# ------------------------------------------------------------------
|
||||
if UNIVERSAL_RULES['compound_before_isolation']:
|
||||
if not _check_compound_before_isolation(supersets):
|
||||
@@ -634,7 +708,7 @@ def validate_workout(
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Warmup check
|
||||
# 7. Warmup check
|
||||
# ------------------------------------------------------------------
|
||||
if UNIVERSAL_RULES['warmup_mandatory']:
|
||||
if not _has_warmup(supersets):
|
||||
@@ -645,7 +719,7 @@ def validate_workout(
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. Cooldown check
|
||||
# 8. Cooldown check
|
||||
# ------------------------------------------------------------------
|
||||
if not _has_cooldown(supersets):
|
||||
violations.append(RuleViolation(
|
||||
@@ -655,9 +729,9 @@ def validate_workout(
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. HIIT duration cap
|
||||
# 9. HIIT duration cap
|
||||
# ------------------------------------------------------------------
|
||||
if wt_key == 'hiit':
|
||||
if wt_key == 'high_intensity_interval_training':
|
||||
max_hiit_min = UNIVERSAL_RULES.get('max_hiit_duration_min', 30)
|
||||
# Estimate total working time from working supersets
|
||||
total_working_exercises = sum(
|
||||
@@ -683,7 +757,7 @@ def validate_workout(
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. Total exercise count cap
|
||||
# 10. Total exercise count cap
|
||||
# ------------------------------------------------------------------
|
||||
max_exercises = wt_rules.get(
|
||||
'max_exercises_per_session',
|
||||
@@ -706,7 +780,7 @@ def validate_workout(
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 10. Workout type match percentage (refactored from _validate_workout_type_match)
|
||||
# 11. Workout type match percentage (refactored from _validate_workout_type_match)
|
||||
# ------------------------------------------------------------------
|
||||
_STRENGTH_TYPES = {
|
||||
'traditional_strength_training', 'functional_strength_training',
|
||||
|
||||
@@ -889,6 +889,8 @@ class ExerciseSelector:
|
||||
selected_names = set()
|
||||
# Intra-superset family tracking
|
||||
selected_family_groups = set() # group names used in this superset
|
||||
selected_families = set() # exact families used in this superset
|
||||
selected_family_counts = Counter() # exact family counts in this superset
|
||||
|
||||
# Shuffle to break any ordering bias
|
||||
random.shuffle(pool)
|
||||
@@ -910,8 +912,14 @@ class ExerciseSelector:
|
||||
|
||||
for fam in candidate_families:
|
||||
# Cross-workout: check family count limit
|
||||
total_count = self.used_movement_families.get(fam, 0)
|
||||
if total_count >= self._get_family_limit(fam):
|
||||
historical_count = self.used_movement_families.get(fam, 0)
|
||||
in_superset_count = selected_family_counts.get(fam, 0)
|
||||
if historical_count + in_superset_count >= self._get_family_limit(fam):
|
||||
blocked = True
|
||||
break
|
||||
|
||||
# Intra-superset: avoid exact family duplicates entirely.
|
||||
if fam in selected_families:
|
||||
blocked = True
|
||||
break
|
||||
|
||||
@@ -930,6 +938,8 @@ class ExerciseSelector:
|
||||
selected_names.add(candidate_name)
|
||||
# Track family groups for intra-superset blocking
|
||||
for fam in candidate_families:
|
||||
selected_families.add(fam)
|
||||
selected_family_counts[fam] += 1
|
||||
group = _FAMILY_TO_GROUP.get(fam)
|
||||
if group:
|
||||
selected_family_groups.add(group)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
56
generator/tests/test_check_rules_drift.py
Normal file
56
generator/tests/test_check_rules_drift.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from generator.models import WorkoutType
|
||||
from generator.rules_engine import DB_CALIBRATION
|
||||
|
||||
|
||||
class TestCheckRulesDriftCommand(TestCase):
|
||||
"""Tests for the strict drift-check command behavior."""
|
||||
|
||||
@staticmethod
|
||||
def _sync_workout_type(name, values):
|
||||
wt, _ = WorkoutType.objects.get_or_create(
|
||||
name=name,
|
||||
defaults={
|
||||
'display_name': name.replace('_', ' ').title(),
|
||||
'description': f'Calibrated {name}',
|
||||
**values,
|
||||
},
|
||||
)
|
||||
update_fields = []
|
||||
for field_name, field_value in values.items():
|
||||
if getattr(wt, field_name) != field_value:
|
||||
setattr(wt, field_name, field_value)
|
||||
update_fields.append(field_name)
|
||||
if update_fields:
|
||||
wt.save(update_fields=update_fields)
|
||||
return wt
|
||||
|
||||
def test_passes_when_all_types_match(self):
|
||||
for type_name, values in DB_CALIBRATION.items():
|
||||
self._sync_workout_type(type_name, values)
|
||||
|
||||
# Should not raise SystemExit when everything matches.
|
||||
call_command('check_rules_drift', verbosity=0)
|
||||
|
||||
def test_fails_when_type_missing(self):
|
||||
for type_name, values in DB_CALIBRATION.items():
|
||||
self._sync_workout_type(type_name, values)
|
||||
WorkoutType.objects.filter(name='cardio').delete()
|
||||
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
call_command('check_rules_drift', verbosity=0)
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
|
||||
def test_fails_when_value_mismatch(self):
|
||||
for type_name, values in DB_CALIBRATION.items():
|
||||
self._sync_workout_type(type_name, values)
|
||||
|
||||
target = WorkoutType.objects.get(name='hypertrophy')
|
||||
target.typical_rest_between_sets = 999
|
||||
target.save(update_fields=['typical_rest_between_sets'])
|
||||
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
call_command('check_rules_drift', verbosity=0)
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
@@ -4,6 +4,7 @@ Tests for _build_working_supersets() — Items #4, #6, #7:
|
||||
- Modality consistency check (duration_bias warning)
|
||||
- Straight-set strength (first superset = single main lift)
|
||||
"""
|
||||
from datetime import date
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from unittest.mock import patch, MagicMock, PropertyMock
|
||||
@@ -16,10 +17,12 @@ from generator.models import (
|
||||
WorkoutType,
|
||||
)
|
||||
from generator.services.workout_generator import (
|
||||
FINAL_CONFORMANCE_MAX_RETRIES,
|
||||
WorkoutGenerator,
|
||||
STRENGTH_WORKOUT_TYPES,
|
||||
WORKOUT_TYPE_DEFAULTS,
|
||||
)
|
||||
from generator.rules_engine import RuleViolation, validate_workout
|
||||
from registered_user.models import RegisteredUser
|
||||
|
||||
User = get_user_model()
|
||||
@@ -58,6 +61,18 @@ class MovementEnforcementTestBase(TestCase):
|
||||
superset_size_min=3,
|
||||
superset_size_max=6,
|
||||
)
|
||||
cls.core_type = WorkoutType.objects.filter(name='core_training').first()
|
||||
if cls.core_type is None:
|
||||
cls.core_type = WorkoutType.objects.create(
|
||||
name='core_training',
|
||||
typical_rest_between_sets=30,
|
||||
typical_intensity='medium',
|
||||
rep_range_min=10,
|
||||
rep_range_max=20,
|
||||
duration_bias=0.5,
|
||||
superset_size_min=3,
|
||||
superset_size_max=5,
|
||||
)
|
||||
|
||||
# Create MovementPatternOrder records
|
||||
MovementPatternOrder.objects.create(
|
||||
@@ -169,6 +184,58 @@ class TestMovementPatternEnforcement(MovementEnforcementTestBase):
|
||||
|
||||
pref.delete()
|
||||
|
||||
def test_retries_when_superset_has_duplicate_focus(self):
|
||||
"""Generator should retry when a working superset repeats focus family."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
curl_a = self._create_mock_exercise(
|
||||
'Alternating Bicep Curls',
|
||||
movement_patterns='upper pull',
|
||||
is_compound=False,
|
||||
exercise_tier='accessory',
|
||||
)
|
||||
curl_b = self._create_mock_exercise(
|
||||
'Bicep Curls',
|
||||
movement_patterns='upper pull',
|
||||
is_compound=False,
|
||||
exercise_tier='accessory',
|
||||
)
|
||||
pull = self._create_mock_exercise('Bent Over Row', movement_patterns='upper pull')
|
||||
hinge = self._create_mock_exercise('Romanian Deadlift', movement_patterns='hip hinge')
|
||||
|
||||
gen.exercise_selector.select_exercises.side_effect = [
|
||||
[curl_a, curl_b], # rejected: duplicate focus
|
||||
[pull, hinge], # accepted
|
||||
]
|
||||
gen.exercise_selector.balance_stretch_positions.side_effect = lambda exs, **_: exs
|
||||
|
||||
muscle_split = {
|
||||
'muscles': ['upper back', 'biceps'],
|
||||
'split_type': 'pull',
|
||||
'label': 'Pull',
|
||||
}
|
||||
wt_params = dict(WORKOUT_TYPE_DEFAULTS['hiit'])
|
||||
wt_params['num_supersets'] = (1, 1)
|
||||
wt_params['exercises_per_superset'] = (2, 2)
|
||||
wt_params['duration_bias'] = 0.0
|
||||
|
||||
supersets = gen._build_working_supersets(muscle_split, self.hiit_type, wt_params)
|
||||
self.assertEqual(len(supersets), 1)
|
||||
self.assertGreaterEqual(gen.exercise_selector.select_exercises.call_count, 2)
|
||||
|
||||
names = [
|
||||
entry['exercise'].name
|
||||
for entry in supersets[0].get('exercises', [])
|
||||
]
|
||||
self.assertNotEqual(
|
||||
set(names),
|
||||
{'Alternating Bicep Curls', 'Bicep Curls'},
|
||||
f'Expected duplicate-focus superset to be retried, got {names}',
|
||||
)
|
||||
|
||||
pref.delete()
|
||||
|
||||
|
||||
class TestStrengthStraightSets(MovementEnforcementTestBase):
|
||||
"""Item #7: First working superset in strength = single main lift."""
|
||||
@@ -288,13 +355,19 @@ class TestStrengthStraightSets(MovementEnforcementTestBase):
|
||||
|
||||
# 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)
|
||||
# Retries may add extra calls; assert at least one non-first
|
||||
# working-superset request asks for 2+ exercises.
|
||||
observed_counts = []
|
||||
for call in gen.exercise_selector.select_exercises.call_args_list:
|
||||
count_arg = call.kwargs.get('count')
|
||||
if count_arg is None and len(call.args) > 1:
|
||||
count_arg = call.args[1]
|
||||
if count_arg is not None:
|
||||
observed_counts.append(count_arg)
|
||||
self.assertTrue(
|
||||
any(c >= 2 for c in observed_counts),
|
||||
f"Expected at least one accessory superset request >=2 exercises, got {observed_counts}",
|
||||
)
|
||||
|
||||
pref.delete()
|
||||
|
||||
@@ -330,6 +403,68 @@ class TestStrengthStraightSets(MovementEnforcementTestBase):
|
||||
|
||||
pref.delete()
|
||||
|
||||
def test_strength_first_superset_survives_post_processing(self):
|
||||
"""generate_single_workout should preserve first strength straight set."""
|
||||
pref = self._make_preference(primary_goal='strength')
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
main_lift = self._create_mock_exercise('Back Squat', exercise_tier='primary')
|
||||
accessory_1 = self._create_mock_exercise('DB Row', exercise_tier='secondary')
|
||||
accessory_2 = self._create_mock_exercise('RDL', exercise_tier='secondary')
|
||||
accessory_3 = self._create_mock_exercise('Lat Pulldown', exercise_tier='accessory')
|
||||
|
||||
gen._build_warmup = MagicMock(return_value=None)
|
||||
gen._build_cooldown = MagicMock(return_value=None)
|
||||
gen._check_quality_gates = MagicMock(return_value=[])
|
||||
gen._get_final_conformance_violations = MagicMock(return_value=[])
|
||||
gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec)
|
||||
gen._build_working_supersets = MagicMock(return_value=[
|
||||
{
|
||||
'name': 'Working Set 1',
|
||||
'rounds': 5,
|
||||
'rest_between_rounds': 120,
|
||||
'modality': 'reps',
|
||||
'exercises': [
|
||||
{'exercise': main_lift, 'reps': 5, 'order': 1},
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': 'Working Set 2',
|
||||
'rounds': 3,
|
||||
'rest_between_rounds': 90,
|
||||
'modality': 'reps',
|
||||
'exercises': [
|
||||
{'exercise': accessory_1, 'reps': 10, 'order': 1},
|
||||
{'exercise': accessory_2, 'reps': 10, 'order': 2},
|
||||
{'exercise': accessory_3, 'reps': 12, 'order': 3},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
muscle_split = {
|
||||
'muscles': ['quads', 'hamstrings'],
|
||||
'split_type': 'lower',
|
||||
'label': 'Lower',
|
||||
}
|
||||
workout_spec = gen.generate_single_workout(
|
||||
muscle_split=muscle_split,
|
||||
workout_type=self.strength_type,
|
||||
scheduled_date=date(2026, 3, 2),
|
||||
)
|
||||
|
||||
working = [
|
||||
ss for ss in workout_spec.get('supersets', [])
|
||||
if ss.get('name', '').startswith('Working')
|
||||
]
|
||||
self.assertGreaterEqual(len(working), 1)
|
||||
self.assertEqual(
|
||||
len(working[0].get('exercises', [])),
|
||||
1,
|
||||
f'Expected first strength working set to stay at 1 exercise, got: {working[0]}',
|
||||
)
|
||||
|
||||
pref.delete()
|
||||
|
||||
|
||||
class TestModalityConsistency(MovementEnforcementTestBase):
|
||||
"""Item #6: Modality consistency warning for duration-dominant workouts."""
|
||||
@@ -503,3 +638,357 @@ class TestModalityConsistency(MovementEnforcementTestBase):
|
||||
)
|
||||
|
||||
pref.delete()
|
||||
|
||||
|
||||
class TestFinalConformance(MovementEnforcementTestBase):
|
||||
"""Strict final conformance enforcement for assembled workouts."""
|
||||
|
||||
def test_core_workout_respects_type_max_exercise_cap(self):
|
||||
"""Core workouts should be trimmed to the calibrated max (8 working exercises)."""
|
||||
pref = self._make_preference(primary_goal='general_fitness')
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
gen._build_warmup = MagicMock(return_value=None)
|
||||
gen._build_cooldown = MagicMock(return_value=None)
|
||||
gen._check_quality_gates = MagicMock(return_value=[])
|
||||
gen._get_final_conformance_violations = MagicMock(return_value=[])
|
||||
gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec)
|
||||
|
||||
working_exercises = [
|
||||
{'exercise': self._create_mock_exercise(f'Core Push {i}', movement_patterns='upper push, core'), 'reps': 12, 'order': i + 1}
|
||||
for i in range(6)
|
||||
]
|
||||
more_working_exercises = [
|
||||
{'exercise': self._create_mock_exercise(f'Core Pull {i}', movement_patterns='upper pull, core'), 'reps': 12, 'order': i + 1}
|
||||
for i in range(6)
|
||||
]
|
||||
|
||||
gen._build_working_supersets = MagicMock(return_value=[
|
||||
{
|
||||
'name': 'Working Set 1',
|
||||
'rounds': 3,
|
||||
'rest_between_rounds': 30,
|
||||
'modality': 'reps',
|
||||
'exercises': working_exercises,
|
||||
},
|
||||
{
|
||||
'name': 'Working Set 2',
|
||||
'rounds': 3,
|
||||
'rest_between_rounds': 30,
|
||||
'modality': 'reps',
|
||||
'exercises': more_working_exercises,
|
||||
},
|
||||
])
|
||||
|
||||
workout_spec = gen.generate_single_workout(
|
||||
muscle_split={
|
||||
'muscles': ['core', 'abs', 'obliques'],
|
||||
'split_type': 'core',
|
||||
'label': 'Core Day',
|
||||
},
|
||||
workout_type=self.core_type,
|
||||
scheduled_date=date(2026, 3, 2),
|
||||
)
|
||||
|
||||
working = [
|
||||
ss for ss in workout_spec.get('supersets', [])
|
||||
if ss.get('name', '').startswith('Working')
|
||||
]
|
||||
total_working = sum(len(ss.get('exercises', [])) for ss in working)
|
||||
self.assertLessEqual(
|
||||
total_working, 8,
|
||||
f'Expected core workout to cap at 8 working exercises, got {total_working}',
|
||||
)
|
||||
|
||||
pref.delete()
|
||||
|
||||
def test_core_cap_removes_extra_minimum_supersets(self):
|
||||
"""When all sets are already at minimum size, remove trailing sets to hit cap."""
|
||||
pref = self._make_preference(primary_goal='general_fitness')
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
gen._build_warmup = MagicMock(return_value=None)
|
||||
gen._build_cooldown = MagicMock(return_value=None)
|
||||
gen._check_quality_gates = MagicMock(return_value=[])
|
||||
gen._get_final_conformance_violations = MagicMock(return_value=[])
|
||||
gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec)
|
||||
|
||||
working_supersets = []
|
||||
for idx in range(6):
|
||||
push = self._create_mock_exercise(
|
||||
f'Push {idx}',
|
||||
movement_patterns='upper push',
|
||||
)
|
||||
pull = self._create_mock_exercise(
|
||||
f'Pull {idx}',
|
||||
movement_patterns='upper pull',
|
||||
)
|
||||
working_supersets.append({
|
||||
'name': f'Working Set {idx + 1}',
|
||||
'rounds': 3,
|
||||
'rest_between_rounds': 30,
|
||||
'modality': 'reps',
|
||||
'exercises': [
|
||||
{'exercise': push, 'reps': 12, 'order': 1},
|
||||
{'exercise': pull, 'reps': 12, 'order': 2},
|
||||
],
|
||||
})
|
||||
|
||||
gen._build_working_supersets = MagicMock(return_value=working_supersets)
|
||||
|
||||
workout_spec = gen.generate_single_workout(
|
||||
muscle_split={
|
||||
'muscles': ['core', 'abs', 'obliques'],
|
||||
'split_type': 'core',
|
||||
'label': 'Core Day',
|
||||
},
|
||||
workout_type=self.core_type,
|
||||
scheduled_date=date(2026, 3, 2),
|
||||
)
|
||||
|
||||
working = [
|
||||
ss for ss in workout_spec.get('supersets', [])
|
||||
if ss.get('name', '').startswith('Working')
|
||||
]
|
||||
total_working = sum(len(ss.get('exercises', [])) for ss in working)
|
||||
self.assertLessEqual(total_working, 8)
|
||||
self.assertLessEqual(len(working), 4)
|
||||
|
||||
pref.delete()
|
||||
|
||||
def test_pad_to_fill_respects_type_cap(self):
|
||||
"""Padding should stop when workout-type max working-exercise cap is reached."""
|
||||
pref = self._make_preference(primary_goal='general_fitness')
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
gen._estimate_total_time = MagicMock(return_value=0)
|
||||
gen.exercise_selector.select_exercises.return_value = [
|
||||
self._create_mock_exercise('Pad Exercise', movement_patterns='upper pull')
|
||||
]
|
||||
|
||||
base_ex_a = self._create_mock_exercise('Base A', movement_patterns='upper push')
|
||||
base_ex_b = self._create_mock_exercise('Base B', movement_patterns='upper pull')
|
||||
workout_spec = {
|
||||
'supersets': [
|
||||
{
|
||||
'name': 'Working Set 1',
|
||||
'rounds': 3,
|
||||
'rest_between_rounds': 30,
|
||||
'modality': 'reps',
|
||||
'exercises': [
|
||||
{'exercise': base_ex_a, 'reps': 12, 'order': 1},
|
||||
{'exercise': base_ex_b, 'reps': 12, 'order': 2},
|
||||
{'exercise': base_ex_a, 'reps': 12, 'order': 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': 'Working Set 2',
|
||||
'rounds': 3,
|
||||
'rest_between_rounds': 30,
|
||||
'modality': 'reps',
|
||||
'exercises': [
|
||||
{'exercise': base_ex_b, 'reps': 12, 'order': 1},
|
||||
{'exercise': base_ex_a, 'reps': 12, 'order': 2},
|
||||
{'exercise': base_ex_b, 'reps': 12, 'order': 3},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
wt_params = dict(WORKOUT_TYPE_DEFAULTS['core'])
|
||||
wt_params['duration_bias'] = 0.0
|
||||
|
||||
padded = gen._pad_to_fill(
|
||||
workout_spec=workout_spec,
|
||||
max_duration_sec=3600,
|
||||
muscle_split={
|
||||
'muscles': ['core', 'abs'],
|
||||
'split_type': 'core',
|
||||
'label': 'Core Day',
|
||||
},
|
||||
wt_params=wt_params,
|
||||
workout_type=self.core_type,
|
||||
)
|
||||
|
||||
total_working = sum(
|
||||
len(ss.get('exercises', []))
|
||||
for ss in padded.get('supersets', [])
|
||||
if ss.get('name', '').startswith('Working')
|
||||
)
|
||||
self.assertLessEqual(total_working, 8)
|
||||
|
||||
pref.delete()
|
||||
|
||||
def test_compound_ordering_uses_validator_definition(self):
|
||||
"""Accessory-tagged entries should not be treated as compounds in ordering."""
|
||||
pref = self._make_preference(primary_goal='general_fitness')
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
accessory_flagged_compound = self._create_mock_exercise(
|
||||
'Accessory Marked Compound',
|
||||
is_compound=True,
|
||||
exercise_tier='accessory',
|
||||
movement_patterns='upper push',
|
||||
)
|
||||
true_compound = self._create_mock_exercise(
|
||||
'Primary Compound',
|
||||
is_compound=True,
|
||||
exercise_tier='secondary',
|
||||
movement_patterns='upper pull',
|
||||
)
|
||||
workout_spec = {
|
||||
'supersets': [
|
||||
{
|
||||
'name': 'Working Set 1',
|
||||
'rounds': 3,
|
||||
'rest_between_rounds': 45,
|
||||
'modality': 'reps',
|
||||
'exercises': [
|
||||
{'exercise': accessory_flagged_compound, 'reps': 10, 'order': 1},
|
||||
{'exercise': true_compound, 'reps': 8, 'order': 2},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
gen._enforce_compound_first_order(workout_spec, is_strength_workout=False)
|
||||
violations = validate_workout(workout_spec, 'hiit', 'general_fitness')
|
||||
compound_order_violations = [
|
||||
v for v in violations
|
||||
if v.rule_id == 'compound_before_isolation'
|
||||
]
|
||||
self.assertEqual(len(compound_order_violations), 0)
|
||||
|
||||
pref.delete()
|
||||
|
||||
def test_final_warning_triggers_regeneration(self):
|
||||
"""A final warning should trigger full regeneration before returning."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
gen._build_warmup = MagicMock(return_value=None)
|
||||
gen._build_cooldown = MagicMock(return_value=None)
|
||||
gen._check_quality_gates = MagicMock(return_value=[])
|
||||
gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec)
|
||||
|
||||
ex = self._create_mock_exercise('Balanced Pull', movement_patterns='upper pull')
|
||||
gen._build_working_supersets = MagicMock(return_value=[
|
||||
{
|
||||
'name': 'Working Set 1',
|
||||
'rounds': 3,
|
||||
'rest_between_rounds': 45,
|
||||
'modality': 'reps',
|
||||
'exercises': [{'exercise': ex, 'reps': 10, 'order': 1}],
|
||||
},
|
||||
])
|
||||
|
||||
gen._get_final_conformance_violations = MagicMock(side_effect=[
|
||||
[RuleViolation(
|
||||
rule_id='exercise_count_cap',
|
||||
severity='warning',
|
||||
message='Too many exercises',
|
||||
)],
|
||||
[],
|
||||
])
|
||||
|
||||
gen.generate_single_workout(
|
||||
muscle_split={
|
||||
'muscles': ['upper back', 'lats'],
|
||||
'split_type': 'pull',
|
||||
'label': 'Pull Day',
|
||||
},
|
||||
workout_type=self.hiit_type,
|
||||
scheduled_date=date(2026, 3, 3),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
gen._build_working_supersets.call_count, 2,
|
||||
'Expected regeneration after final warning.',
|
||||
)
|
||||
pref.delete()
|
||||
|
||||
def test_unresolved_final_violations_raise_error(self):
|
||||
"""Generator should fail fast when conformance cannot be achieved."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
gen._build_warmup = MagicMock(return_value=None)
|
||||
gen._build_cooldown = MagicMock(return_value=None)
|
||||
gen._check_quality_gates = MagicMock(return_value=[])
|
||||
gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec)
|
||||
|
||||
ex = self._create_mock_exercise('Push Only', movement_patterns='upper push')
|
||||
gen._build_working_supersets = MagicMock(return_value=[
|
||||
{
|
||||
'name': 'Working Set 1',
|
||||
'rounds': 3,
|
||||
'rest_between_rounds': 45,
|
||||
'modality': 'reps',
|
||||
'exercises': [{'exercise': ex, 'reps': 10, 'order': 1}],
|
||||
},
|
||||
])
|
||||
gen._get_final_conformance_violations = MagicMock(return_value=[
|
||||
RuleViolation(
|
||||
rule_id='push_pull_ratio',
|
||||
severity='warning',
|
||||
message='Pull:push ratio too low',
|
||||
),
|
||||
])
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
gen.generate_single_workout(
|
||||
muscle_split={
|
||||
'muscles': ['chest', 'triceps'],
|
||||
'split_type': 'push',
|
||||
'label': 'Push Day',
|
||||
},
|
||||
workout_type=self.hiit_type,
|
||||
scheduled_date=date(2026, 3, 4),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
gen._build_working_supersets.call_count,
|
||||
FINAL_CONFORMANCE_MAX_RETRIES + 1,
|
||||
)
|
||||
pref.delete()
|
||||
|
||||
def test_info_violation_is_not_blocking(self):
|
||||
"""Info-level rules should not fail generation in strict mode."""
|
||||
pref = self._make_preference()
|
||||
gen = self._make_generator(pref)
|
||||
|
||||
gen._build_warmup = MagicMock(return_value=None)
|
||||
gen._build_cooldown = MagicMock(return_value=None)
|
||||
gen._check_quality_gates = MagicMock(return_value=[])
|
||||
gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec)
|
||||
|
||||
ex = self._create_mock_exercise('Compound Lift', movement_patterns='upper pull')
|
||||
gen._build_working_supersets = MagicMock(return_value=[
|
||||
{
|
||||
'name': 'Working Set 1',
|
||||
'rounds': 3,
|
||||
'rest_between_rounds': 45,
|
||||
'modality': 'reps',
|
||||
'exercises': [{'exercise': ex, 'reps': 8, 'order': 1}],
|
||||
},
|
||||
])
|
||||
gen._get_final_conformance_violations = MagicMock(return_value=[
|
||||
RuleViolation(
|
||||
rule_id='compound_before_isolation',
|
||||
severity='info',
|
||||
message='Compound exercises should generally appear before isolation.',
|
||||
),
|
||||
])
|
||||
|
||||
workout = gen.generate_single_workout(
|
||||
muscle_split={
|
||||
'muscles': ['upper back'],
|
||||
'split_type': 'pull',
|
||||
'label': 'Pull Day',
|
||||
},
|
||||
workout_type=self.strength_type,
|
||||
scheduled_date=date(2026, 3, 5),
|
||||
)
|
||||
|
||||
self.assertIsInstance(workout, dict)
|
||||
self.assertEqual(gen._build_working_supersets.call_count, 1)
|
||||
pref.delete()
|
||||
|
||||
@@ -73,7 +73,7 @@ class TestWorkoutTypeRulesCoverage(TestCase):
|
||||
expected_types = [
|
||||
'traditional_strength_training',
|
||||
'hypertrophy',
|
||||
'hiit',
|
||||
'high_intensity_interval_training',
|
||||
'functional_strength_training',
|
||||
'cross_training',
|
||||
'core_training',
|
||||
@@ -116,14 +116,14 @@ class TestDBCalibrationCoverage(TestCase):
|
||||
|
||||
def test_all_8_types_in_calibration(self):
|
||||
expected_names = [
|
||||
'Functional Strength Training',
|
||||
'Traditional Strength Training',
|
||||
'HIIT',
|
||||
'Cross Training',
|
||||
'Core Training',
|
||||
'Flexibility',
|
||||
'Cardio',
|
||||
'Hypertrophy',
|
||||
'functional_strength_training',
|
||||
'traditional_strength_training',
|
||||
'high_intensity_interval_training',
|
||||
'cross_training',
|
||||
'core_training',
|
||||
'flexibility',
|
||||
'cardio',
|
||||
'hypertrophy',
|
||||
]
|
||||
for name in expected_names:
|
||||
self.assertIn(name, DB_CALIBRATION, f"Missing {name} in DB_CALIBRATION")
|
||||
@@ -137,7 +137,11 @@ class TestHelperFunctions(TestCase):
|
||||
_normalize_type_key('Traditional Strength Training'),
|
||||
'traditional_strength_training',
|
||||
)
|
||||
self.assertEqual(_normalize_type_key('HIIT'), 'hiit')
|
||||
self.assertEqual(_normalize_type_key('HIIT'), 'high_intensity_interval_training')
|
||||
self.assertEqual(
|
||||
_normalize_type_key('high intensity interval training'),
|
||||
'high_intensity_interval_training',
|
||||
)
|
||||
self.assertEqual(_normalize_type_key('cardio'), 'cardio')
|
||||
|
||||
def test_classify_rep_weight(self):
|
||||
@@ -500,6 +504,86 @@ class TestValidateWorkout(TestCase):
|
||||
"Expected superset size warning for 8-exercise superset in strength",
|
||||
)
|
||||
|
||||
def test_superset_focus_repetition_error(self):
|
||||
"""Two curl-family exercises in one superset should produce an error."""
|
||||
curl_a = _make_exercise(
|
||||
name='Alternating Bicep Curls',
|
||||
movement_patterns='upper pull',
|
||||
is_compound=False,
|
||||
exercise_tier='accessory',
|
||||
)
|
||||
curl_b = _make_exercise(
|
||||
name='Bicep Curls',
|
||||
movement_patterns='upper pull',
|
||||
is_compound=False,
|
||||
exercise_tier='accessory',
|
||||
)
|
||||
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=curl_a, reps=10, order=1),
|
||||
_make_entry(exercise=curl_b, reps=10, order=2),
|
||||
],
|
||||
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, 'functional_strength_training', 'general_fitness',
|
||||
)
|
||||
repetition_errors = [
|
||||
v for v in violations
|
||||
if v.rule_id == 'superset_focus_repetition' and v.severity == 'error'
|
||||
]
|
||||
self.assertTrue(
|
||||
repetition_errors,
|
||||
f"Expected superset focus repetition error, got {[v.rule_id for v in violations]}",
|
||||
)
|
||||
|
||||
def test_adjacent_focus_repetition_info(self):
|
||||
"""Adjacent working supersets with same focus profile should be advisory."""
|
||||
pull_a = _make_exercise(name='Bicep Curl', movement_patterns='upper pull')
|
||||
pull_b = _make_exercise(name='Hammer Curl', movement_patterns='upper pull')
|
||||
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=pull_a, reps=10, order=1)],
|
||||
rounds=3,
|
||||
),
|
||||
_make_superset(
|
||||
name='Working Set 2',
|
||||
exercises=[_make_entry(exercise=pull_b, reps=10, order=1)],
|
||||
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, 'functional_strength_training', 'general_fitness',
|
||||
)
|
||||
adjacent_infos = [
|
||||
v for v in violations
|
||||
if v.rule_id == 'adjacent_superset_focus_repetition' and v.severity == 'info'
|
||||
]
|
||||
self.assertTrue(
|
||||
adjacent_infos,
|
||||
"Expected adjacent superset focus repetition advisory info.",
|
||||
)
|
||||
|
||||
def test_compound_before_isolation_info(self):
|
||||
"""Isolation before compound should produce info violation."""
|
||||
isolation = _make_exercise(
|
||||
|
||||
@@ -210,3 +210,42 @@ class TestWeeklySplit(TestCase):
|
||||
|
||||
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()
|
||||
|
||||
430
generator/tests/test_workout_research_generation.py
Normal file
430
generator/tests/test_workout_research_generation.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
Integration tests for research-backed workout generation.
|
||||
|
||||
These tests validate generated workouts against the expectations encoded from
|
||||
workout_research.md in generator.rules_engine.
|
||||
"""
|
||||
|
||||
import random
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, timedelta
|
||||
from itertools import combinations
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from equipment.models import Equipment
|
||||
from equipment.models import WorkoutEquipment
|
||||
from exercise.models import Exercise
|
||||
from generator.models import UserPreference, WorkoutType
|
||||
from generator.rules_engine import DB_CALIBRATION, validate_workout
|
||||
from generator.services.workout_generator import WorkoutGenerator
|
||||
from muscle.models import ExerciseMuscle, Muscle
|
||||
from registered_user.models import RegisteredUser
|
||||
|
||||
|
||||
@contextmanager
|
||||
def seeded_random(seed):
|
||||
"""Use a deterministic random seed without leaking global random state."""
|
||||
state = random.getstate()
|
||||
random.seed(seed)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
random.setstate(state)
|
||||
|
||||
|
||||
class TestWorkoutResearchGeneration(TestCase):
|
||||
"""
|
||||
TDD coverage for end-to-end generated workout quality:
|
||||
1) One workout per workout type
|
||||
2) Workouts for deterministic random workout-type pairs
|
||||
"""
|
||||
|
||||
MUSCLE_NAMES = [
|
||||
'chest',
|
||||
'upper back',
|
||||
'lats',
|
||||
'deltoids',
|
||||
'quads',
|
||||
'hamstrings',
|
||||
'glutes',
|
||||
'core',
|
||||
'biceps',
|
||||
'triceps',
|
||||
'calves',
|
||||
'forearms',
|
||||
'abs',
|
||||
'obliques',
|
||||
]
|
||||
|
||||
SPLITS_BY_TYPE = {
|
||||
'traditional_strength_training': {
|
||||
'label': 'Strength Day',
|
||||
'muscles': ['quads', 'hamstrings', 'glutes', 'core'],
|
||||
'split_type': 'lower',
|
||||
},
|
||||
'hypertrophy': {
|
||||
'label': 'Hypertrophy Day',
|
||||
'muscles': ['chest', 'upper back', 'deltoids', 'biceps', 'triceps'],
|
||||
'split_type': 'upper',
|
||||
},
|
||||
'high_intensity_interval_training': {
|
||||
'label': 'HIIT Day',
|
||||
'muscles': ['chest', 'upper back', 'quads', 'core'],
|
||||
'split_type': 'full_body',
|
||||
},
|
||||
'functional_strength_training': {
|
||||
'label': 'Functional Day',
|
||||
'muscles': ['chest', 'upper back', 'quads', 'hamstrings', 'core'],
|
||||
'split_type': 'full_body',
|
||||
},
|
||||
'cross_training': {
|
||||
'label': 'Cross Day',
|
||||
'muscles': ['chest', 'upper back', 'quads', 'core'],
|
||||
'split_type': 'full_body',
|
||||
},
|
||||
'core_training': {
|
||||
'label': 'Core Day',
|
||||
'muscles': ['abs', 'obliques', 'core'],
|
||||
'split_type': 'core',
|
||||
},
|
||||
'flexibility': {
|
||||
'label': 'Mobility Day',
|
||||
'muscles': ['hamstrings', 'glutes', 'core'],
|
||||
'split_type': 'full_body',
|
||||
},
|
||||
'cardio': {
|
||||
'label': 'Cardio Day',
|
||||
'muscles': ['quads', 'calves', 'core'],
|
||||
'split_type': 'cardio',
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
User = get_user_model()
|
||||
auth_user = User.objects.create_user(
|
||||
username='research_gen',
|
||||
password='testpass123',
|
||||
)
|
||||
cls.registered_user = RegisteredUser.objects.create(
|
||||
first_name='Research',
|
||||
last_name='Generator',
|
||||
user=auth_user,
|
||||
)
|
||||
|
||||
# Keep equipment filtering permissive without triggering "no equipment" fallback warnings.
|
||||
cls.bodyweight = Equipment.objects.create(
|
||||
name='Bodyweight',
|
||||
category='none',
|
||||
is_weight=False,
|
||||
)
|
||||
|
||||
cls.preference = UserPreference.objects.create(
|
||||
registered_user=cls.registered_user,
|
||||
days_per_week=5,
|
||||
fitness_level=2,
|
||||
primary_goal='general_fitness',
|
||||
secondary_goal='',
|
||||
preferred_workout_duration=90,
|
||||
)
|
||||
cls.preference.available_equipment.add(cls.bodyweight)
|
||||
|
||||
cls.muscles = {}
|
||||
for name in cls.MUSCLE_NAMES:
|
||||
cls.muscles[name] = Muscle.objects.create(name=name)
|
||||
|
||||
cls.workout_types = {}
|
||||
for wt_name, fields in DB_CALIBRATION.items():
|
||||
wt, _ = WorkoutType.objects.get_or_create(
|
||||
name=wt_name,
|
||||
defaults={
|
||||
'display_name': wt_name.replace('_', ' ').title(),
|
||||
'description': f'Calibrated {wt_name}',
|
||||
**fields,
|
||||
},
|
||||
)
|
||||
# Keep DB values aligned with calibration regardless of fixtures/migrations.
|
||||
update_fields = []
|
||||
for field_name, field_value in fields.items():
|
||||
if getattr(wt, field_name) != field_value:
|
||||
setattr(wt, field_name, field_value)
|
||||
update_fields.append(field_name)
|
||||
if update_fields:
|
||||
wt.save(update_fields=update_fields)
|
||||
cls.workout_types[wt_name] = wt
|
||||
cls.preference.preferred_workout_types.add(wt)
|
||||
|
||||
# Populate all workout-structure expectations for all goals/sections.
|
||||
call_command('calibrate_structure_rules')
|
||||
|
||||
cls._seed_exercise_pool()
|
||||
|
||||
@classmethod
|
||||
def _create_exercise(
|
||||
cls,
|
||||
name,
|
||||
movement_patterns,
|
||||
*,
|
||||
is_weight,
|
||||
is_duration,
|
||||
is_reps,
|
||||
is_compound,
|
||||
exercise_tier='secondary',
|
||||
hr_elevation_rating=6,
|
||||
complexity_rating=3,
|
||||
difficulty_level='intermediate',
|
||||
stretch_position='mid',
|
||||
):
|
||||
ex = Exercise.objects.create(
|
||||
name=name,
|
||||
movement_patterns=movement_patterns,
|
||||
muscle_groups=', '.join(cls.MUSCLE_NAMES),
|
||||
is_weight=is_weight,
|
||||
is_duration=is_duration,
|
||||
is_reps=is_reps,
|
||||
is_compound=is_compound,
|
||||
exercise_tier=exercise_tier,
|
||||
hr_elevation_rating=hr_elevation_rating,
|
||||
complexity_rating=complexity_rating,
|
||||
difficulty_level=difficulty_level,
|
||||
stretch_position=stretch_position,
|
||||
estimated_rep_duration=3.0,
|
||||
)
|
||||
# Attach broad muscle mappings so split filtering has high coverage.
|
||||
for muscle in cls.muscles.values():
|
||||
ExerciseMuscle.objects.create(exercise=ex, muscle=muscle)
|
||||
return ex
|
||||
|
||||
@classmethod
|
||||
def _seed_exercise_pool(cls):
|
||||
working_patterns = [
|
||||
'lower push - squat, lower push, upper push, upper pull, core',
|
||||
'lower pull - hip hinge, lower pull, upper push, upper pull, core',
|
||||
'upper push - horizontal, upper push, upper pull, core',
|
||||
'upper pull - horizontal, upper pull, upper push, core',
|
||||
'upper push - vertical, upper push, upper pull, core',
|
||||
'upper pull - vertical, upper pull, upper push, core',
|
||||
'carry, core, lower push, upper pull',
|
||||
'cardio/locomotion, upper push, upper pull, core',
|
||||
'plyometric, lower push, upper pull, upper push, core',
|
||||
'arms, upper push, upper pull, core',
|
||||
]
|
||||
|
||||
duration_patterns = [
|
||||
'cardio/locomotion, upper push, upper pull, core',
|
||||
'plyometric, upper push, upper pull, lower push, core',
|
||||
'core - anti-extension, cardio/locomotion, upper push, upper pull',
|
||||
'core - anti-rotation, cardio/locomotion, upper push, upper pull',
|
||||
'core - anti-lateral flexion, cardio/locomotion, upper push, upper pull',
|
||||
]
|
||||
|
||||
for idx in range(60):
|
||||
cls._create_exercise(
|
||||
name=f'Engine Move {idx + 1:02d}',
|
||||
movement_patterns=working_patterns[idx % len(working_patterns)],
|
||||
is_weight=True,
|
||||
is_duration=False,
|
||||
is_reps=True,
|
||||
is_compound=True,
|
||||
exercise_tier='secondary',
|
||||
hr_elevation_rating=6,
|
||||
)
|
||||
|
||||
for idx in range(40):
|
||||
cls._create_exercise(
|
||||
name=f'Interval Move {idx + 1:02d}',
|
||||
movement_patterns=duration_patterns[idx % len(duration_patterns)],
|
||||
is_weight=False,
|
||||
is_duration=True,
|
||||
is_reps=True,
|
||||
is_compound=True,
|
||||
exercise_tier='secondary',
|
||||
hr_elevation_rating=8,
|
||||
)
|
||||
|
||||
for idx in range(14):
|
||||
cls._create_exercise(
|
||||
name=f'Warmup Flow {idx + 1:02d}',
|
||||
movement_patterns='dynamic stretch, activation, mobility, warm up',
|
||||
is_weight=False,
|
||||
is_duration=True,
|
||||
is_reps=False,
|
||||
is_compound=False,
|
||||
exercise_tier='accessory',
|
||||
hr_elevation_rating=3,
|
||||
complexity_rating=2,
|
||||
stretch_position='lengthened',
|
||||
)
|
||||
|
||||
for idx in range(14):
|
||||
cls._create_exercise(
|
||||
name=f'Cooldown Stretch {idx + 1:02d}',
|
||||
movement_patterns='static stretch, mobility, yoga, cool down',
|
||||
is_weight=False,
|
||||
is_duration=True,
|
||||
is_reps=False,
|
||||
is_compound=False,
|
||||
exercise_tier='accessory',
|
||||
hr_elevation_rating=2,
|
||||
complexity_rating=2,
|
||||
stretch_position='lengthened',
|
||||
)
|
||||
|
||||
def _set_goal(self, goal):
|
||||
self.preference.primary_goal = goal
|
||||
self.preference.secondary_goal = ''
|
||||
self.preference.save(update_fields=['primary_goal', 'secondary_goal'])
|
||||
|
||||
def _generate_workout_for_type(self, wt_name, *, seed, goal='general_fitness', day_offset=0):
|
||||
self._set_goal(goal)
|
||||
generator = WorkoutGenerator(self.preference, duration_override=90)
|
||||
split = dict(self.SPLITS_BY_TYPE[wt_name])
|
||||
with seeded_random(seed):
|
||||
workout = generator.generate_single_workout(
|
||||
muscle_split=split,
|
||||
workout_type=self.workout_types[wt_name],
|
||||
scheduled_date=date(2026, 3, 2) + timedelta(days=day_offset),
|
||||
)
|
||||
return workout, list(generator.warnings)
|
||||
|
||||
def _assert_research_alignment(self, workout_spec, wt_name, goal, context, generation_warnings=None):
|
||||
violations = validate_workout(workout_spec, wt_name, goal)
|
||||
blocking = [v for v in violations if v.severity in {'error', 'warning'}]
|
||||
|
||||
messages = [f'[{v.severity}] {v.rule_id}: {v.message}' for v in violations]
|
||||
self.assertEqual(
|
||||
len(blocking),
|
||||
0,
|
||||
(
|
||||
f'{context} failed strict research validation for {wt_name}/{goal}. '
|
||||
f'Violations: {messages}'
|
||||
),
|
||||
)
|
||||
|
||||
working = [
|
||||
ss for ss in workout_spec.get('supersets', [])
|
||||
if ss.get('name', '').startswith('Working')
|
||||
]
|
||||
self.assertGreaterEqual(
|
||||
len(working), 1,
|
||||
f'{context} should have at least one working superset.',
|
||||
)
|
||||
|
||||
if generation_warnings is not None:
|
||||
self.assertEqual(
|
||||
generation_warnings,
|
||||
[],
|
||||
f'{context} emitted generation warnings: {generation_warnings}',
|
||||
)
|
||||
|
||||
def test_generate_one_workout_for_each_type_matches_research(self):
|
||||
"""
|
||||
Generate one workout per workout type and ensure each passes
|
||||
research-backed rules validation.
|
||||
"""
|
||||
for idx, wt_name in enumerate(DB_CALIBRATION.keys(), start=1):
|
||||
workout, generation_warnings = self._generate_workout_for_type(
|
||||
wt_name,
|
||||
seed=7000 + idx,
|
||||
goal='general_fitness',
|
||||
day_offset=idx,
|
||||
)
|
||||
self._assert_research_alignment(
|
||||
workout,
|
||||
wt_name,
|
||||
'general_fitness',
|
||||
context='single-type generation',
|
||||
generation_warnings=generation_warnings,
|
||||
)
|
||||
|
||||
def test_generate_deterministic_random_workout_type_pairs(self):
|
||||
"""
|
||||
Generate workouts for deterministic random pairs of workout types.
|
||||
Each workout in every pair must satisfy research-backed rules.
|
||||
"""
|
||||
all_pairs = list(combinations(DB_CALIBRATION.keys(), 2))
|
||||
rng = random.Random(20260223)
|
||||
sampled_pairs = rng.sample(all_pairs, 8)
|
||||
|
||||
for pair_idx, (wt_a, wt_b) in enumerate(sampled_pairs):
|
||||
workout_a, warnings_a = self._generate_workout_for_type(
|
||||
wt_a,
|
||||
seed=8100 + pair_idx * 10,
|
||||
goal='general_fitness',
|
||||
day_offset=pair_idx * 2,
|
||||
)
|
||||
self._assert_research_alignment(
|
||||
workout_a,
|
||||
wt_a,
|
||||
'general_fitness',
|
||||
context=f'random-pair[{pair_idx}] first',
|
||||
generation_warnings=warnings_a,
|
||||
)
|
||||
|
||||
workout_b, warnings_b = self._generate_workout_for_type(
|
||||
wt_b,
|
||||
seed=8100 + pair_idx * 10 + 1,
|
||||
goal='general_fitness',
|
||||
day_offset=pair_idx * 2 + 1,
|
||||
)
|
||||
self._assert_research_alignment(
|
||||
workout_b,
|
||||
wt_b,
|
||||
'general_fitness',
|
||||
context=f'random-pair[{pair_idx}] second',
|
||||
generation_warnings=warnings_b,
|
||||
)
|
||||
|
||||
def test_generation_honors_exclusions_and_equipment_preferences(self):
|
||||
"""Generated workouts should not include excluded exercises or unavailable equipment."""
|
||||
wt_name = 'functional_strength_training'
|
||||
wt = self.workout_types[wt_name]
|
||||
|
||||
# Restrict user to only Bodyweight equipment and exclude one candidate exercise.
|
||||
self.preference.available_equipment.clear()
|
||||
self.preference.available_equipment.add(self.bodyweight)
|
||||
excluded = Exercise.objects.filter(name='Engine Move 01').first()
|
||||
self.assertIsNotNone(excluded)
|
||||
self.preference.excluded_exercises.add(excluded)
|
||||
|
||||
workout, generation_warnings = self._generate_workout_for_type(
|
||||
wt_name,
|
||||
seed=9401,
|
||||
goal='general_fitness',
|
||||
day_offset=10,
|
||||
)
|
||||
|
||||
all_exercises = []
|
||||
for ss in workout.get('supersets', []):
|
||||
for entry in ss.get('exercises', []):
|
||||
ex = entry.get('exercise')
|
||||
if ex is not None:
|
||||
all_exercises.append(ex)
|
||||
|
||||
self.assertTrue(all_exercises, 'Expected at least one exercise in generated workout.')
|
||||
self.assertNotIn(
|
||||
excluded.pk,
|
||||
{ex.pk for ex in all_exercises},
|
||||
'Excluded exercise was found in generated workout.',
|
||||
)
|
||||
|
||||
ex_ids = [ex.pk for ex in all_exercises]
|
||||
available_equipment_ids = {self.bodyweight.pk}
|
||||
requirements = {}
|
||||
for ex_id, eq_id in WorkoutEquipment.objects.filter(
|
||||
exercise_id__in=ex_ids,
|
||||
).values_list('exercise_id', 'equipment_id'):
|
||||
requirements.setdefault(ex_id, set()).add(eq_id)
|
||||
bad_equipment = [
|
||||
ex_id for ex_id, required_ids in requirements.items()
|
||||
if required_ids and not required_ids.issubset(available_equipment_ids)
|
||||
]
|
||||
self.assertEqual(
|
||||
bad_equipment,
|
||||
[],
|
||||
f'Found exercises requiring unavailable equipment: {bad_equipment}',
|
||||
)
|
||||
self.assertEqual(generation_warnings, [])
|
||||
@@ -938,6 +938,16 @@ def preview_day(request):
|
||||
|
||||
# Optional plan_id: exclude exercises from sibling workouts in the same plan (Item #9)
|
||||
plan_id = request.data.get('plan_id')
|
||||
if plan_id in ('', None):
|
||||
plan_id = None
|
||||
elif not isinstance(plan_id, int):
|
||||
try:
|
||||
plan_id = int(plan_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'error': 'plan_id must be an integer.'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
from generator.services.workout_generator import WorkoutGenerator
|
||||
@@ -945,7 +955,7 @@ def preview_day(request):
|
||||
generator = WorkoutGenerator(preference)
|
||||
|
||||
# If plan_id is provided, exclude sibling workout exercises
|
||||
if plan_id:
|
||||
if plan_id is not None:
|
||||
try:
|
||||
plan = GeneratedWeeklyPlan.objects.get(
|
||||
pk=plan_id,
|
||||
@@ -974,6 +984,8 @@ def preview_day(request):
|
||||
workout_type=workout_type,
|
||||
scheduled_date=scheduled_date,
|
||||
)
|
||||
if plan_id is not None:
|
||||
day_preview['plan_id'] = plan_id
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Day preview generation failed: {str(e)}'},
|
||||
|
||||
Reference in New Issue
Block a user