fix(audio): Loading Universe splash; single-element in-gesture audio (Safari); first-video-on couples audio
Operator follow-ups: - Remove the tap-to-start wall; show a 'Loading Universe…' splash until all media preloaded (main() awaits preloadAllMedia + a progress bar), then reveal the app. - Both Video + Audio toggles default OFF (black + silent at start). - FIX audio still silent on real Safari: the A/B-crossfade-after-gesture didn't unlock on Safari (priming srcless elements doesn't unlock them). Use ONE <audio> element played SYNCHRONOUSLY inside the toggle's click — the reliable Safari unlock; later altitude swaps reuse the unlocked element. - First time Video turns on, also turn Audio on (one flip = full experience), set+ played in the same gesture; Audio-on-first stays audio-only. - Soundtrack follows Altitude via the single element (brief fade swap). - Verified in headless Chromium AND WebKit: coupling works, soundtrack plays, video-off blanks, zero JS errors. 290 tests + 5 Playwright E2E pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+48
-35
@@ -1,7 +1,9 @@
|
||||
"""Playwright E2E for the audio layer (audio spec §9). Skips cleanly when
|
||||
Playwright or its browser binary is absent (the headless dev box) — the unit +
|
||||
contract tiers cover the logic; this asserts the rendered <audio> element src and
|
||||
playback survives a Visual fade. Runs at the PPE stage where a browser exists.
|
||||
"""Playwright E2E for the audio + Video/Audio toggles (audio spec §9). Skips
|
||||
cleanly when Playwright or its browser binary is absent.
|
||||
|
||||
NOTE: headless engines relax BOTH autoplay and GPU compositing, so these assert
|
||||
the WIRING (toggle→play, the first-video-on→audio coupling, video-off blanking) —
|
||||
real Safari/iOS autoplay and real-GPU compositing still need a device by-ear/eye check.
|
||||
|
||||
Install: pip install -e '.[e2e]' && python -m playwright install chromium
|
||||
"""
|
||||
@@ -12,7 +14,6 @@ from contextlib import closing
|
||||
|
||||
import pytest
|
||||
|
||||
# Skip the whole module unless Playwright is importable.
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
|
||||
|
||||
@@ -50,50 +51,62 @@ def page(app_url):
|
||||
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
# Headless Chromium blocks media playback without a real audio device;
|
||||
# this flag lets play() actually start so `paused` is meaningful (the
|
||||
# spec §9 asserts playback via element src/paused, not a waveform).
|
||||
# headless blocks media playback without a device; this flag lets play()
|
||||
# actually start so `paused` is meaningful (spec §9 asserts src/paused)
|
||||
browser = p.chromium.launch(args=["--autoplay-policy=no-user-gesture-required"])
|
||||
except Exception as exc: # no browser binary installed
|
||||
pytest.skip(f"chromium not available: {exc}")
|
||||
pg = browser.new_page()
|
||||
pg.goto(app_url)
|
||||
pg.click("#start-gesture") # unlock autoplay
|
||||
# wait out the "Loading Universe…" splash (it overlays + blocks clicks)
|
||||
pg.wait_for_selector("#loading", state="detached", timeout=60000)
|
||||
yield pg
|
||||
browser.close()
|
||||
|
||||
|
||||
def _toggle(page, which):
|
||||
"""Click the visible toggle track for #visual / #audio (the input is hidden)."""
|
||||
page.click(f"label[for='{which}'] .dev-switch-track")
|
||||
|
||||
|
||||
def test_app_starts_blanked_and_silent(page):
|
||||
# both toggles default off → black screen, no audio (no tap-to-start wall)
|
||||
assert page.is_checked("#visual") is False
|
||||
assert page.is_checked("#audio") is False
|
||||
page.wait_for_selector("#black:not(.hidden)")
|
||||
assert page.evaluate("document.getElementById('aud').paused") is True
|
||||
|
||||
|
||||
def test_audio_toggle_plays_the_current_scale_soundtrack(page):
|
||||
# Audio on (toggle) → the current altitude's soundtrack (cosmos at index 0) plays
|
||||
page.click("label[for='audio'] .dev-switch-track")
|
||||
_toggle(page, "audio")
|
||||
page.wait_for_function(
|
||||
"['audA','audB'].some(id => {"
|
||||
" const a = document.getElementById(id);"
|
||||
" return /\\/media\\/audio\\/.+\\.mp3/.test(a.src) && !a.paused;"
|
||||
"})"
|
||||
"(() => { const a = document.getElementById('aud');"
|
||||
" return /\\/media\\/audio\\/.+\\.mp3/.test(a.src) && !a.paused; })()"
|
||||
)
|
||||
|
||||
|
||||
def test_video_toggle_off_blanks_the_screen_and_hides_layers(page):
|
||||
page.click("label[for='visual'] .dev-switch-track")
|
||||
page.wait_for_selector("#black:not(.hidden)") # black cover shown
|
||||
# the video layers themselves are hidden too (GPU-composite-proof blanking);
|
||||
# wait for the opacity transition to settle to 0
|
||||
def test_first_video_on_also_enables_audio(page):
|
||||
_toggle(page, "visual") # first video-on couples audio
|
||||
page.wait_for_function("document.getElementById('audio').checked === true")
|
||||
page.wait_for_function(
|
||||
"(() => { const a = document.getElementById('aud'); return !!a.src && !a.paused; })()"
|
||||
)
|
||||
page.wait_for_selector("#black.hidden", state="attached") # video showing (black hidden=display:none)
|
||||
|
||||
|
||||
def test_audio_before_video_stays_audio_only(page):
|
||||
_toggle(page, "audio") # audio on first
|
||||
page.wait_for_function("!document.getElementById('aud').paused")
|
||||
assert page.is_checked("#visual") is False # video untouched
|
||||
page.wait_for_selector("#black:not(.hidden)") # still blanked
|
||||
|
||||
|
||||
def test_video_off_blanks_after_being_on(page):
|
||||
_toggle(page, "visual") # on
|
||||
page.wait_for_selector("#black.hidden", state="attached")
|
||||
_toggle(page, "visual") # off
|
||||
page.wait_for_selector("#black:not(.hidden)")
|
||||
# the GPU-composited video layers are hidden too (settle the opacity transition)
|
||||
page.wait_for_function(
|
||||
"['vid','paint'].every(id => getComputedStyle(document.getElementById(id)).opacity === '0')"
|
||||
)
|
||||
|
||||
|
||||
def test_audio_survives_video_off(page):
|
||||
page.click("label[for='audio'] .dev-switch-track")
|
||||
page.wait_for_function(
|
||||
"['audA','audB'].some(id => !document.getElementById(id).paused "
|
||||
"&& document.getElementById(id).src)"
|
||||
)
|
||||
page.click("label[for='visual'] .dev-switch-track")
|
||||
page.wait_for_selector("#black:not(.hidden)")
|
||||
playing = page.evaluate(
|
||||
"['audA','audB'].some(id => "
|
||||
"{const a = document.getElementById(id); return !a.paused && !!a.src;})"
|
||||
)
|
||||
assert playing is True # audio keeps playing with video off
|
||||
|
||||
Reference in New Issue
Block a user