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