trainUs/tests/test_step26_voice_announcements.py
David 1424029eae
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
Step 26 to announce exercise
2026-07-06 15:33:33 +02:00

357 lines
13 KiB
Python

"""
Step 26 — Voice Announcements (Web Speech API)
`speechSynthesis` / `SpeechSynthesisUtterance` are replaced by a lightweight
mock injected before every page load: each call to `speak()` is recorded into
`window.__spokenTexts` as `{ text, lang, volume }`.
page.clock.install() controls fake time so EMOM/TABATA timer-driven
pre-announcements are deterministic (as in Step 15).
- test_first_step_title_spoken_on_start: the first step's exercise title is
announced when execution begins
- test_title_spoken_on_advance: advancing to the next step announces its title
- test_emom_speaks_next_at_10s: EMOM speaks "Next: X" 10 s before a slot ends
- test_tabata_speaks_next_on_rest: TABATA speaks "Next: X" when the rest phase
opens
- test_no_duplicate_announcement_after_preannounce: the step-change
announcement is skipped right after a pre-announcement for that same step
- test_utterance_lang_follows_app_language: utterance `lang` matches the
current i18next language
- test_utterance_volume_follows_audio_volume: utterance `volume` matches the
`audio_volume` setting
- test_voice_disabled_nothing_spoken: unchecking the setting silences all
announcements, and the setting persists across reload
- test_works_with_speech_synthesis_absent: execution works normally (red path)
when `speechSynthesis` does not exist
"""
import json
import pytest
from playwright.sync_api import Page, expect
# ── Speech mock injected before every page load ───────────────────────────────
_SPEECH_MOCK = """
window.__spokenTexts = [];
function FakeUtterance(text) {
this.text = text;
this.lang = '';
this.volume = 1;
}
window.SpeechSynthesisUtterance = FakeUtterance;
Object.defineProperty(window, 'speechSynthesis', {
configurable: true,
value: {
cancel: function () {},
speak: function (u) {
window.__spokenTexts.push({ text: u.text, lang: u.lang, volume: u.volume });
},
},
});
"""
_SPEECH_ABSENT = """
delete Window.prototype.speechSynthesis;
"""
def _setup(page: Page, app_url: str, mock_speech=True, install_clock=False):
if install_clock:
page.clock.install()
if mock_speech:
page.add_init_script(_SPEECH_MOCK)
else:
page.add_init_script(_SPEECH_ABSENT)
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_combo(page: Page, title: str, combo_type: str, exercise_ids: list,
timer_config: dict = None):
tc_json = json.dumps(timer_config or {})
ex_json = 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_json}, created_by: uid
}})
await window.__repos.combos.setExercises(cid, {ex_json})
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 l of await window.__repos.executionLogs.getAll())
await window.__repos.executionLogs.delete(l.id)
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(300)
def _spoken(page: Page) -> list[dict]:
return page.evaluate("window.__spokenTexts")
# ── Tests ──────────────────────────────────────────────────────────────────────
def test_first_step_title_spoken_on_start(page: Page, app_url: str):
"""The first step's exercise title is announced when execution begins."""
_setup(page, app_url)
_clean_all(page)
ex1 = _create_exercise(page, "Voice Ex1")
sess_id = _create_session(page, "Voice Session", [
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
])
_navigate_to_execute(page, app_url, sess_id)
spoken = _spoken(page)
assert any(s["text"] == "Voice Ex1" for s in spoken), f"Unexpected spoken texts: {spoken}"
def test_title_spoken_on_advance(page: Page, app_url: str):
"""Advancing to the next step announces its title."""
_setup(page, app_url)
_clean_all(page)
ex1 = _create_exercise(page, "Voice Adv Ex1")
ex2 = _create_exercise(page, "Voice Adv Ex2")
sess_id = _create_session(page, "Voice Adv Session", [
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
{"item_id": ex2, "item_type": "exercise", "repetitions": 5},
])
_navigate_to_execute(page, app_url, sess_id)
page.click('[data-testid="execute-done-btn"]')
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("Voice Adv Ex2")
spoken = _spoken(page)
assert any(s["text"] == "Voice Adv Ex2" for s in spoken), f"Unexpected spoken texts: {spoken}"
def test_emom_speaks_next_at_10s(page: Page, app_url: str):
"""EMOM speaks 'Next: X' 10 s before a slot ends."""
_setup(page, app_url, install_clock=True)
_clean_all(page)
ex1 = _create_exercise(page, "Voice EMOM Ex1")
ex2 = _create_exercise(page, "Voice EMOM Ex2")
combo_id = _create_combo(page, "Voice EMOM", "EMOM", [ex1, ex2])
sess_id = _create_session(page, "Voice EMOM Session", [
{"item_id": combo_id, "item_type": "combo", "repetitions": 2},
])
_navigate_to_execute(page, app_url, sess_id)
page.clock.run_for(50_000) # 60s slot - 10s = 50s elapsed
page.wait_for_timeout(300)
spoken = _spoken(page)
assert any(s["text"] == "Next: Voice EMOM Ex2" for s in spoken), \
f"Expected pre-announcement, got: {spoken}"
def test_tabata_speaks_next_on_rest(page: Page, app_url: str):
"""TABATA speaks 'Next: X' when the rest phase opens."""
_setup(page, app_url, install_clock=True)
_clean_all(page)
ex1 = _create_exercise(page, "Voice Tab Ex1")
ex2 = _create_exercise(page, "Voice Tab Ex2")
combo_id = _create_combo(page, "Voice Tab", "TABATA", [ex1, ex2],
timer_config={"workTime": 10, "restTime": 10})
sess_id = _create_session(page, "Voice Tab Session", [
{"item_id": combo_id, "item_type": "combo", "repetitions": 1},
])
_navigate_to_execute(page, app_url, sess_id)
page.clock.run_for(10_000) # work phase ends, rest phase opens
page.wait_for_timeout(300)
spoken = _spoken(page)
assert any(s["text"] == "Next: Voice Tab Ex2" for s in spoken), \
f"Expected pre-announcement, got: {spoken}"
def test_no_duplicate_announcement_after_preannounce(page: Page, app_url: str):
"""The step-change announcement is skipped right after a pre-announcement."""
_setup(page, app_url, install_clock=True)
_clean_all(page)
ex1 = _create_exercise(page, "Voice Dup Ex1")
ex2 = _create_exercise(page, "Voice Dup Ex2")
combo_id = _create_combo(page, "Voice Dup", "EMOM", [ex1, ex2])
sess_id = _create_session(page, "Voice Dup Session", [
{"item_id": combo_id, "item_type": "combo", "repetitions": 2},
])
_navigate_to_execute(page, app_url, sess_id)
page.clock.run_for(60_000) # full first slot: pre-announce at 50s, expire+advance at 60s
page.wait_for_timeout(300)
spoken = _spoken(page)
ex2_mentions = [s["text"] for s in spoken if "Voice Dup Ex2" in s["text"]]
assert ex2_mentions == ["Next: Voice Dup Ex2"], \
f"Expected only the pre-announcement, no duplicate step-change announcement: {spoken}"
def test_utterance_lang_follows_app_language(page: Page, app_url: str):
"""Utterance `lang` matches the current i18next language."""
_setup(page, app_url)
_clean_all(page)
page.evaluate("""async () => {
await window.__repos.settings.set('language', 'fr')
}""")
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
ex1 = _create_exercise(page, "Voice Lang Ex1")
sess_id = _create_session(page, "Voice Lang Session", [
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
])
_navigate_to_execute(page, app_url, sess_id)
spoken = _spoken(page)
match = next((s for s in spoken if s["text"] == "Voice Lang Ex1"), None)
assert match is not None, f"Expected title spoken: {spoken}"
assert match["lang"] == "fr-FR", f"Expected fr-FR, got {match['lang']}"
# Reset language back to English for other tests
page.evaluate("""async () => {
await window.__repos.settings.set('language', 'en')
}""")
def test_utterance_volume_follows_audio_volume(page: Page, app_url: str):
"""Utterance `volume` matches the `audio_volume` setting."""
_setup(page, app_url)
_clean_all(page)
page.evaluate("""async () => {
await window.__repos.settings.set('audio_volume', '30')
}""")
ex1 = _create_exercise(page, "Voice Vol Ex1")
sess_id = _create_session(page, "Voice Vol Session", [
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
])
_navigate_to_execute(page, app_url, sess_id)
spoken = _spoken(page)
match = next((s for s in spoken if s["text"] == "Voice Vol Ex1"), None)
assert match is not None, f"Expected title spoken: {spoken}"
assert match["volume"] == pytest.approx(0.3), f"Expected 0.3, got {match['volume']}"
page.evaluate("""async () => {
await window.__repos.settings.set('audio_volume', '50')
}""")
def test_voice_disabled_nothing_spoken(page: Page, app_url: str):
"""Unchecking the setting silences announcements and persists across reload."""
page.on("dialog", lambda dialog: dialog.accept())
_setup(page, app_url)
_clean_all(page)
page.goto(f"{app_url}#/settings")
page.wait_for_selector('[data-testid="settings-voice-enabled"]')
checkbox = page.locator('[data-testid="settings-voice-enabled"]')
expect(checkbox).to_be_checked()
checkbox.uncheck()
expect(checkbox).not_to_be_checked()
page.reload()
page.wait_for_selector('[data-testid="settings-voice-enabled"]')
expect(page.locator('[data-testid="settings-voice-enabled"]')).not_to_be_checked()
ex1 = _create_exercise(page, "Voice Off Ex1")
sess_id = _create_session(page, "Voice Off Session", [
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
])
_navigate_to_execute(page, app_url, sess_id)
spoken = _spoken(page)
assert spoken == [], f"Expected no announcements, got: {spoken}"
# Re-enable for other tests
page.goto(f"{app_url}#/settings")
page.wait_for_selector('[data-testid="settings-voice-enabled"]')
page.locator('[data-testid="settings-voice-enabled"]').check()
def test_works_with_speech_synthesis_absent(page: Page, app_url: str):
"""Execution works normally when `speechSynthesis` does not exist (red path)."""
_setup(page, app_url, mock_speech=False)
_clean_all(page)
errors = []
page.on("pageerror", lambda exc: errors.append(str(exc)))
ex_id = _create_exercise(page, "Voice Absent Ex")
sess_id = _create_session(page, "Voice Absent Session", [
{"item_id": ex_id, "item_type": "exercise", "repetitions": 5},
])
_navigate_to_execute(page, app_url, sess_id)
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text(
"Voice Absent Ex"
)
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,
)
logs = page.evaluate("window.__repos.executionLogs.getAll()")
assert len(logs) == 1
assert logs[0]["status"] == "completed"
assert errors == [], f"Unexpected page errors: {errors}"