trainUs/tests/test_step8_execution.py
David 352a3d5e88
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Initial commit
2026-06-18 13:06:57 +02:00

517 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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