316 lines
13 KiB
Python
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}"
|