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