""" Step 8 — Execution Mode Tests - test_start_session_navigates_to_execute: Clicking play from detail view navigates to execute view - test_session_list_play_buttons: Play buttons appear on each session card in the list - test_execute_view_elements: Execute view shows title, progress, current step, next step - test_steps_advance_on_done: Pressing Done advances to the next step - test_reps_weight_editable_and_saved: Reps and weight inputs are editable and saved to log - test_session_complete_after_last_step: Completing all steps redirects to session detail - test_asymmetric_side_shown: Side toggle visible for asymmetric exercises only - test_asymmetric_side_logged: Side value is saved in the execution log - test_combo_rounds_auto_advance: Non-final combo rounds advance silently (no pause screen) - test_final_round_screen_shown: Final-round screen appears after last exercise of last round - test_continue_advances_past_combo: Continue button advances to next session item - test_one_more_round_appends_round: One More Round inserts an extra round into the queue - test_exit_mid_session_saves_partial_log: Exit saves only completed items to log - test_prefill_from_last_session_run: Re-executing a session prefills from last run - test_no_prior_log_uses_defaults: No prior log uses session item defaults for prefill - test_progress_bar_reflects_position: Progress label updates as steps are completed - test_combo_context_shown: Combo name and round number shown in step card for combo exercises """ import pytest from playwright.sync_api import Page, expect def _wait_for_db(page: Page, app_url: str): page.goto(app_url) page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) def _wait_for_execute(page: Page): """Wait for the execute view to be fully loaded.""" page.wait_for_selector('[data-testid="execute-session-title"]', timeout=10000) page.wait_for_timeout(300) def _create_exercise(page: Page, title: str, asymmetric=False, alternate=False, default_reps=10, default_weight=0): return page.evaluate(f"""async () => {{ const userId = await window.__repos.settings.get('user_uuid') return window.__repos.exercises.create({{ title: {repr(title)}, description: '', asymmetric: {'true' if asymmetric else 'false'}, alternate: {'true' if alternate else 'false'}, image_urls: '[]', video_urls: '[]', default_reps: {default_reps}, default_weight: {default_weight}, created_by: userId }}) }}""") def _create_session_with_exercises(page: Page, title: str, items: list): """items: list of dicts with exerciseId, reps, weight.""" items_json = str(items).replace("'", '"').replace("True", "true").replace("False", "false") return page.evaluate(f"""async () => {{ const userId = await window.__repos.settings.get('user_uuid') const sessId = await window.__repos.sessions.create({{ title: {repr(title)}, description: '', created_by: userId }}) const items = {items_json} const mapped = items.map((it, i) => ({{ item_id: it.item_id, item_type: it.item_type, position: i, repetitions: it.repetitions, weight: it.weight }})) await window.__repos.sessions.setItems(sessId, mapped) return sessId }}""") def _create_combo(page: Page, title: str, exercises: list): """exercises: list of {{exerciseId, defaultReps, defaultWeight}}.""" exercises_json = str(exercises).replace("'", '"').replace("True", "true").replace("False", "false") return page.evaluate(f"""async () => {{ const userId = await window.__repos.settings.get('user_uuid') const comboId = await window.__repos.combos.create({{ title: {repr(title)}, description: '', type: 'NONE', timer_config: {{}}, created_by: userId }}) const exercises = {exercises_json} await window.__repos.combos.setExercises(comboId, exercises.map(e => ({{ exerciseId: e.exerciseId, defaultReps: e.defaultReps, defaultWeight: e.defaultWeight }}))) return comboId }}""") def _clean_all(page: Page): page.evaluate("""async () => { const sessions = await window.__repos.sessions.getAll() for (const s of sessions) await window.__repos.sessions.delete(s.id) const combos = await window.__repos.combos.getAll() for (const c of combos) await window.__repos.combos.delete(c.id) const exercises = await window.__repos.exercises.getAll() for (const e of exercises) await window.__repos.exercises.delete(e.id) }""") # ── fixtures ────────────────────────────────────────────────────────────── @pytest.fixture def simple_session(page: Page, app_url: str): """A session with one exercise.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "Press", default_reps=8, default_weight=50) sess_id = _create_session_with_exercises(page, "Simple Session", [ {"item_id": ex_id, "item_type": "exercise", "repetitions": 8, "weight": 50} ]) return {"sess_id": sess_id, "ex_id": ex_id} @pytest.fixture def two_step_session(page: Page, app_url: str): """A session with two exercises.""" _wait_for_db(page, app_url) _clean_all(page) ex1 = _create_exercise(page, "Step One", default_reps=5, default_weight=30) ex2 = _create_exercise(page, "Step Two", default_reps=10, default_weight=0) sess_id = _create_session_with_exercises(page, "Two Step Session", [ {"item_id": ex1, "item_type": "exercise", "repetitions": 5, "weight": 30}, {"item_id": ex2, "item_type": "exercise", "repetitions": 10, "weight": 0}, ]) return {"sess_id": sess_id, "ex1": ex1, "ex2": ex2} @pytest.fixture def asymmetric_session(page: Page, app_url: str): """A session with an asymmetric exercise.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "Lunge", asymmetric=True, alternate=True, default_reps=12, default_weight=0) sess_id = _create_session_with_exercises(page, "Asym Session", [ {"item_id": ex_id, "item_type": "exercise", "repetitions": 12, "weight": 0} ]) return {"sess_id": sess_id, "ex_id": ex_id} @pytest.fixture def combo_session(page: Page, app_url: str): """A session with a 2-exercise combo run for 2 rounds.""" _wait_for_db(page, app_url) _clean_all(page) ex1 = _create_exercise(page, "Squat", default_reps=15, default_weight=0) ex2 = _create_exercise(page, "Jump", default_reps=20, default_weight=0) combo_id = _create_combo(page, "HIIT", [ {"exerciseId": ex1, "defaultReps": 15, "defaultWeight": 0}, {"exerciseId": ex2, "defaultReps": 20, "defaultWeight": 0}, ]) sess_id = _create_session_with_exercises(page, "Combo Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 2, "weight": 0} ]) return {"sess_id": sess_id, "combo_id": combo_id, "ex1": ex1, "ex2": ex2} @pytest.fixture def mixed_session(page: Page, app_url: str): """A session with an exercise followed by a combo.""" _wait_for_db(page, app_url) _clean_all(page) ex_solo = _create_exercise(page, "Solo Ex", default_reps=5, default_weight=0) ex_c1 = _create_exercise(page, "Combo Ex1", default_reps=10, default_weight=0) ex_c2 = _create_exercise(page, "Combo Ex2", default_reps=10, default_weight=0) combo_id = _create_combo(page, "Mini Combo", [ {"exerciseId": ex_c1, "defaultReps": 10, "defaultWeight": 0}, {"exerciseId": ex_c2, "defaultReps": 10, "defaultWeight": 0}, ]) sess_id = _create_session_with_exercises(page, "Mixed Session", [ {"item_id": ex_solo, "item_type": "exercise", "repetitions": 5, "weight": 0}, {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0}, ]) return {"sess_id": sess_id} # ── tests ───────────────────────────────────────────────────────────────── def test_start_session_navigates_to_execute(page: Page, app_url: str, simple_session): """Clicking play button from the session detail view navigates to execute view.""" sess_id = simple_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}") page.wait_for_timeout(600) execute_btn = page.locator('[data-testid="session-execute-btn"]') expect(execute_btn).to_be_visible() execute_btn.click() page.wait_for_timeout(800) assert f"/sessions/{sess_id}/execute" in page.url expect(page.locator('[data-testid="execute-session-title"]')).to_be_visible() def test_session_list_play_buttons(page: Page, app_url: str, simple_session): """Every session card in the list shows a play button.""" sess_id = simple_session["sess_id"] page.goto(f"{app_url}#/sessions") page.wait_for_timeout(600) play_btn = page.locator(f'[data-testid="session-card-play-{sess_id}"]') expect(play_btn).to_be_visible() def test_execute_view_elements(page: Page, app_url: str, two_step_session): """Execute view shows session title, progress, current step, and next step preview.""" sess_id = two_step_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) expect(page.locator('[data-testid="execute-session-title"]')).to_have_text("Two Step Session") expect(page.locator('[data-testid="execute-progress-label"]')).to_contain_text("0 / 2") expect(page.locator('[data-testid="execute-current-step"]')).to_be_visible() expect(page.locator('[data-testid="execute-reps-input"]')).to_be_visible() expect(page.locator('[data-testid="execute-weight-input"]')).to_be_visible() expect(page.locator('[data-testid="execute-done-btn"]')).to_be_visible() expect(page.locator('[data-testid="execute-next-step"]')).to_be_visible() def test_steps_advance_on_done(page: Page, app_url: str, two_step_session): """Pressing Done advances from one step to the next.""" sess_id = two_step_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) first_title = page.locator('[data-testid="execute-exercise-title"]').inner_text() assert first_title == "Step One" page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) second_title = page.locator('[data-testid="execute-exercise-title"]').inner_text() assert second_title == "Step Two" progress = page.locator('[data-testid="execute-progress-label"]').inner_text() assert "1 / 2" in progress def test_reps_weight_editable_and_saved(page: Page, app_url: str, simple_session): """Reps and weight inputs can be edited and the values are saved to the execution log.""" sess_id = simple_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) page.fill('[data-testid="execute-reps-input"]', "12") page.fill('[data-testid="execute-weight-input"]', "55") page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(800) # Check log saved correct values result = page.evaluate(f"""async () => {{ const logs = await window.__repos.executionLogs.getBySessionId('{sess_id}') if (!logs.length) return null const items = await window.__repos.executionLogs.getItems(logs[0].id) return items.length ? {{reps: items[0].reps_done, weight: items[0].weight_used}} : null }}""") assert result is not None assert result["reps"] == 12 assert result["weight"] == 55.0 def test_session_complete_after_last_step(page: Page, app_url: str, simple_session): """Completing all steps redirects back to the session detail view.""" sess_id = simple_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(800) assert f"/sessions/{sess_id}" in page.url assert "/execute" not in page.url def test_asymmetric_side_shown(page: Page, app_url: str, asymmetric_session): """Side toggle is visible for asymmetric exercises and hidden for non-asymmetric ones.""" _wait_for_db(page, app_url) _clean_all(page) ex_sym = _create_exercise(page, "Squat Sym", default_reps=10) ex_asym = _create_exercise(page, "Lunge Asym", asymmetric=True, alternate=True, default_reps=10) sess_id = _create_session_with_exercises(page, "Side Test Session", [ {"item_id": ex_sym, "item_type": "exercise", "repetitions": 10, "weight": 0}, {"item_id": ex_asym, "item_type": "exercise", "repetitions": 10, "weight": 0}, ]) page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) # First step: non-asymmetric — no side toggle expect(page.locator('[data-testid="execute-side-toggle"]')).not_to_be_visible() page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) # Second step: asymmetric — side toggle visible side_toggle = page.locator('[data-testid="execute-side-toggle"]') expect(side_toggle).to_be_visible() assert "LEFT" in side_toggle.inner_text() or "RIGHT" in side_toggle.inner_text() # Toggle side initial = side_toggle.inner_text() side_toggle.click() page.wait_for_timeout(200) toggled = side_toggle.inner_text() assert initial != toggled def test_asymmetric_side_logged(page: Page, app_url: str, asymmetric_session): """Side value is stored in execution_log_item.side for asymmetric exercises.""" sess_id = asymmetric_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) # Switch to RIGHT page.click('[data-testid="execute-side-toggle"]') page.wait_for_timeout(200) page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(800) result = page.evaluate(f"""async () => {{ const logs = await window.__repos.executionLogs.getBySessionId('{sess_id}') if (!logs.length) return null const items = await window.__repos.executionLogs.getItems(logs[0].id) return items.length ? items[0].side : null }}""") assert result == "right" def test_combo_rounds_auto_advance(page: Page, app_url: str, combo_session): """Between non-final rounds of a combo, no final-round screen appears.""" sess_id = combo_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) # Step 1: Squat Round 1 page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) # Step 2: Jump Round 1 — after Done, should auto-advance to Round 2 (no pause screen) page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) # No final-round screen; we should be on Squat Round 2 expect(page.locator('[data-testid="execute-final-round"]')).not_to_be_visible() expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("Squat") combo_ctx = page.locator('[data-testid="execute-combo-context"]').inner_text() assert "2" in combo_ctx def test_final_round_screen_shown(page: Page, app_url: str, combo_session): """Final-round screen appears after the last exercise of the last round.""" sess_id = combo_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) # Complete all 4 steps (2 exercises × 2 rounds) for _ in range(3): page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) # Step 4 — last exercise of last round page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="execute-final-round"]')).to_be_visible() expect(page.locator('[data-testid="execute-continue-btn"]')).to_be_visible() expect(page.locator('[data-testid="execute-one-more-btn"]')).to_be_visible() def test_continue_advances_past_combo(page: Page, app_url: str, mixed_session): """Continue → after last combo round advances to the next session item.""" sess_id = mixed_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) # Solo Ex (step 1) page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) # Combo Ex1 (step 2, round 1 of 1) page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) # Combo Ex2 (step 3, last step of last round) → final-round screen page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="execute-final-round"]')).to_be_visible() # Click Continue → should finish session and redirect (no more items) page.click('[data-testid="execute-continue-btn"]') page.wait_for_timeout(800) assert f"/sessions/{sess_id}" in page.url assert "/execute" not in page.url def test_one_more_round_appends_round(page: Page, app_url: str, combo_session): """One More Round inserts an extra round and updates the progress.""" sess_id = combo_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) # Complete all 4 steps to reach final-round screen for _ in range(4): page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="execute-final-round"]')).to_be_visible() # Click One More Round page.click('[data-testid="execute-one-more-btn"]') page.wait_for_timeout(500) # Should be on first exercise of new round progress = page.locator('[data-testid="execute-progress-label"]').inner_text() assert "/ 6" in progress # 4 original + 2 new = 6 total combo_ctx = page.locator('[data-testid="execute-combo-context"]').inner_text() assert "3" in combo_ctx # Round 3 def test_exit_mid_session_saves_partial_log(page: Page, app_url: str, two_step_session): """Exiting mid-session saves only the completed steps to the execution log.""" sess_id = two_step_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) # Complete step 1 only page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) # Exit mid-session page.click('[data-testid="execute-exit-btn"]') page.wait_for_timeout(800) assert f"/sessions/{sess_id}" in page.url assert "/execute" not in page.url result = page.evaluate(f"""async () => {{ const logs = await window.__repos.executionLogs.getBySessionId('{sess_id}') if (!logs.length) return null const items = await window.__repos.executionLogs.getItems(logs[0].id) return {{items: items.length, finishedAt: logs[0].finished_at}} }}""") assert result is not None assert result["items"] == 1 assert result["finishedAt"] is not None def test_prefill_from_last_session_run(page: Page, app_url: str, simple_session): """Re-executing a session prefills reps/weight from the last run.""" sess_id = simple_session["sess_id"] # First run with custom values page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) page.fill('[data-testid="execute-reps-input"]', "15") page.fill('[data-testid="execute-weight-input"]', "75") page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(800) # Second run — should prefill from last run page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) assert page.locator('[data-testid="execute-reps-input"]').input_value() == "15" assert page.locator('[data-testid="execute-weight-input"]').input_value() == "75" def test_no_prior_log_uses_defaults(page: Page, app_url: str, simple_session): """With no prior log, session item defaults are used for prefill.""" sess_id = simple_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) # Default reps=8, weight=50 (from fixture) assert page.locator('[data-testid="execute-reps-input"]').input_value() == "8" assert page.locator('[data-testid="execute-weight-input"]').input_value() == "50" def test_progress_bar_reflects_position(page: Page, app_url: str, two_step_session): """Progress label updates from 0/2 → 1/2 → redirects after 2/2.""" sess_id = two_step_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) expect(page.locator('[data-testid="execute-progress-label"]')).to_contain_text("0 / 2") page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="execute-progress-label"]')).to_contain_text("1 / 2") def test_combo_context_shown(page: Page, app_url: str, combo_session): """Combo name and round number are shown in the step card for combo exercises.""" sess_id = combo_session["sess_id"] page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) combo_ctx = page.locator('[data-testid="execute-combo-context"]') expect(combo_ctx).to_be_visible() ctx_text = combo_ctx.inner_text() assert "HIIT" in ctx_text assert "1" in ctx_text # round 1 assert "2" in ctx_text # of 2