""" Step 15 — Audio Cues Tests The WebAudio API is replaced by a lightweight mock on every page load so that each beep is recorded in window._audioLog as {freq: number}: - 440 Hz → soft bip - 880 Hz → loud BIP page.clock.install() controls fake time so setInterval ticks are driven explicitly with page.clock.run_for(ms), making timer-dependent assertions instant and deterministic. TABATA audio cues - test_tabata_bip_at_half_work_interval: 440 Hz bip fires at half of workTime - test_tabata_countdown_end_of_work_phase: bip,bip,BIP at the end of the work phase - test_tabata_bip_at_half_rest_interval: 440 Hz bip fires at half of restTime - test_tabata_countdown_end_of_rest_phase: bip,bip,BIP at the end of the rest phase AMRAP audio cues - test_amrap_minute_mark_plays_bip: soft bip (not BIP) fires at each elapsed minute - test_amrap_half_time_plays_BIP: loud BIP fires at exactly the halfway point - test_amrap_last_minute_countdown: bip at r=2, bip at r=1, BIP at expiry """ import json import pytest from playwright.sync_api import Page, expect # ── Audio mock injected before every page load ──────────────────────────────── _AUDIO_MOCK = """ window._audioLog = []; window.AudioContext = function () { return { state: 'running', currentTime: 0, createOscillator: function () { let _freq = 0; return { connect: function () {}, frequency: { get value() { return _freq; }, set value(v) { _freq = v; } }, start: function () {}, stop: function () { window._audioLog.push({ freq: _freq }); } }; }, createGain: function () { return { connect: function () {}, gain: { value: 0 } }; }, resume: function () { return Promise.resolve(); }, destination: {} }; }; """ # ── Shared helpers ──────────────────────────────────────────────────────────── def _setup(page: Page, app_url: str): """Install fake clock and audio mock, then load the app.""" page.clock.install() page.add_init_script(_AUDIO_MOCK) page.goto(app_url) page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) def _create_exercise(page: Page, title: str) -> str: return page.evaluate(f"""async () => {{ const uid = 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: 10, default_weight: 0, created_by: uid }}) }}""") def _create_timed_combo(page: Page, title: str, combo_type: str, exercise_ids: list, timer_config: dict = None) -> str: tc = json.dumps(timer_config or {}) exs = json.dumps([{"exerciseId": eid, "defaultReps": 10, "defaultWeight": 0} for eid in exercise_ids]) return page.evaluate(f"""async () => {{ const uid = await window.__repos.settings.get('user_uuid') const cid = await window.__repos.combos.create({{ title: {repr(title)}, description: '', type: {repr(combo_type)}, timer_config: {tc}, created_by: uid }}) await window.__repos.combos.setExercises(cid, {exs}) return cid }}""") def _create_session(page: Page, title: str, items: list) -> str: items_json = json.dumps(items) return page.evaluate(f"""async () => {{ const uid = await window.__repos.settings.get('user_uuid') const sid = await window.__repos.sessions.create({{ title: {repr(title)}, description: '', created_by: uid }}) const items = {items_json} await window.__repos.sessions.setItems(sid, items.map((it, i) => ({{ item_id: it.item_id, item_type: it.item_type, position: i, repetitions: it.repetitions, weight: 0 }}))) return sid }}""") def _clean_all(page: Page): page.evaluate("""async () => { for (const s of await window.__repos.sessions.getAll()) await window.__repos.sessions.delete(s.id) for (const c of await window.__repos.combos.getAll()) await window.__repos.combos.delete(c.id) for (const e of await window.__repos.exercises.getAll()) 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") page.wait_for_selector('[data-testid="execute-session-title"]', timeout=10000) page.wait_for_timeout(400) def _freqs(page: Page) -> list[int]: """Return the list of recorded frequencies from the audio mock.""" return [e["freq"] for e in page.evaluate("window._audioLog")] # ── TABATA audio cue tests ──────────────────────────────────────────────────── def test_tabata_bip_at_half_work_interval(page: Page, app_url: str, browser_name: str): """A soft bip (440 Hz) fires at exactly the half-interval point of the work phase.""" _setup(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "Tab HalfWork Ex") # workTime=20 → half=10; restTime=60 so we stay in work phase during the test combo_id = _create_timed_combo(page, "Tab HalfWork", "TABATA", [ex_id], timer_config={"workTime": 20, "restTime": 60}) sess_id = _create_session(page, "Tab HalfWork Session", [{"item_id": combo_id, "item_type": "combo", "repetitions": 1}]) _navigate_to_execute(page, app_url, sess_id) # Immediately after load: BIP(880) fires for work phase start page.clock.run_for(10_000) # advance to r=10 — exactly the half-interval page.wait_for_timeout(300) f = _freqs(page) # Expected so far: BIP at t=0, bip at t=10 (half) assert f.count(880) == 1, f"Expected 1 BIP (work start) so far: {f}" assert f.count(440) == 1, f"Expected exactly 1 bip at half-interval: {f}" assert f == [880, 440], f"Unexpected audio sequence: {f}" def test_tabata_countdown_end_of_work_phase(page: Page, app_url: str, browser_name: str): """bip, bip, BIP fires at the last 2 seconds of the work phase (1-second spacing).""" _setup(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "Tab CntdwnWork Ex") # workTime=10 → half=5, countdown at r=2 and r=1, BIP at rest start combo_id = _create_timed_combo(page, "Tab CntdwnWork", "TABATA", [ex_id], timer_config={"workTime": 10, "restTime": 60}) sess_id = _create_session(page, "Tab CntdwnWork Session", [{"item_id": combo_id, "item_type": "combo", "repetitions": 1}]) _navigate_to_execute(page, app_url, sess_id) page.clock.run_for(10_000) # full work phase page.wait_for_timeout(300) f = _freqs(page) # BIP(work start), bip(r=5 half), bip(r=2), bip(r=1), BIP(rest start) assert f == [880, 440, 440, 440, 880], f"Unexpected audio sequence: {f}" def test_tabata_bip_at_half_rest_interval(page: Page, app_url: str, browser_name: str): """A soft bip (440 Hz) fires at the half-interval point of the rest phase.""" _setup(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "Tab HalfRest Ex") # workTime=10 (half=5), restTime=20 (half=10) combo_id = _create_timed_combo(page, "Tab HalfRest", "TABATA", [ex_id], timer_config={"workTime": 10, "restTime": 20}) sess_id = _create_session(page, "Tab HalfRest Session", [{"item_id": combo_id, "item_type": "combo", "repetitions": 1}]) _navigate_to_execute(page, app_url, sess_id) # 10s work + 10s into rest (= rest half-interval) page.clock.run_for(20_000) page.wait_for_timeout(300) f = _freqs(page) # Work: BIP(start) bip(r=5) bip(r=2) bip(r=1) BIP(rest start) # Rest (first 10 s): bip(r=10 half) assert f == [880, 440, 440, 440, 880, 440], f"Unexpected audio sequence: {f}" def test_tabata_countdown_end_of_rest_phase(page: Page, app_url: str, browser_name: str): """bip, bip, BIP fires at the last 2 seconds of the rest phase.""" _setup(page, app_url) _clean_all(page) ex1 = _create_exercise(page, "Tab CntdwnRest Ex1") ex2 = _create_exercise(page, "Tab CntdwnRest Ex2") # workTime=10 (half=5), restTime=10 (half=5); two exercises to verify rest→work BIP combo_id = _create_timed_combo(page, "Tab CntdwnRest", "TABATA", [ex1, ex2], timer_config={"workTime": 10, "restTime": 10}) sess_id = _create_session(page, "Tab CntdwnRest Session", [{"item_id": combo_id, "item_type": "combo", "repetitions": 1}]) _navigate_to_execute(page, app_url, sess_id) # 10s work + 10s rest page.clock.run_for(20_000) page.wait_for_timeout(300) f = _freqs(page) # Work: BIP bip(r=5) bip(r=2) bip(r=1) BIP(rest start) # Rest: bip(r=5) bip(r=2) bip(r=1) BIP(work2 start) assert f == [880, 440, 440, 440, 880, 440, 440, 440, 880], \ f"Unexpected audio sequence: {f}" # ── AMRAP audio cue tests ───────────────────────────────────────────────────── def test_amrap_minute_mark_plays_bip(page: Page, app_url: str, browser_name: str): """Soft bip (440 Hz, not BIP) fires at each elapsed minute outside the last minute.""" _setup(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "AMRAP Min Bip Ex") # 4-min AMRAP (240 s): halfDuration=120; first minute fires at r=180 (t=60) combo_id = _create_timed_combo(page, "AMRAP Min Bip", "AMRAP", [ex_id]) sess_id = _create_session(page, "AMRAP Min Bip Session", [{"item_id": combo_id, "item_type": "combo", "repetitions": 4}]) _navigate_to_execute(page, app_url, sess_id) # AMRAP plays no BIP at start page.clock.run_for(61_000) # past the 1-minute mark, before half-time (120 s) page.wait_for_timeout(300) f = _freqs(page) assert 880 not in f, f"BIP should not fire before half-time: {f}" assert f.count(440) == 1, f"Expected exactly 1 bip at the 1-minute mark: {f}" def test_amrap_half_time_plays_BIP(page: Page, app_url: str, browser_name: str): """Loud BIP (880 Hz) fires at the halfway point of the total AMRAP duration.""" _setup(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "AMRAP Half BIP Ex") # 4-min AMRAP (240 s): halfDuration = round(240/2) = 120 s # At t=60 (r=180): bip for 1-min mark # At t=120 (r=120): BIP for half-time (also coincides with 2-min mark, BIP takes priority) combo_id = _create_timed_combo(page, "AMRAP Half BIP", "AMRAP", [ex_id]) sess_id = _create_session(page, "AMRAP Half BIP Session", [{"item_id": combo_id, "item_type": "combo", "repetitions": 4}]) _navigate_to_execute(page, app_url, sess_id) page.clock.run_for(121_000) # past both 1-min bip (t=60) and half-time BIP (t=120) page.wait_for_timeout(300) f = _freqs(page) assert 880 in f, f"Half-time BIP should have fired: {f}" assert f.count(880) == 1, f"Expected exactly 1 BIP (half-time), got {f.count(880)}: {f}" # The minute bip at t=60 must precede the half-time BIP at t=120 assert f.index(440) < f.index(880), f"Minute bip should precede half-time BIP: {f}" def test_amrap_last_minute_countdown(page: Page, app_url: str, browser_name: str): """bip at r=2 and r=1 lead into the final BIP when AMRAP time expires (1-second spacing).""" _setup(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "AMRAP LastMin Ex") # 3-min AMRAP (180 s): halfDuration=90 # Full expected sequence: # bip at t=60 (r=120, 1-min mark) # BIP at t=90 (r=90, half-time) # bip at t=150 (r=30, midpoint of last minute) # bip at t=178 (r=2, countdown) # bip at t=179 (r=1, countdown) # BIP at t=180 (r=0, expiry) combo_id = _create_timed_combo(page, "AMRAP LastMin", "AMRAP", [ex_id]) sess_id = _create_session(page, "AMRAP LastMin Session", [{"item_id": combo_id, "item_type": "combo", "repetitions": 3}]) _navigate_to_execute(page, app_url, sess_id) # Advance the full AMRAP duration in one step for deterministic results page.clock.run_for(180_000) page.wait_for_timeout(300) f = _freqs(page) assert f == [440, 880, 440, 440, 440, 880], f"Unexpected full AMRAP audio sequence: {f}" # Verify the bip,bip,BIP countdown is the final 3 sounds assert f[-3:] == [440, 440, 880], f"Last 3 sounds should be bip,bip,BIP: {f}"