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

316 lines
13 KiB
Python

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