Spaces:
Running on Zero
Running on Zero
Download tests/test_transport_seam.py from WolfDavid/japanese-learning-avatar: direct link, hf CLI and curl.
- Browser
- Download file 20.2 kB
-
https://huggingface.co/spaces/WolfDavid/japanese-learning-avatar/resolve/706670a3ece0683b9770f0b7db36e4daa955f91e/tests/test_transport_seam.py
- Command line
-
hf download hf://spaces/WolfDavid/japanese-learning-avatar@706670a3ece0683b9770f0b7db36e4daa955f91e/tests/test_transport_seam.py
-
curl -L -o test_transport_seam.py https://huggingface.co/spaces/WolfDavid/japanese-learning-avatar/resolve/706670a3ece0683b9770f0b7db36e4daa955f91e/tests/test_transport_seam.py
20.2 kB
| """The transport seam. If the gr.HTML spike fails, only the stagePort changes. | |
| These tests exist so that stays true as the phase progresses. Plans 01-07 and 01-08 | |
| add turn behaviour; these tests fail if it lands in a transport file instead of in | |
| the shared turn-loop module. | |
| """ | |
| import re | |
| from pathlib import Path | |
| import pytest | |
| # Resolved from this file rather than the process cwd: the quick loop is documented as | |
| # running from the repo root, but a relative Path() would make that a silent | |
| # precondition of every assertion below. | |
| AVATAR = Path(__file__).resolve().parent.parent / "avatar" | |
| CORE = ["vrm-stage.js", "lipsync.js", "audio-queue.js"] | |
| SHARED = ["facade.js", "turn-loop.js"] | |
| TRANSPORTS = ["avatar.js", "avatar-iframe.js"] | |
| GRADIO_TOKENS = ["gradio", "gradio_api", "server.", "trigger("] | |
| AMPLITUDE_TOKENS = ["AnalyserNode", "getByteFrequencyData", "getFloatTimeDomainData"] | |
| TURN_SURFACE = ["startListening", "stopListening", "dispatchTurn", "requestSlower"] | |
| # Plan 01-07. Mic capture and ASR run in the PARENT document under both transports - | |
| # only rendering and audio playback live inside the iframe - so these modules must hang | |
| # off the shared turn loop, never off a transport. | |
| AUDIO_IN = ["mic.js", "asr.js"] | |
| # Wave 5. The host glue binds the page's controls to window.Avatar. It is loaded by the | |
| # shared boot template in avatar_component.py, so both transports get the same controls; | |
| # it must import no avatar module, be imported by none, and implement no turn behaviour. | |
| HOST = "host.js" | |
| COMPONENT = AVATAR.parent / "src" / "japanese_avatar" / "ui" / "avatar_component.py" | |
| def src(name: str) -> str: | |
| return (AVATAR / name).read_text(encoding="utf-8") | |
| def avatar_surface() -> list[str]: | |
| """Parse AVATAR_SURFACE out of facade.js. Never duplicate the list in the test.""" | |
| m = re.search(r"AVATAR_SURFACE\s*=\s*\[(.*?)\]", src("facade.js"), re.S) | |
| assert m, "AVATAR_SURFACE not found in avatar/facade.js" | |
| return re.findall(r"'([a-zA-Z]+)'", m.group(1)) | |
| def test_stage_core_is_gradio_free(name): | |
| lowered = src(name).lower() | |
| for tok in GRADIO_TOKENS: | |
| assert tok.lower() not in lowered, ( | |
| f"{name} references {tok!r}; the stage core must stay transport-agnostic" | |
| ) | |
| def test_no_amplitude_lipsync(name): | |
| for tok in AMPLITUDE_TOKENS: | |
| assert tok not in src(name), f"{name} uses {tok!r}; AVTR-02 disqualifies amplitude lip-sync" | |
| def test_shared_modules_are_gradio_free(name): | |
| lowered = src(name).lower() | |
| for tok in GRADIO_TOKENS: | |
| assert tok.lower() not in lowered, f"{name} must not know about Gradio" | |
| def test_lipsync_uses_audiocontext_clock(): | |
| s = src("lipsync.js") | |
| assert "currentTime" in s | |
| assert "setTimeout" not in s | |
| assert "requestAnimationFrame" not in s # the stage owns the loop; the player is a tick fn | |
| def test_module_urls_are_deps_pinned(): | |
| """The three runtime modules are the VENDORED, version-pinned builds (plan 01-10). | |
| Until 01-10 this asserted the esm.sh ``?deps=three@0.185.1`` URLs; the pin now lives | |
| in scripts/vendor_modules.py and the stage imports the files it wrote. Every one of | |
| the three must resolve relative to the module, so that the app | |
| (/gradio_api/file=avatar/...) and the standalone stage.html share one rule. | |
| """ | |
| s = src("vrm-stage.js") | |
| for name in ("three.mjs", "GLTFLoader.mjs", "three-vrm.mjs"): | |
| assert f"resolveModuleUrl('./vendor/{name}')" in s, f"{name} is not vendored-resolved" | |
| assert "esm.sh/" not in s, "a CDN URL is back in the render path" | |
| assert "import.meta.url" in s | |
| assert "importmap" not in s | |
| def test_only_facade_assigns_window_avatar(): | |
| """One facade builder. Two transports cannot drift if only one of them writes the object.""" | |
| for p in AVATAR.glob("*.js"): | |
| assigns = re.search(r"window\.Avatar\s*=", p.read_text(encoding="utf-8")) | |
| if p.name == "facade.js": | |
| assert assigns, "facade.js must be the one that installs window.Avatar" | |
| else: | |
| assert not assigns, f"{p.name} assigns window.Avatar; only facade.js may" | |
| def test_transports_only_do_plumbing(name): | |
| """A transport builds a stagePort and delegates. It never implements turn behaviour.""" | |
| s = src(name) | |
| assert "installFacade" in s and "createTurnLoop" in s, ( | |
| f"{name} must use the shared facade/turn loop" | |
| ) | |
| for member in TURN_SURFACE: | |
| assert f"function {member}" not in s and f"{member}(text" not in s, ( | |
| f"{name} implements {member}; turn behaviour belongs in avatar/turn-loop.js " | |
| "or the iframe fallback silently loses it" | |
| ) | |
| def test_surface_member_is_declared_once(member): | |
| """Every promised method must resolve in facade.js's surface list.""" | |
| assert member in avatar_surface() | |
| def test_turn_surface_lives_in_the_shared_module(member): | |
| assert member in src("turn-loop.js"), ( | |
| f"{member} must be defined in avatar/turn-loop.js so BOTH transports get it" | |
| ) | |
| def test_debug_contract_keys_present(key): | |
| joined = src("facade.js") + src("turn-loop.js") + src("vrm-stage.js") | |
| assert key in joined, f"__debug.{key} is required by 01-VALIDATION.md Observable Signals" | |
| def test_audio_input_modules_are_gradio_free(name): | |
| lowered = src(name).lower() | |
| for tok in GRADIO_TOKENS: | |
| assert tok.lower() not in lowered, ( | |
| f"{name} references {tok!r}; mic capture and ASR must stay host-agnostic" | |
| ) | |
| def test_audio_input_modules_hang_off_the_turn_loop(name): | |
| """Imported by turn-loop.js, by neither transport. | |
| If a transport imported these directly, the other transport would silently lose | |
| push-to-talk - the exact failure mode the shared turn loop exists to prevent. | |
| """ | |
| assert f"./{name}" in src("turn-loop.js"), ( | |
| f"avatar/turn-loop.js must import {name} so BOTH transports get it at once" | |
| ) | |
| for transport in TRANSPORTS: | |
| assert name not in src(transport), ( | |
| f"{transport} imports {name}; mic/ASR wiring belongs in avatar/turn-loop.js" | |
| ) | |
| def test_audio_input_modules_do_not_do_amplitude_lipsync(name): | |
| """RMS in mic.js gates ASR. It must never become a lip-sync source (AVTR-02).""" | |
| for tok in AMPLITUDE_TOKENS: | |
| assert tok not in src(name), f"{name} uses {tok!r}; AVTR-02 disqualifies amplitude lip-sync" | |
| def test_blocklist_does_not_swallow_ordinary_japanese(): | |
| """The bare polite form is a thing a learner says out loud. | |
| Blocking it would be worse than the hallucination it prevents, and this guard exists | |
| because that entry is exactly what a later "helpful" edit would add. | |
| """ | |
| entries = re.findall(r"^\s*'([^']+)',\s*$", src("mic.js"), re.M) | |
| assert "ご視聴ありがとうございました" in entries, ( | |
| "the subtitle-boilerplate blocklist is missing its canonical entry" | |
| ) | |
| assert "ありがとうございました" not in entries, ( | |
| "the bare polite form is blocklisted; it is ordinary Japanese, not a hallucination" | |
| ) | |
| def test_gate_thresholds_match_the_measured_fixtures(): | |
| """The three constants the gate lives or dies by, pinned to docs/ASR-TIERS.md. | |
| Measured: cafe_noise_30s.wav modulates 1.957, speech_ja.wav modulates 10.716. A | |
| threshold above ~10 would reject real speech; below ~2.0 would pass the noise fixture. | |
| """ | |
| s = src("mic.js") | |
| assert "MIN_DURATION_MS: 300" in s | |
| assert "MIN_RMS: 0.01" in s | |
| assert "MIN_MODULATION: 2.5" in s | |
| assert "BLOCKLIST_MAX_MS: 1500" in s | |
| assert "REARM_TAIL_MS = 200" in s | |
| def test_no_remote_asr_path_exists(): | |
| """Tier D would burn the exact visitor GPU quota SC-4 tests the loop's survival | |
| without, so no hosted transcription path may exist anywhere in avatar/.""" | |
| for name in ["asr.js", "mic.js", "turn-loop.js"]: | |
| lowered = src(name).lower() | |
| for tok in ["zerogpu", "spaces.gpu", "/api/asr", "@spaces"]: | |
| assert tok not in lowered, ( | |
| f"{name} references {tok!r}; browser ASR must cost zero GPU quota" | |
| ) | |
| def test_asr_module_url_is_pinned(): | |
| s = src("asr.js") | |
| assert s.count("https://esm.sh/@huggingface/transformers@4.2.0") == 1, ( | |
| "exactly one pinned runtime URL, same discipline as vrm-stage.js" | |
| ) | |
| assert "importmap" not in s | |
| def test_asr_webgpu_fallback_is_real_not_aspirational(): | |
| """The fallback must probe for an ADAPTER, not merely for navigator.gpu. | |
| Measured on this stack: when navigator.gpu exists but requestAdapter() resolves to | |
| null, calling pipeline(..., {device:'webgpu'}) throws AND poisons the ONNX Runtime | |
| backend registry for the whole page, so the subsequent WASM re-instantiation fails | |
| with the same WebGPU error. Catch-and-retry alone is therefore not a working | |
| fallback; the doomed call must never be made. Deleting this assertion re-introduces | |
| a bug that only shows up on the machines the fallback exists for. | |
| """ | |
| s = src("asr.js") | |
| assert "navigator.gpu" in s | |
| assert "webgpu" in s | |
| assert "requestAdapter" in s, ( | |
| "avatar/asr.js checks navigator.gpu but never requests an adapter; that is the " | |
| "exact naive check that ships a broken app to every machine without a GPU" | |
| ) | |
| assert re.search(r"catch\s*\(", s), "no catch around the WebGPU init path" | |
| assert s.count("language: 'ja'") == 1 | |
| def test_push_to_talk_debug_keys_present(key): | |
| assert key in src("turn-loop.js"), ( | |
| f"__debug.{key} is contributed by avatar/turn-loop.js, so both transports report it" | |
| ) | |
| def test_push_to_talk_is_implemented_not_deferred(member): | |
| """The two methods this plan owns must no longer be notWiredYet stubs.""" | |
| s = src("turn-loop.js") | |
| assert f"notWiredYet('{member}'" not in s, ( | |
| f"{member} is still a deferred stub in avatar/turn-loop.js" | |
| ) | |
| assert f"async {member}(" in s, f"{member} must be implemented in avatar/turn-loop.js" | |
| def test_transports_gained_push_to_talk_without_gaining_code(name): | |
| """The whole point of the seam: the iframe fallback got this feature for free.""" | |
| s = src(name) | |
| for token in ["startListening", "stopListening", "mic.", "asr.", "createMic", "createAsr"]: | |
| assert token not in s, ( | |
| f"{name} mentions {token!r}; push-to-talk must live only in avatar/turn-loop.js" | |
| ) | |
| def test_turn_dispatch_is_implemented_not_deferred(member): | |
| """The wave-5 methods are real, and nothing in the file is a deferred stub any more.""" | |
| s = src("turn-loop.js") | |
| assert "notWiredYet" not in s, "a deferred stub survives in avatar/turn-loop.js" | |
| assert f"{member}(" in s, f"{member} must be implemented in avatar/turn-loop.js" | |
| def test_thinking_engages_before_the_first_await(): | |
| """VOIC-05's cheapest latency mitigation: the pose switches at dispatch, not at response. | |
| Verified by line number inside dispatchTurn: setThinking(true) precedes the first | |
| await, which is the server call. | |
| """ | |
| lines = src("turn-loop.js").splitlines() | |
| start = next(i for i, line in enumerate(lines) if "async function dispatchTurn(" in line) | |
| body = lines[start:] | |
| think_at = next(i for i, line in enumerate(body) if "setThinking(true)" in line) | |
| await_at = next(i for i, line in enumerate(body) if "await " in line) | |
| assert think_at < await_at, ( | |
| f"setThinking(true) is on dispatchTurn line {think_at} but the first await is on " | |
| f"line {await_at}; the thinking pose must engage before the server round trip" | |
| ) | |
| assert "await bridge." in body[await_at], body[await_at] | |
| def test_replay_is_networkless_by_construction(): | |
| """replay() re-emits the cached buffer. No fetch, no dynamic import, anywhere in the loop.""" | |
| s = src("turn-loop.js") | |
| assert "fetch(" not in s | |
| assert "import(" not in s | |
| assert "XMLHttpRequest" not in s | |
| def test_turn_loop_publishes_the_latency_numbers(): | |
| s = src("turn-loop.js") | |
| for key in ("lastTurnMs", "lastStageTimings", "lastSubtitle", "replayCount", "turnCount"): | |
| assert f"{key}:" in s, f"__debug.{key} is not initialised in avatar/turn-loop.js" | |
| for mark in ("turn:dispatch", "turn:response", "turn:speech-start"): | |
| assert f"performance.mark('{mark}')" in s, f"performance.mark({mark!r}) missing" | |
| assert "0.75" in s, "the slower speed must be the VOICEVOX speedScale 0.75" | |
| assert "greeting" in s | |
| def test_host_glue_is_neither_a_transport_nor_the_turn_loop(): | |
| """host.js is page glue: loaded from the shared boot template, importing nothing here.""" | |
| host = src(HOST) | |
| assert not re.search(r"^\s*import\s", host, re.M), ( | |
| "host.js must not import any avatar module; it is handed the facade" | |
| ) | |
| for member in TURN_SURFACE: | |
| assert f"function {member}" not in host and f"async {member}(" not in host, ( | |
| f"host.js implements {member}; turn behaviour belongs in avatar/turn-loop.js" | |
| ) | |
| for member in ["dispatchTurn", "requestSlower", "replay", "startListening", "stopListening"]: | |
| assert f".{member}(" in host, f"host.js never calls Avatar.{member}()" | |
| for name in [*TRANSPORTS, *SHARED, *CORE, *AUDIO_IN]: | |
| assert HOST not in src(name), f"{name} references {HOST}; only the boot template may" | |
| component = COMPONENT.read_text(encoding="utf-8") | |
| assert component.count(f"import('/gradio_api/file=avatar/{HOST}')") == 1, ( | |
| "the shared boot template must load host.js exactly once, for both transports" | |
| ) | |
| assert "bindHost(avatar, document)" in component | |
| # ------------------------------------------------------------------ plan 01-11: audio unlock | |
| # | |
| # The owner heard nothing on a phone. The only resume() on the turn path lived in | |
| # playBuffer, AFTER the server round trip and therefore outside the tap's user activation; | |
| # a gesture-gated browser leaves the context suspended, and every test had been masking it | |
| # with --autoplay-policy=no-user-gesture-required. These guards pin the fix statically; the | |
| # strict-policy browser tests prove it behaviourally at three layers. | |
| # The four turn-loop methods a gesture reaches, by the signature line each starts on. | |
| GESTURE_ENTRY_POINTS = [ | |
| "async function dispatchTurn(", | |
| "async replay()", | |
| "async requestSlower()", | |
| "async startListening()", | |
| ] | |
| UNLOCK_CALL = "stagePort.unlockAudio()" | |
| def _function_body(source: str, signature: str) -> list[str]: | |
| """The lines of the function whose signature line contains ``signature``, from the | |
| signature to its closing brace, found by brace counting from the signature's ``{``.""" | |
| lines = source.splitlines() | |
| start = next(i for i, line in enumerate(lines) if signature in line) | |
| depth = 0 | |
| opened = False | |
| for i in range(start, len(lines)): | |
| depth += lines[i].count("{") - lines[i].count("}") | |
| if "{" in lines[i]: | |
| opened = True | |
| if opened and depth == 0: | |
| return lines[start : i + 1] | |
| raise AssertionError(f"unbalanced braces after {signature!r}") | |
| def _statements(body: list[str]) -> list[str]: | |
| """The body's lines after the signature, minus blank lines and comment-only lines.""" | |
| out = [] | |
| for line in body[1:]: | |
| stripped = line.strip() | |
| if not stripped or stripped.startswith(("//", "/*", "*")): | |
| continue | |
| out.append(stripped) | |
| return out | |
| def test_stage_port_unlocks_audio_under_both_transports(name): | |
| """unlockAudio is a stagePort method on BOTH transports, and the iframe stage answers | |
| the message the iframe transport sends for it.""" | |
| assert "unlockAudio" in src(name), f"{name} builds a stagePort with no unlockAudio" | |
| assert "unlockAudio" in avatar_surface(), "unlockAudio is not in AVATAR_SURFACE" | |
| assert "'avatar:unlockAudio'" in src("avatar-iframe.js") | |
| assert "'avatar:unlockAudio'" in src("stage.html"), ( | |
| "avatar/stage.html has no handler for the iframe transport's unlock message" | |
| ) | |
| def test_inline_unlock_is_synchronous(): | |
| """The inline transport must CALL resume() synchronously, never await it: the call has | |
| to happen in the gesture's stack, and a blocked resume() promise never settles.""" | |
| body = _function_body(src("avatar.js"), "unlockAudio()") | |
| joined = "\n".join(body) | |
| assert "audioCtx.resume()" in joined | |
| assert "await" not in joined, "unlockAudio must not await; the CALL is the unlock" | |
| assert "return audioCtx.state" in joined | |
| def test_turn_loop_unlocks_audio_before_its_first_await(signature): | |
| """Line-order proof, in the spirit of test_thinking_engages_before_the_first_await: | |
| the unlock is the FIRST statement of every gesture-reachable entry point, so it runs | |
| inside the gesture's call stack - anything after an await does not.""" | |
| body = _function_body(src("turn-loop.js"), signature) | |
| statements = _statements(body) | |
| assert statements and statements[0].startswith(UNLOCK_CALL), ( | |
| f"{signature} does not start with {UNLOCK_CALL}; first statement is {statements[:1]}" | |
| ) | |
| awaits = [i for i, line in enumerate(statements) if "await " in line] | |
| if awaits: | |
| assert awaits[0] > 0, f"{signature} awaits before it unlocks" | |
| def test_turn_loop_unlock_count(): | |
| assert src("turn-loop.js").count(UNLOCK_CALL) >= 4 | |
| def test_host_unlocks_audio_in_every_gesture_handler(): | |
| """Belt and braces: the host unlocks first thing in all six gesture handlers, so a | |
| gesture that never reaches the loop (Send with an empty box) still unlocks.""" | |
| host = src(HOST) | |
| assert host.count("avatar.unlockAudio()") >= 6, ( | |
| f"host.js calls avatar.unlockAudio() {host.count('avatar.unlockAudio()')} time(s); " | |
| "every gesture handler (send, Enter, hello, replay, slower, ptt) must call it first" | |
| ) | |
| for handler in ("'click'", "'keydown'", "'pointerdown'"): | |
| assert handler in host | |
| def test_audio_queue_refuses_to_fake_speech_on_a_stopped_clock(): | |
| """playBuffer must check the context is running BEFORE it emits speech-start, and must | |
| report the blocked state as an 'error' rather than scheduling against a dead clock.""" | |
| s = src("audio-queue.js") | |
| assert "state !== 'running'" in s | |
| assert s.index("state !== 'running'") < s.index("emit('speech-start'"), ( | |
| "audio-queue.js emits speech-start before it checks audioCtx.state" | |
| ) | |
| assert "where: 'audio'" in s | |
| # A blocked resume() promise never settles in Chromium; the wait must be bounded. | |
| assert "Promise.race" in s and "RESUME_GRACE_MS" in s | |
| def test_audio_state_is_published(name): | |
| """audioState is a published field on getDebug() from both transports and the | |
| standalone harness, so the strict-policy tests assert a number, not a silence.""" | |
| assert "audioState" in src(name), f"{name} does not publish audioState" | |