"""Tests for the ProgressionService class (rep ladder model).""" from datetime import date, timedelta from sqlmodel import SQLModel, Session, create_engine from app.models.user import User from app.models.exercise import Exercise from app.models.workout_day import WorkoutDay from app.models.workout_session import WorkoutSession from app.models.workout_log import WorkoutLog from app.models.user_exercise_program import UserExerciseProgram from app.models.progress_log import ProgressLog from app.services.progression_service import ProgressionService class TestProgressionService: """Tests for the rep ladder auto-progression engine.""" def _setup(self, starting_weight="30 lbs"): """Create an in-memory DB with a user, exercise, and program.""" engine = create_engine("sqlite:///:memory:") SQLModel.metadata.create_all(engine) session = Session(engine) user = User(username="phil", display_name="Phillip") day = WorkoutDay(name="Push", day_number=1, description="Push day") exercise = Exercise( name="DB Chest Press", muscle_group="Chest", workout_day="Push", sets=3, tempo="3-1-2", form_cues="...", ) session.add_all([user, day, exercise]) session.commit() session.refresh(user) session.refresh(day) session.refresh(exercise) program = UserExerciseProgram( user_id=user.id, exercise_id=exercise.id, starting_weight=starting_weight, ) session.add(program) session.commit() session.refresh(program) service = ProgressionService(session) return session, service, user, day, exercise, program def _log_session(self, session, user, day, exercise, reps, weight, felt_easy, days_ago): """Helper to log a workout session with 3 sets.""" ws = WorkoutSession( user_id=user.id, workout_day_id=day.id, date=date.today() - timedelta(days=days_ago), ) session.add(ws) session.commit() session.refresh(ws) for set_num in range(1, 4): session.add(WorkoutLog( session_id=ws.id, exercise_id=exercise.id, set_number=set_num, reps_completed=reps, weight_used=weight, felt_easy=felt_easy, )) session.commit() def test_baseline_no_logs(self) -> None: """Should return 3x6 @ starting_weight when no logs exist.""" session, service, user, day, exercise, program = self._setup() suggestion = service.get_suggestion(user.id, exercise.id) assert suggestion["suggested_reps"] == 6 assert suggestion["suggested_weight"] == "30 lbs" assert suggestion["suggested_sets"] == 3 assert suggestion["ladder_position"] == 0 assert suggestion["progression_type"] == "baseline" session.close() def test_climb_when_felt_easy(self) -> None: """Should climb from 6 to 8 reps when all sets felt easy.""" session, service, user, day, exercise, program = self._setup() self._log_session(session, user, day, exercise, reps=6, weight="30 lbs", felt_easy=True, days_ago=7) suggestion = service.get_suggestion(user.id, exercise.id) assert suggestion["suggested_reps"] == 8 assert suggestion["suggested_weight"] == "30 lbs" assert suggestion["progression_type"] == "climb" assert suggestion["ladder_position"] == 1 session.close() def test_hold_when_not_felt_easy(self) -> None: """Should hold at current reps when not all sets felt easy.""" session, service, user, day, exercise, program = self._setup() self._log_session(session, user, day, exercise, reps=8, weight="30 lbs", felt_easy=False, days_ago=7) suggestion = service.get_suggestion(user.id, exercise.id) assert suggestion["suggested_reps"] == 8 assert suggestion["suggested_weight"] == "30 lbs" assert suggestion["progression_type"] == "hold" session.close() def test_weight_increase_at_12_felt_easy(self) -> None: """Should suggest +5 lbs when at 12 reps and all felt easy.""" session, service, user, day, exercise, program = self._setup() self._log_session(session, user, day, exercise, reps=12, weight="30 lbs", felt_easy=True, days_ago=7) suggestion = service.get_suggestion(user.id, exercise.id) assert suggestion["suggested_reps"] == 6 assert suggestion["suggested_weight"] == "35 lbs" assert suggestion["progression_type"] == "weight_increase" assert suggestion["ladder_position"] == 0 session.close() def test_deload_after_4_struggling_sessions(self) -> None: """Should deload after 4 consecutive struggling sessions.""" session, service, user, day, exercise, program = self._setup() for week in range(4): self._log_session(session, user, day, exercise, reps=8, weight="40 lbs", felt_easy=False, days_ago=(4 - week) * 7) suggestion = service.get_suggestion(user.id, exercise.id) assert suggestion["progression_type"] == "deload" assert suggestion["suggested_reps"] == 6 assert "32" in suggestion["suggested_weight"] # 40 * 0.8 = 32 session.close() def test_bodyweight_hold_at_top(self) -> None: """Bodyweight exercises should hold at 12 reps, no weight increase.""" session, service, user, day, exercise, program = self._setup(starting_weight="BW") self._log_session(session, user, day, exercise, reps=12, weight="BW", felt_easy=True, days_ago=7) suggestion = service.get_suggestion(user.id, exercise.id) assert suggestion["suggested_reps"] == 12 assert suggestion["suggested_weight"] == "BW" assert suggestion["progression_type"] == "hold_at_top" session.close() def test_bodyweight_climb(self) -> None: """Bodyweight exercises should climb the ladder normally.""" session, service, user, day, exercise, program = self._setup(starting_weight="BW") self._log_session(session, user, day, exercise, reps=8, weight="BW", felt_easy=True, days_ago=7) suggestion = service.get_suggestion(user.id, exercise.id) assert suggestion["suggested_reps"] == 10 assert suggestion["progression_type"] == "climb" session.close() def test_no_program(self) -> None: """Should return no_program when no program exists.""" engine = create_engine("sqlite:///:memory:") SQLModel.metadata.create_all(engine) session = Session(engine) user = User(username="u", display_name="U") exercise = Exercise( name="Ex", muscle_group="Test", workout_day="Push", sets=3, tempo="3-1-2", form_cues="...", ) session.add_all([user, exercise]) session.commit() session.refresh(user) session.refresh(exercise) service = ProgressionService(session) suggestion = service.get_suggestion(user.id, exercise.id) assert suggestion["progression_type"] == "no_program" session.close() def test_record_progression(self) -> None: """record_progression should write to progress_log table.""" session, service, user, day, exercise, program = self._setup() service.record_progression( user_id=user.id, exercise_id=exercise.id, suggested_reps=10, suggested_weight="30 lbs", actual_reps=10, actual_weight="30 lbs", progression_type="climb", ) from sqlmodel import select logs = session.exec(select(ProgressLog)).all() assert len(logs) == 1 assert logs[0].progression_applied == "climb" session.close() def test_full_ladder_climb(self) -> None: """Should climb 6 -> 8 -> 10 -> 12 across sessions.""" session, service, user, day, exercise, program = self._setup() expected_climbs = [(6, 8), (8, 10), (10, 12)] for i, (current, expected_next) in enumerate(expected_climbs): self._log_session(session, user, day, exercise, reps=current, weight="30 lbs", felt_easy=True, days_ago=(len(expected_climbs) - i) * 7) suggestion = service.get_suggestion(user.id, exercise.id) # Latest session is 10 reps felt easy -> should suggest 12 assert suggestion["suggested_reps"] == 12 assert suggestion["progression_type"] == "climb" session.close()