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