trainUs/tests/test_step18_execution_abandon.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

228 lines
9.3 KiB
Python

"""
Step 18 — Execution Abandon Tests (R4)
R4 — Execution log status column + route-leave guard:
- test_completed_session_has_completed_status: Finishing a session sets status='completed'
- test_exit_button_sets_aborted_status: Using the exit button sets status='aborted'
- test_leave_guard_shows_confirm_dialog: Navigating away mid-session triggers a confirm dialog
- test_leave_guard_cancel_stays_on_page: Cancelling the confirm keeps the user on the execute view
- test_leave_guard_confirm_sets_aborted: Confirming the leave dialog marks the log as aborted
- test_no_null_finished_at_after_abandon: After any navigation away, no log has finished_at=NULL
- test_stats_only_count_completed_sessions: Stats total-sessions count excludes aborted logs
"""
import json
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 _clean_all(page: Page):
page.evaluate("""async () => {
const logs = await window.__repos.executionLogs.getAll()
for (const l of logs) await window.__repos.executionLogs.delete(l.id)
const sessions = await window.__repos.sessions.getAll()
for (const s of sessions) await window.__repos.sessions.delete(s.id)
const exercises = await window.__repos.exercises.getAll()
for (const e of exercises) await window.__repos.exercises.delete(e.id)
}""")
def _create_exercise(page: Page, title: str) -> str:
return page.evaluate(
"""async ([title]) => {
const userId = await window.__repos.settings.get('user_uuid')
return window.__repos.exercises.create({
title, description: '', asymmetric: false, alternate: false,
image_urls: '[]', video_urls: '[]', default_reps: 5, default_weight: 0,
created_by: userId
})
}""",
[title],
)
def _create_session(page: Page, title: str, ex_id: str) -> str:
return page.evaluate(
"""async ([title, exId]) => {
const userId = await window.__repos.settings.get('user_uuid')
const sessId = await window.__repos.sessions.create({title, description: '', created_by: userId})
await window.__repos.sessions.setItems(sessId, [{
item_id: exId, item_type: 'exercise', position: 0, repetitions: 5, weight: 0
}])
return sessId
}""",
[title, ex_id],
)
def _navigate_to_execute(page: Page, app_url: str, sess_id: str):
"""Navigate to the execute view for a given session."""
page.goto(f"{app_url}#/sessions/{sess_id}/execute")
page.wait_for_selector('[data-testid="execute-session-title"]', timeout=10000)
page.wait_for_timeout(300)
@pytest.fixture
def session_and_exercise(page: Page, app_url: str):
"""Create a session with one exercise and return (sess_id, ex_id)."""
_wait_for_db(page, app_url)
_clean_all(page)
ex_id = _create_exercise(page, "Test Exercise")
sess_id = _create_session(page, "Test Session", ex_id)
return sess_id, ex_id
def test_completed_session_has_completed_status(page: Page, app_url: str, session_and_exercise):
"""Finishing all steps sets the execution log status to 'completed'."""
sess_id, _ = session_and_exercise
_navigate_to_execute(page, app_url, sess_id)
# Click Done to complete the single exercise step — for a one-exercise session this
# redirects directly to the session detail view (no final-round screen)
page.click('[data-testid="execute-done-btn"]')
page.wait_for_function(
f"window.location.hash.includes('/sessions/{sess_id}') && !window.location.hash.includes('/execute')",
timeout=5000,
)
page.wait_for_timeout(300)
logs = page.evaluate("""async () => window.__repos.executionLogs.getAll()""")
assert len(logs) == 1, f"Expected 1 log, got {len(logs)}"
assert logs[0]["status"] == "completed", f"Expected status=completed, got: {logs[0]}"
assert logs[0]["finished_at"] is not None, "finished_at should be set"
def test_exit_button_sets_aborted_status(page: Page, app_url: str, session_and_exercise):
"""Using the exit button during execution marks the log status as 'aborted'."""
sess_id, _ = session_and_exercise
_navigate_to_execute(page, app_url, sess_id)
# Click the exit button and confirm the dialog
page.once("dialog", lambda d: d.accept())
page.click('[data-testid="execute-exit-btn"]')
page.wait_for_timeout(500)
logs = page.evaluate("""async () => window.__repos.executionLogs.getAll()""")
assert len(logs) == 1, f"Expected 1 log, got {len(logs)}"
assert logs[0]["status"] == "aborted", f"Expected status=aborted, got: {logs[0]}"
assert logs[0]["finished_at"] is not None, "finished_at should be set on abort"
def test_leave_guard_shows_confirm_dialog(page: Page, app_url: str, session_and_exercise):
"""Navigating away via the nav bar while execution is in progress triggers a confirm dialog."""
sess_id, _ = session_and_exercise
_navigate_to_execute(page, app_url, sess_id)
# Intercept the dialog to detect it appeared, then dismiss (cancel)
dialog_appeared = []
page.once("dialog", lambda d: (dialog_appeared.append(d.message), d.dismiss()))
page.click('a[href="#/home"]')
page.wait_for_timeout(500)
assert len(dialog_appeared) > 0, "Leave guard should show a confirm dialog"
# Should still be on the execute view (dismiss = stay)
page.wait_for_selector('[data-testid="execute-session-title"]', timeout=3000)
def test_leave_guard_cancel_stays_on_page(page: Page, app_url: str, session_and_exercise):
"""Dismissing the leave confirm keeps the user on the execute view."""
sess_id, _ = session_and_exercise
_navigate_to_execute(page, app_url, sess_id)
page.once("dialog", lambda d: d.dismiss())
page.click('a[href="#/home"]')
page.wait_for_timeout(500)
title_el = page.locator('[data-testid="execute-session-title"]')
expect(title_el).to_be_visible()
def test_leave_guard_confirm_sets_aborted(page: Page, app_url: str, session_and_exercise):
"""Accepting the leave confirm marks the log as aborted and navigates away."""
sess_id, _ = session_and_exercise
_navigate_to_execute(page, app_url, sess_id)
# Accept the dialog → exit() is called → status='aborted'
page.once("dialog", lambda d: d.accept())
page.click('a[href="#/home"]')
page.wait_for_timeout(800)
# We should no longer be on the execute view
title_el = page.locator('[data-testid="execute-session-title"]')
expect(title_el).not_to_be_visible(timeout=3000)
logs = page.evaluate("""async () => window.__repos.executionLogs.getAll()""")
assert len(logs) == 1, f"Expected 1 log after abandon, got {len(logs)}"
assert logs[0]["status"] == "aborted", f"Expected aborted, got: {logs[0]['status']}"
def test_no_null_finished_at_after_abandon(page: Page, app_url: str, session_and_exercise):
"""After confirming the leave dialog, no execution_log row has finished_at=NULL."""
sess_id, _ = session_and_exercise
_navigate_to_execute(page, app_url, sess_id)
page.once("dialog", lambda d: d.accept())
page.click('a[href="#/home"]')
page.wait_for_timeout(800)
null_logs = page.evaluate(
"""async () => {
const logs = await window.__repos.executionLogs.getAll()
return logs.filter(l => l.finished_at === null || l.finished_at === undefined)
}"""
)
assert len(null_logs) == 0, f"No log should have NULL finished_at; orphans: {null_logs}"
def test_stats_only_count_completed_sessions(page: Page, app_url: str):
"""Stats total-sessions card counts only completed logs, not aborted ones."""
_wait_for_db(page, app_url)
_clean_all(page)
ex_id = _create_exercise(page, "Stat Exercise")
sess_id = _create_session(page, "Stat Session", ex_id)
now = "2026-01-15T10:00:00.000Z"
# Seed one completed log and one aborted log
page.evaluate(
"""async ([sessId, exId, now]) => {
const completedId = await window.__repos.executionLogs.create({
session_id: sessId,
started_at: now,
finished_at: now,
})
await window.__repos.executionLogs.update(completedId, { status: 'completed', finished_at: now })
const abortedId = await window.__repos.executionLogs.create({
session_id: sessId,
started_at: now,
finished_at: now,
})
await window.__repos.executionLogs.update(abortedId, { status: 'aborted', finished_at: now })
}""",
[sess_id, ex_id, now],
)
# Navigate to stats (all-time range)
page.evaluate("() => { window.location.hash = '#/stats' }")
page.wait_for_selector('[data-testid="stats-total-sessions"]', timeout=5000)
page.wait_for_timeout(500)
# Select all-time tab (last tab)
date_range = page.locator('[data-testid="stats-date-range"] button')
count = date_range.count()
date_range.nth(count - 1).click()
page.wait_for_timeout(300)
total_text = page.locator('[data-testid="stats-total-sessions"]').text_content()
# The count should be 1 (only completed), not 2
assert "1" in total_text, f"Stats should count 1 completed session, got text: '{total_text}'"