""" Step 12 — Timers & Combo Execution Tests - test_tabata_form_inputs_shown: TABATA combo form shows work/rest time inputs - test_tabata_form_inputs_not_shown_for_other_types: Work/rest inputs hidden for non-TABATA types - test_tabata_timer_config_saved: TABATA work/rest times saved to timer_config - test_amrap_label_in_session_form: AMRAP combo item shows "Duration (min)" label - test_amrap_label_in_session_detail: AMRAP combo item shows minutes in detail view - test_emom_timer_visible: EMOM combo steps show a timer display - test_emom_timer_pause_resume: Pause button toggles pause state on EMOM timer - test_emom_done_early_advances: Tapping Done during EMOM advances to next step immediately - test_emom_auto_advance: EMOM timer auto-advances to next step after 60 seconds - test_amrap_timer_visible: AMRAP combo steps show a timer display - test_amrap_manual_done_cycles: Done button manually advances through AMRAP exercises - test_amrap_timeup_screen: Time-up overlay appears when AMRAP timer expires - test_amrap_continue_advances: Continue after time-up advances past AMRAP combo - test_tabata_work_phase_shows_timer: TABATA shows timer on work phase - test_tabata_rest_screen_after_work: REST screen appears after work phase timer expires - test_tabata_advances_after_rest: Next exercise appears after rest phase timer expires - test_tabata_pause_during_work: Pause freezes TABATA work timer - test_tabata_pause_during_rest: Pause freezes TABATA rest timer - test_non_timed_combo_unchanged: NONE-type combos still show final-round screen as before """ import pytest from playwright.sync_api import Page, expect # ── helpers ─────────────────────────────────────────────────────────────────── 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): page.wait_for_selector('[data-testid="execute-session-title"]', timeout=10000) page.wait_for_timeout(400) def _create_exercise(page: Page, title: str, default_reps=10, default_weight=0.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: false, alternate: false, image_urls: '[]', video_urls: '[]', default_reps: {default_reps}, default_weight: {default_weight}, created_by: userId }}) }}""") def _create_timed_combo(page: Page, title: str, combo_type: str, exercise_ids: list, timer_config: dict = None): if timer_config is None: timer_config = {} import json tc_json = json.dumps(timer_config) ex_json = str([{"exerciseId": eid, "defaultReps": 10, "defaultWeight": 0} for eid in exercise_ids]).replace("'", '"') 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: {repr(combo_type)}, timer_config: {tc_json}, created_by: userId }}) const exercises = {ex_json} await window.__repos.combos.setExercises(comboId, exercises) return comboId }}""") def _create_session_with_items(page: Page, title: str, items: list): import json items_json = json.dumps(items) 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} await window.__repos.sessions.setItems(sessId, items.map((it, i) => ({{ item_id: it.item_id, item_type: it.item_type, position: i, repetitions: it.repetitions, weight: it.weight || 0 }}))) return sessId }}""") 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) }""") def _navigate_to_execute(page: Page, app_url: str, sess_id: str): page.goto(f"{app_url}#/sessions/{sess_id}/execute") _wait_for_execute(page) # ── Combo form ──────────────────────────────────────────────────────────────── def test_tabata_form_inputs_shown(page: Page, app_url: str, browser_name: str): """TABATA combo form shows work time and rest time inputs.""" _wait_for_db(page, app_url) page.goto(f"{app_url}#/combos/new") page.wait_for_selector('[data-testid="combo-type"]', timeout=5000) # Before selecting TABATA, inputs should not be visible expect(page.locator('[data-testid="combo-tabata-work-time"]')).not_to_be_visible() expect(page.locator('[data-testid="combo-tabata-rest-time"]')).not_to_be_visible() # Select TABATA page.select_option('[data-testid="combo-type"]', "TABATA") expect(page.locator('[data-testid="combo-tabata-work-time"]')).to_be_visible() expect(page.locator('[data-testid="combo-tabata-rest-time"]')).to_be_visible() def test_tabata_form_inputs_not_shown_for_other_types(page: Page, app_url: str, browser_name: str): """Work/rest inputs are hidden for EMOM and AMRAP types.""" _wait_for_db(page, app_url) page.goto(f"{app_url}#/combos/new") page.wait_for_selector('[data-testid="combo-type"]', timeout=5000) for combo_type in ["EMOM", "AMRAP", "NONE"]: page.select_option('[data-testid="combo-type"]', combo_type) expect(page.locator('[data-testid="combo-tabata-work-time"]')).not_to_be_visible() expect(page.locator('[data-testid="combo-tabata-rest-time"]')).not_to_be_visible() def test_tabata_timer_config_saved(page: Page, app_url: str, browser_name: str): """TABATA work/rest times are persisted in timer_config.""" _wait_for_db(page, app_url) _clean_all(page) page.goto(f"{app_url}#/combos/new") page.wait_for_selector('[data-testid="combo-type"]', timeout=5000) page.fill('[data-testid="combo-title"]', "My Tabata") page.select_option('[data-testid="combo-type"]', "TABATA") page.fill('[data-testid="combo-tabata-work-time"]', "15") page.fill('[data-testid="combo-tabata-rest-time"]', "5") page.click('[data-testid="combo-save"]') page.wait_for_selector('[data-testid="combo-detail-title"]', timeout=8000) combo_id = page.url.split("/")[-1] timer_config = page.evaluate(f"""async () => {{ const c = await window.__repos.combos.getById({repr(combo_id)}) return c.timer_config }}""") assert timer_config.get("workTime") == 15 assert timer_config.get("restTime") == 5 # ── Session form / detail labels for AMRAP ──────────────────────────────────── def test_amrap_label_in_session_form(page: Page, app_url: str, browser_name: str): """AMRAP combo items show 'Duration (min)' instead of 'Reps' in session form.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "Jump") amrap_id = _create_timed_combo(page, "AMRAP Combo", "AMRAP", [ex_id]) none_id = _create_timed_combo(page, "None Combo", "NONE", [ex_id]) page.goto(f"{app_url}#/sessions/new") page.wait_for_selector('[data-testid="session-title"]', timeout=5000) # Add AMRAP combo page.select_option('[data-testid="session-item-type-select"]', "combo") page.wait_for_timeout(300) page.select_option('[data-testid="session-item-select"]', amrap_id) page.click('[data-testid="session-item-add"]') page.wait_for_timeout(300) # The reps label for AMRAP item should say "Duration (min)" label = page.locator('[data-testid="session-item-0"] label').first.inner_text() assert "min" in label.lower() or "duration" in label.lower() # Add NONE combo page.select_option('[data-testid="session-item-type-select"]', "combo") page.wait_for_timeout(300) page.select_option('[data-testid="session-item-select"]', none_id) page.click('[data-testid="session-item-add"]') page.wait_for_timeout(300) # The reps label for NONE combo should say "Reps" (not duration) label2 = page.locator('[data-testid="session-item-1"] label').first.inner_text() assert "rep" in label2.lower() or label2.lower() == "reps" def test_amrap_label_in_session_detail(page: Page, app_url: str, browser_name: str): """AMRAP combo items show minutes unit in session detail view.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "Burpee") amrap_id = _create_timed_combo(page, "AMRAP Detail", "AMRAP", [ex_id]) sess_id = _create_session_with_items(page, "AMRAP Session", [ {"item_id": amrap_id, "item_type": "combo", "repetitions": 10, "weight": 0} ]) page.goto(f"{app_url}#/sessions/{sess_id}") page.wait_for_selector('[data-testid="session-detail-title"]', timeout=5000) detail_text = page.locator('[data-testid="session-detail-item-details-0"]').inner_text() # Should show "10 minutes" (not "10 reps") assert "10" in detail_text assert "min" in detail_text.lower() # ── EMOM timer ──────────────────────────────────────────────────────────────── def test_emom_timer_visible(page: Page, app_url: str, browser_name: str): """EMOM combo steps show a timer display.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "EMOM Press") combo_id = _create_timed_combo(page, "EMOM Combo", "EMOM", [ex_id]) sess_id = _create_session_with_items(page, "EMOM Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) timer = page.locator('[data-testid="execute-timer"]') expect(timer).to_be_visible() # Should show countdown format MM:SS timer_text = timer.inner_text() assert ":" in timer_text def test_emom_timer_pause_resume(page: Page, app_url: str, browser_name: str): """Pause button toggles pause state — button label changes.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "EMOM Press P") combo_id = _create_timed_combo(page, "EMOM Pause Combo", "EMOM", [ex_id]) sess_id = _create_session_with_items(page, "EMOM Pause Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) timer = page.locator('[data-testid="execute-timer"]') expect(timer).to_be_visible() # Initially timer is running — button should say Pause pause_btn = timer.locator('button') initial_label = pause_btn.inner_text() assert "pause" in initial_label.lower() or "pause" in pause_btn.get_attribute("aria-label", "").lower() # Click pause pause_btn.click() page.wait_for_timeout(300) # Button should now say Resume resume_label = pause_btn.inner_text() assert "resume" in resume_label.lower() or "resume" in pause_btn.get_attribute("aria-label", "").lower() # Click again to resume pause_btn.click() page.wait_for_timeout(300) after_resume = pause_btn.inner_text() assert "pause" in after_resume.lower() or "pause" in pause_btn.get_attribute("aria-label", "").lower() def test_emom_done_early_advances(page: Page, app_url: str, browser_name: str): """Tapping Done during EMOM advances to the next step immediately.""" _wait_for_db(page, app_url) _clean_all(page) ex1 = _create_exercise(page, "EMOM Ex1") ex2 = _create_exercise(page, "EMOM Ex2") combo_id = _create_timed_combo(page, "EMOM Two", "EMOM", [ex1, ex2]) sess_id = _create_session_with_items(page, "EMOM Two Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) # We're on step 1 (EMOM Ex1) expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("EMOM Ex1") # Tap Done early (should advance without waiting 60 seconds) page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(500) # Now we should be on step 2 expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("EMOM Ex2") def test_emom_auto_advance(page: Page, app_url: str, browser_name: str): """EMOM timer auto-advances to next step after 60 seconds.""" page.clock.install() _wait_for_db(page, app_url) _clean_all(page) ex1 = _create_exercise(page, "EMOM Auto1") ex2 = _create_exercise(page, "EMOM Auto2") combo_id = _create_timed_combo(page, "EMOM Auto", "EMOM", [ex1, ex2]) sess_id = _create_session_with_items(page, "EMOM Auto Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("EMOM Auto1") # Advance 61 seconds — timer should fire and auto-advance page.clock.run_for(61000) page.wait_for_timeout(500) expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("EMOM Auto2") # ── AMRAP timer ─────────────────────────────────────────────────────────────── def test_amrap_timer_visible(page: Page, app_url: str, browser_name: str): """AMRAP combo steps show a timer display.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "AMRAP Ex") combo_id = _create_timed_combo(page, "AMRAP Combo Vis", "AMRAP", [ex_id]) sess_id = _create_session_with_items(page, "AMRAP Vis Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 5, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) timer = page.locator('[data-testid="execute-timer"]') expect(timer).to_be_visible() # Timer should show 05:00 (5 minutes) timer_text = timer.inner_text() assert "05:00" in timer_text or "5:" in timer_text def test_amrap_manual_done_cycles(page: Page, app_url: str, browser_name: str): """Done button manually cycles through AMRAP exercises (auto-adds next round).""" _wait_for_db(page, app_url) _clean_all(page) ex1 = _create_exercise(page, "AMRAP Cycle1") ex2 = _create_exercise(page, "AMRAP Cycle2") combo_id = _create_timed_combo(page, "AMRAP Cycle", "AMRAP", [ex1, ex2]) sess_id = _create_session_with_items(page, "AMRAP Cycle Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 3, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) # Step 1: AMRAP Cycle1 expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("AMRAP Cycle1") # Done → advances to AMRAP Cycle2 page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(400) expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("AMRAP Cycle2") # Done → auto-cycles back to AMRAP Cycle1 (round 2) page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(400) expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("AMRAP Cycle1") def test_amrap_timeup_screen(page: Page, app_url: str, browser_name: str): """Time-up overlay appears when AMRAP timer expires.""" page.clock.install() _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "AMRAP TimeUp Ex") combo_id = _create_timed_combo(page, "AMRAP TimeUp", "AMRAP", [ex_id]) sess_id = _create_session_with_items(page, "AMRAP TimeUp Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) # Advance 61 seconds (1 minute AMRAP) page.clock.run_for(61000) page.wait_for_timeout(500) timeup = page.locator('[data-testid="execute-amrap-timeup"]') expect(timeup).to_be_visible() def test_amrap_continue_advances(page: Page, app_url: str, browser_name: str): """Continue after AMRAP time-up advances to the next session item.""" page.clock.install() _wait_for_db(page, app_url) _clean_all(page) ex_amrap = _create_exercise(page, "AMRAP Cont Ex") ex_next = _create_exercise(page, "After AMRAP") combo_id = _create_timed_combo(page, "AMRAP Cont", "AMRAP", [ex_amrap]) sess_id = _create_session_with_items(page, "AMRAP Cont Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0}, {"item_id": ex_next, "item_type": "exercise", "repetitions": 5, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) # Expire the AMRAP timer page.clock.run_for(61000) page.wait_for_timeout(500) expect(page.locator('[data-testid="execute-amrap-timeup"]')).to_be_visible() # Click Continue page.click('[data-testid="execute-amrap-continue-btn"]') page.wait_for_timeout(400) # Should now be on the next exercise expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("After AMRAP") # ── TABATA timer ────────────────────────────────────────────────────────────── def test_tabata_work_phase_shows_timer(page: Page, app_url: str, browser_name: str): """TABATA step shows timer during work phase.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "TABATA Work Ex") combo_id = _create_timed_combo(page, "TABATA Work", "TABATA", [ex_id], timer_config={"workTime": 30, "restTime": 10}) sess_id = _create_session_with_items(page, "TABATA Work Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) timer = page.locator('[data-testid="execute-timer"]') expect(timer).to_be_visible() timer_text = timer.inner_text() assert ":" in timer_text def test_tabata_rest_screen_after_work(page: Page, app_url: str, browser_name: str): """REST screen appears after TABATA work phase timer expires (using short times).""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "TABATA Rest Ex") combo_id = _create_timed_combo(page, "TABATA Rest", "TABATA", [ex_id], timer_config={"workTime": 2, "restTime": 10}) sess_id = _create_session_with_items(page, "TABATA Rest Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) # Wait for work phase to expire (2 seconds + margin) page.wait_for_timeout(3500) rest_card = page.locator('[data-testid="execute-tabata-rest"]') expect(rest_card).to_be_visible() def test_tabata_advances_after_rest(page: Page, app_url: str, browser_name: str): """Next exercise appears after TABATA rest phase timer expires.""" _wait_for_db(page, app_url) _clean_all(page) ex1 = _create_exercise(page, "TABATA Adv1") ex2 = _create_exercise(page, "TABATA Adv2") combo_id = _create_timed_combo(page, "TABATA Adv", "TABATA", [ex1, ex2], timer_config={"workTime": 2, "restTime": 1}) sess_id = _create_session_with_items(page, "TABATA Adv Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) # Step 1 is TABATA Adv1 expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("TABATA Adv1") # Wait for work (2s) + rest (1s) + margin → Playwright polls until ex2 appears expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text( "TABATA Adv2", timeout=8000 ) def test_tabata_done_starts_rest(page: Page, app_url: str, browser_name: str): """Tapping Done during TABATA work phase starts the rest phase immediately.""" _wait_for_db(page, app_url) _clean_all(page) ex1 = _create_exercise(page, "TABATA Done1") ex2 = _create_exercise(page, "TABATA Done2") combo_id = _create_timed_combo(page, "TABATA Done", "TABATA", [ex1, ex2], timer_config={"workTime": 60, "restTime": 60}) sess_id = _create_session_with_items(page, "TABATA Done Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("TABATA Done1") # Tap Done early — should start rest phase page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(400) rest_card = page.locator('[data-testid="execute-tabata-rest"]') expect(rest_card).to_be_visible() def test_tabata_pause_during_work(page: Page, app_url: str, browser_name: str): """Pause freezes TABATA work timer — REST screen does not appear while paused.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "TABATA Pause Ex") combo_id = _create_timed_combo(page, "TABATA Pause", "TABATA", [ex_id], timer_config={"workTime": 2, "restTime": 1}) sess_id = _create_session_with_items(page, "TABATA Pause Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) timer = page.locator('[data-testid="execute-timer"]') expect(timer).to_be_visible() # Pause immediately timer.locator('button').click() page.wait_for_timeout(3500) # Wait longer than workTime (2s) # Timer is paused — REST screen should NOT appear expect(page.locator('[data-testid="execute-tabata-rest"]')).not_to_be_visible() expect(page.locator('[data-testid="execute-current-step"]')).to_be_visible() def test_tabata_pause_during_rest(page: Page, app_url: str, browser_name: str): """Pause freezes TABATA rest timer — next exercise does not appear while paused.""" _wait_for_db(page, app_url) _clean_all(page) ex1 = _create_exercise(page, "TABATA PauseR1") ex2 = _create_exercise(page, "TABATA PauseR2") combo_id = _create_timed_combo(page, "TABATA PauseR", "TABATA", [ex1, ex2], timer_config={"workTime": 2, "restTime": 10}) sess_id = _create_session_with_items(page, "TABATA PauseR Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) # Wait for work phase to complete (2s) page.wait_for_timeout(3000) rest_card = page.locator('[data-testid="execute-tabata-rest"]') expect(rest_card).to_be_visible() # Pause during rest rest_card.locator('button').click() page.wait_for_timeout(3000) # Wait well beyond rest time (paused) # Should still be on rest screen (not advanced to ex2) expect(rest_card).to_be_visible() expect(page.locator('[data-testid="execute-exercise-title"]')).not_to_be_visible() # ── Non-timed combo unchanged ───────────────────────────────────────────────── def test_non_timed_combo_unchanged(page: Page, app_url: str, browser_name: str): """NONE-type combos still show the final-round screen — no timer displayed.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "None Combo Ex") combo_id = _create_timed_combo(page, "None Combo", "NONE", [ex_id]) sess_id = _create_session_with_items(page, "None Session", [ {"item_id": combo_id, "item_type": "combo", "repetitions": 1, "weight": 0} ]) _navigate_to_execute(page, app_url, sess_id) # No timer should be shown expect(page.locator('[data-testid="execute-timer"]')).not_to_be_visible() # Tap Done page.click('[data-testid="execute-done-btn"]') page.wait_for_timeout(400) # Final-round screen should appear (unchanged from Step 8 behaviour) 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()