228 lines
9.3 KiB
Python
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}'"
|