WolfDavid commited on
Commit
d6ea280
·
1 Parent(s): f822421

test(01-03): enforce the transport seam statically and in a real browser

Browse files

- tests/test_transport_seam.py: 36 static cases; no stage-core or shared module may
name the host framework, no transport may assign window.Avatar or implement turn
behaviour, and AVATAR_SURFACE is parsed from facade.js rather than duplicated
- host-token checks are case-insensitive, so a capitalised mention cannot slip past
- tests/e2e/test_stage_standalone.py: 4 tests against avatar/stage.html on a static
server - one three.mjs instance, idle life, demo lip-sync, no duplicate-instance
console warning. Sampling runs in-page at frame rate: a 250 ms poll would miss a
120 ms blink on most runs and be flaky by construction
- tests/e2e/test_facade_parity.py: boots the real app twice, once per transport, and
compares the live method sets, debug keys and deferred-method errors
- stage.html: mirror the event stream onto window.__stageEvents so the standalone
harness can assert on speech-end with no driver on the other side
- avatar_component.py: js_on_load is compiled into a NON-async Function by gradio
6.22.0, so the documented top-level await is a SyntaxError and the component never
booted; wrap in an async IIFE with its own catch
- avatar_component.py: custom props are **kwargs on gr.HTML, not a props= dict; the
dict form made props.vrmUrl undefined and GLTFLoader threw

avatar/stage.html CHANGED
@@ -64,7 +64,16 @@
64
 
65
  const host = window.parent !== window ? window.parent : null;
66
  const post = (msg) => host && host.postMessage(msg, '*');
67
- const emit = (event, data) => post({ type: 'avatar:event', event, data });
 
 
 
 
 
 
 
 
 
68
 
69
  const canvas = document.getElementById('vrm-canvas');
70
  let audioCtx = null;
 
64
 
65
  const host = window.parent !== window ? window.parent : null;
66
  const post = (msg) => host && host.postMessage(msg, '*');
67
+
68
+ // Opened standalone there is no parent to post to, so the event stream would
69
+ // vanish. Mirroring it onto window.__stageEvents is the harness counterpart of
70
+ // window.__stageDebug: it lets a browser test assert on speech-start /
71
+ // speech-end / error without a driver on the other side of the boundary.
72
+ window.__stageEvents = [];
73
+ const emit = (event, data) => {
74
+ window.__stageEvents.push({ event, data, at: performance.now() });
75
+ post({ type: 'avatar:event', event, data });
76
+ };
77
 
78
  const canvas = document.getElementById('vrm-canvas');
79
  let audioCtx = null;
src/japanese_avatar/ui/avatar_component.py CHANGED
@@ -15,17 +15,25 @@ VRM_URL = "/gradio_api/file=avatar/assets/tutor.vrm"
15
 
16
  # No `head=`: the modules load via dynamic import() inside js_on_load, so there is no
17
  # ordering constraint and no import map to be injected too late.
18
- _INLINE_JS = """
19
- const m = await import('/gradio_api/file=avatar/avatar.js');
20
- await m.boot(element, props, trigger, server);
21
- watch('value', () => m.onDirective(props.value));
 
 
 
 
 
 
 
 
 
 
 
22
  """
23
 
24
- _IFRAME_JS = """
25
- const m = await import('/gradio_api/file=avatar/avatar-iframe.js');
26
- await m.boot(element, props, trigger, server);
27
- watch('value', () => m.onDirective(props.value));
28
- """
29
 
30
 
31
  class VrmStage(gr.HTML):
@@ -45,7 +53,12 @@ class VrmStage(gr.HTML):
45
  ".vrm-stage iframe{width:100%;height:100%;border:0;display:block}"
46
  ),
47
  js_on_load=_IFRAME_JS if AVATAR_TRANSPORT == "iframe" else _INLINE_JS,
48
- props={"vrmUrl": VRM_URL, "transport": AVATAR_TRANSPORT},
 
 
 
 
 
49
  container=False,
50
  padding=False,
51
  min_height=480,
 
15
 
16
  # No `head=`: the modules load via dynamic import() inside js_on_load, so there is no
17
  # ordering constraint and no import map to be injected too late.
18
+ #
19
+ # The async IIFE is NOT decoration. Gradio 6.22.0 compiles this string with
20
+ # `Function('element','trigger','props','server','upload','watch', js_on_load)` - a
21
+ # plain, non-async function - so a top-level `await` here is a SyntaxError and the
22
+ # component silently never boots. The published docs snippet uses top-level await and
23
+ # does not work on this version. Verified against gradio/templates/frontend/assets.
24
+ #
25
+ # The IIFE also escapes Gradio's own try/catch, so it carries its own .catch: a boot
26
+ # failure must reach the console, because that console line is plan 01-05's verdict.
27
+ _BOOT_JS = """
28
+ (async () => {{
29
+ const m = await import('/gradio_api/file=avatar/{module}');
30
+ await m.boot(element, props, trigger, server);
31
+ watch('value', () => m.onDirective(props.value));
32
+ }})().catch((err) => console.error('avatar boot failed:', err));
33
  """
34
 
35
+ _INLINE_JS = _BOOT_JS.format(module="avatar.js")
36
+ _IFRAME_JS = _BOOT_JS.format(module="avatar-iframe.js")
 
 
 
37
 
38
 
39
  class VrmStage(gr.HTML):
 
53
  ".vrm-stage iframe{width:100%;height:100%;border:0;display:block}"
54
  ),
55
  js_on_load=_IFRAME_JS if AVATAR_TRANSPORT == "iframe" else _INLINE_JS,
56
+ # Custom props are **kwargs on gr.HTML, NOT a props= dict. Passing
57
+ # props={"vrmUrl": ...} creates a single prop literally named "props"
58
+ # and js_on_load then reads props.vrmUrl as undefined - the VRM URL
59
+ # reaches GLTFLoader as undefined and the avatar never mounts.
60
+ vrmUrl=VRM_URL,
61
+ transport=AVATAR_TRANSPORT,
62
  container=False,
63
  padding=False,
64
  min_height=480,
tests/e2e/conftest.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Browser-suite plumbing: a static server for the standalone harness and a real
2
+ Gradio app subprocess per transport for the parity proof.
3
+
4
+ Both suites run entirely locally. They must be green BEFORE plan 01-05 deploys
5
+ anything, because that is what makes 01-05's verdict interpretable: if standalone
6
+ passes and deployed fails, the problem is the host, not three.js.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import http.server
13
+ import os
14
+ import socket
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ import urllib.error
20
+ import urllib.request
21
+ from pathlib import Path
22
+
23
+ import pytest
24
+
25
+ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
26
+ APP_BOOT_TIMEOUT_S = 180
27
+
28
+
29
+ def _free_port() -> int:
30
+ with socket.socket() as s:
31
+ s.bind(("127.0.0.1", 0))
32
+ return s.getsockname()[1]
33
+
34
+
35
+ @pytest.fixture(scope="session")
36
+ def browser_type_launch_args(browser_type_launch_args):
37
+ """Chromium's autoplay policy leaves an ungestured AudioContext suspended forever.
38
+
39
+ A suspended context never advances currentTime, so the viseme player would never
40
+ tick and 'speech-end' would never fire - the test would be measuring the autoplay
41
+ policy rather than the lip-sync. The flag is the standard way to opt out.
42
+ """
43
+ return {
44
+ **browser_type_launch_args,
45
+ "args": [
46
+ *browser_type_launch_args.get("args", []),
47
+ "--autoplay-policy=no-user-gesture-required",
48
+ ],
49
+ }
50
+
51
+
52
+ @pytest.fixture(scope="session")
53
+ def static_server() -> str:
54
+ """Serve the repo root so /avatar/stage.html and /avatar/assets/tutor.vrm resolve."""
55
+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(REPO_ROOT))
56
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", _free_port()), handler)
57
+ server.daemon_threads = True
58
+ threading.Thread(target=server.serve_forever, daemon=True).start()
59
+ try:
60
+ yield f"http://127.0.0.1:{server.server_port}"
61
+ finally:
62
+ server.shutdown()
63
+ server.server_close()
64
+
65
+
66
+ def _wait_for_http(url: str, proc: subprocess.Popen, timeout_s: int) -> None:
67
+ deadline = time.time() + timeout_s
68
+ while time.time() < deadline:
69
+ if proc.poll() is not None:
70
+ out = proc.stdout.read() if proc.stdout else ""
71
+ raise RuntimeError(f"app.py exited with {proc.returncode}:\n{out}")
72
+ try:
73
+ with urllib.request.urlopen(url, timeout=2) as response:
74
+ if response.status == 200:
75
+ return
76
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError):
77
+ time.sleep(0.5)
78
+ raise RuntimeError(f"app.py did not answer {url} within {timeout_s}s")
79
+
80
+
81
+ @pytest.fixture(scope="session")
82
+ def gradio_apps():
83
+ """Start app.py once per AVATAR_TRANSPORT value and return its base URL.
84
+
85
+ One env var is the entire difference between the two deployments, which is the
86
+ property the parity suite exists to prove.
87
+ """
88
+ started: dict[str, tuple[str, subprocess.Popen]] = {}
89
+
90
+ def start(transport: str) -> str:
91
+ if transport not in started:
92
+ port = _free_port()
93
+ env = {
94
+ **os.environ,
95
+ "AVATAR_TRANSPORT": transport,
96
+ "GRADIO_SERVER_NAME": "127.0.0.1",
97
+ "GRADIO_SERVER_PORT": str(port),
98
+ "GRADIO_ANALYTICS_ENABLED": "False",
99
+ }
100
+ proc = subprocess.Popen( # noqa: S603
101
+ [sys.executable, "app.py"],
102
+ cwd=str(REPO_ROOT),
103
+ env=env,
104
+ stdout=subprocess.PIPE,
105
+ stderr=subprocess.STDOUT,
106
+ text=True,
107
+ )
108
+ url = f"http://127.0.0.1:{port}/"
109
+ try:
110
+ _wait_for_http(url, proc, APP_BOOT_TIMEOUT_S)
111
+ except Exception:
112
+ proc.kill()
113
+ raise
114
+ started[transport] = (url, proc)
115
+ return started[transport][0]
116
+
117
+ try:
118
+ yield start
119
+ finally:
120
+ for _url, proc in started.values():
121
+ proc.terminate()
122
+ try:
123
+ proc.wait(timeout=15)
124
+ except subprocess.TimeoutExpired:
125
+ proc.kill()
tests/e2e/test_facade_parity.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mechanical proof that the two transports expose the same object.
2
+
3
+ The static tests in tests/test_transport_seam.py can only prove that no transport
4
+ *writes* window.Avatar. These boot the real Gradio app twice - once per
5
+ AVATAR_TRANSPORT value - and compare the LIVE objects, which is the only way to catch
6
+ a method that resolves in one transport and silently does not in the other.
7
+
8
+ Marked slow but NOT deployed: they run entirely locally and must be green before
9
+ plan 01-05 deploys anything.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import pytest
15
+
16
+ from tests.test_transport_seam import avatar_surface
17
+
18
+ pytestmark = pytest.mark.slow
19
+
20
+ TRANSPORTS = ("inline", "iframe")
21
+ AVATAR_READY = "() => !!window.Avatar && !!window.Avatar.__debug && window.Avatar.__debug.ready"
22
+ BOOT_TIMEOUT_MS = 120_000
23
+
24
+ # Deliberately double-quoted and built from data: the surface list must come from
25
+ # facade.js, never from a literal in this file.
26
+ DEFERRED_PLAN = "01-08"
27
+ DEFERRED_METHODS = ("dispatchTurn", "requestSlower")
28
+
29
+ CALL_DEFERRED = """
30
+ async (name) => {
31
+ try {
32
+ await window.Avatar[name]('x');
33
+ return { threw: false, message: '' };
34
+ } catch (err) {
35
+ return { threw: true, message: String((err && err.message) || err) };
36
+ }
37
+ }
38
+ """
39
+
40
+
41
+ @pytest.fixture(scope="session")
42
+ def live_avatars(browser, gradio_apps):
43
+ """Boot each transport once and capture everything the three tests compare."""
44
+ captured = {}
45
+ for transport in TRANSPORTS:
46
+ url = gradio_apps(transport)
47
+ page = browser.new_page()
48
+ try:
49
+ page.goto(url)
50
+ page.wait_for_function(AVATAR_READY, timeout=BOOT_TIMEOUT_MS)
51
+ captured[transport] = {
52
+ "surface": page.evaluate(
53
+ "() => Object.keys(window.Avatar).filter(k => k !== '__debug').sort()"
54
+ ),
55
+ "debug_keys": page.evaluate(
56
+ "async () => Object.keys(await window.Avatar.getDebug()).sort()"
57
+ ),
58
+ "reported_transport": page.evaluate("() => window.Avatar.__debug.transport"),
59
+ "deferred": {name: page.evaluate(CALL_DEFERRED, name) for name in DEFERRED_METHODS},
60
+ }
61
+ finally:
62
+ page.close()
63
+ return captured
64
+
65
+
66
+ def test_transports_expose_identical_surfaces(live_avatars):
67
+ inline = live_avatars["inline"]["surface"]
68
+ iframe = live_avatars["iframe"]["surface"]
69
+ expected = sorted(avatar_surface())
70
+
71
+ assert live_avatars["inline"]["reported_transport"] == "inline"
72
+ assert live_avatars["iframe"]["reported_transport"] == "iframe"
73
+ assert inline == iframe, (
74
+ "the transports have drifted; symmetric difference "
75
+ f"(inline ^ iframe) = {sorted(set(inline) ^ set(iframe))}"
76
+ )
77
+ assert inline == expected, (
78
+ "the live object does not match AVATAR_SURFACE parsed from avatar/facade.js; "
79
+ f"symmetric difference = {sorted(set(inline) ^ set(expected))}"
80
+ )
81
+
82
+
83
+ def test_transports_expose_identical_debug_keys(live_avatars):
84
+ inline = live_avatars["inline"]["debug_keys"]
85
+ iframe = live_avatars["iframe"]["debug_keys"]
86
+ assert inline == iframe, (
87
+ "__debug has drifted between transports; symmetric difference "
88
+ f"(inline ^ iframe) = {sorted(set(inline) ^ set(iframe))}"
89
+ )
90
+
91
+
92
+ def test_deferred_methods_fail_loudly_not_silently(live_avatars):
93
+ """The stubs are placeholders, not accidental no-ops.
94
+
95
+ Plan 01-08 deletes this test when it implements the methods for real.
96
+ """
97
+ for transport in TRANSPORTS:
98
+ for name, outcome in live_avatars[transport]["deferred"].items():
99
+ assert outcome["threw"], (
100
+ f"{transport}: Avatar.{name}() returned instead of throwing; a silent "
101
+ "no-op is exactly what the deferred stubs exist to prevent"
102
+ )
103
+ assert DEFERRED_PLAN in outcome["message"], (
104
+ f"{transport}: Avatar.{name}() threw {outcome['message']!r}, which does "
105
+ f"not name plan {DEFERRED_PLAN}"
106
+ )
tests/e2e/test_stage_standalone.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The stage core, proven in a real browser with no Python and no host framework.
2
+
3
+ avatar/stage.html is opened directly off a static server. If these four tests pass and
4
+ the deployed component later fails, the fault is provably in the host, not in three.js,
5
+ the VRM or the viseme player. That is the whole point of running them before 01-05.
6
+
7
+ Marked slow but NOT deployed: they need no Space and must be green before one exists.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pytest
13
+
14
+ pytestmark = pytest.mark.slow
15
+
16
+ # The VRM is 10.3 MiB and the module graph another ~1.4 MiB. This is a real download.
17
+ READY_TIMEOUT_MS = 30_000
18
+ STAGE_READY = "() => !!window.__stageDebug && window.__stageDebug.ready === true"
19
+
20
+ # Sampling happens INSIDE the page at animation-frame rate rather than by polling from
21
+ # Python. A blink is a 120 ms ramp on a 1.8-5.8 s schedule, so its peak occupies well
22
+ # under 2% of the wall clock: a 250 ms poll would miss it on most runs and the test
23
+ # would be flaky by construction. Same sampler, ~60x the rate, no flakiness.
24
+ IDLE_SAMPLER = """
25
+ async (ms) => {
26
+ const blink = [], breath = [];
27
+ const t0 = performance.now();
28
+ return await new Promise((resolve) => {
29
+ const step = () => {
30
+ const d = window.__stageDebug;
31
+ if (d) { blink.push(d.blinkValue); breath.push(d.breathValue); }
32
+ if (performance.now() - t0 >= ms) resolve({ blink, breath });
33
+ else requestAnimationFrame(step);
34
+ };
35
+ requestAnimationFrame(step);
36
+ });
37
+ }
38
+ """
39
+
40
+ # Peak-hold per viseme until the utterance ends, then the settled values afterwards.
41
+ VISEME_SAMPLER = """
42
+ async (ms) => {
43
+ const names = ['aa', 'ih', 'ou', 'ee', 'oh'];
44
+ const peak = {}; for (const n of names) peak[n] = 0;
45
+ const t0 = performance.now();
46
+ const ended = () => (window.__stageEvents || []).some((e) => e.event === 'speech-end');
47
+ return await new Promise((resolve) => {
48
+ const step = () => {
49
+ const d = window.__stageDebug;
50
+ if (d && d.currentVisemes) {
51
+ for (const n of names) peak[n] = Math.max(peak[n], d.currentVisemes[n]);
52
+ }
53
+ if ((ended() && d && names.every((n) => d.currentVisemes[n] === 0))
54
+ || performance.now() - t0 >= ms) {
55
+ resolve({ peak, settled: d ? d.currentVisemes : null, ended: ended() });
56
+ } else {
57
+ requestAnimationFrame(step);
58
+ }
59
+ };
60
+ requestAnimationFrame(step);
61
+ });
62
+ }
63
+ """
64
+
65
+
66
+ def _open_stage(page, static_server, query: str = ""):
67
+ page.goto(f"{static_server}/avatar/stage.html{query}")
68
+ page.wait_for_function(STAGE_READY, timeout=READY_TIMEOUT_MS)
69
+ return page.evaluate("() => window.__stageDebug")
70
+
71
+
72
+ def test_stage_boots_with_one_three_instance(page, static_server):
73
+ """The single structural fact the whole renderer depends on."""
74
+ debug = _open_stage(page, static_server, "?demo=1")
75
+
76
+ assert debug["threeInstanceCount"] == 1, (
77
+ f"expected exactly one three.mjs resource entry, got {debug['threeInstanceCount']}; "
78
+ "the ?deps=three@0.185.1 pin has stopped converging and the VRM is a T-posed glTF"
79
+ )
80
+ assert isinstance(debug["vrmMetaTitle"], str) and debug["vrmMetaTitle"], (
81
+ "vrmMetaTitle is empty; the VRM loaded without its VRMC_vrm.meta block"
82
+ )
83
+
84
+
85
+ def test_idle_life_values_change(page, static_server):
86
+ """AVTR-01's substance: the avatar is alive before any audio exists.
87
+
88
+ 12 s spans at least one blink on the 1.8-5.8 s schedule and three 4 s breath cycles.
89
+ Never a pixel diff - the canvas is opaque, the numbers are not.
90
+ """
91
+ _open_stage(page, static_server)
92
+ samples = page.evaluate(IDLE_SAMPLER, 12_000)
93
+
94
+ breath = samples["breath"]
95
+ blink = samples["blink"]
96
+ assert len({round(b, 4) for b in breath}) > 5, (
97
+ f"breathValue took only {len(set(breath))} distinct values over 12 s; it is not breathing"
98
+ )
99
+ assert max(blink) > 0.5, (
100
+ f"blinkValue peaked at {max(blink)} over 12 s; no blink completed its 0 -> 1 -> 0 ramp"
101
+ )
102
+
103
+
104
+ def test_demo_timeline_drives_visemes(page, static_server):
105
+ """A hardcoded timeline against a canned WAV, clocked off the AudioContext."""
106
+ _open_stage(page, static_server, "?demo=1")
107
+ result = page.evaluate(VISEME_SAMPLER, 20_000)
108
+
109
+ peak = result["peak"]
110
+ assert result["ended"], f"no speech-end was emitted; peaks were {peak}"
111
+
112
+ opened = [name for name in ("aa", "ih", "oh") if peak[name] > 0.5]
113
+ assert len(opened) >= 3, (
114
+ f"only {opened} exceeded 0.5; こんにちは drives o, i, i and a, so aa/ih/oh "
115
+ f"must all open. Peaks: {peak}"
116
+ )
117
+ assert all(value == 0 for value in result["settled"].values()), (
118
+ f"the mouth did not shut after speech-end: {result['settled']}"
119
+ )
120
+
121
+
122
+ def test_console_has_no_multiple_three_warning(page, static_server):
123
+ """The console message that means the ?deps= pin silently stopped working."""
124
+ messages: list[str] = []
125
+ page.on("console", lambda m: messages.append(m.text))
126
+ page.on("pageerror", lambda e: messages.append(str(e)))
127
+
128
+ _open_stage(page, static_server, "?demo=1")
129
+ page.wait_for_timeout(3_000)
130
+
131
+ offenders = [m for m in messages if "Multiple instances of Three.js" in m]
132
+ assert not offenders, f"three.js reported duplicate instances: {offenders}"
tests/test_transport_seam.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The transport seam. If the gr.HTML spike fails, only the stagePort changes.
2
+
3
+ These tests exist so that stays true as the phase progresses. Plans 01-07 and 01-08
4
+ add turn behaviour; these tests fail if it lands in a transport file instead of in
5
+ the shared turn-loop module.
6
+ """
7
+
8
+ import re
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ # Resolved from this file rather than the process cwd: the quick loop is documented as
14
+ # running from the repo root, but a relative Path() would make that a silent
15
+ # precondition of every assertion below.
16
+ AVATAR = Path(__file__).resolve().parent.parent / "avatar"
17
+ CORE = ["vrm-stage.js", "lipsync.js", "audio-queue.js"]
18
+ SHARED = ["facade.js", "turn-loop.js"]
19
+ TRANSPORTS = ["avatar.js", "avatar-iframe.js"]
20
+ GRADIO_TOKENS = ["gradio", "gradio_api", "server.", "trigger("]
21
+ AMPLITUDE_TOKENS = ["AnalyserNode", "getByteFrequencyData", "getFloatTimeDomainData"]
22
+ TURN_SURFACE = ["startListening", "stopListening", "dispatchTurn", "requestSlower"]
23
+
24
+
25
+ def src(name: str) -> str:
26
+ return (AVATAR / name).read_text(encoding="utf-8")
27
+
28
+
29
+ def avatar_surface() -> list[str]:
30
+ """Parse AVATAR_SURFACE out of facade.js. Never duplicate the list in the test."""
31
+ m = re.search(r"AVATAR_SURFACE\s*=\s*\[(.*?)\]", src("facade.js"), re.S)
32
+ assert m, "AVATAR_SURFACE not found in avatar/facade.js"
33
+ return re.findall(r"'([a-zA-Z]+)'", m.group(1))
34
+
35
+
36
+ @pytest.mark.parametrize("name", CORE)
37
+ def test_stage_core_is_gradio_free(name):
38
+ lowered = src(name).lower()
39
+ for tok in GRADIO_TOKENS:
40
+ assert tok.lower() not in lowered, (
41
+ f"{name} references {tok!r}; the stage core must stay transport-agnostic"
42
+ )
43
+
44
+
45
+ @pytest.mark.parametrize("name", CORE)
46
+ def test_no_amplitude_lipsync(name):
47
+ for tok in AMPLITUDE_TOKENS:
48
+ assert tok not in src(name), f"{name} uses {tok!r}; AVTR-02 disqualifies amplitude lip-sync"
49
+
50
+
51
+ @pytest.mark.parametrize("name", SHARED)
52
+ def test_shared_modules_are_gradio_free(name):
53
+ lowered = src(name).lower()
54
+ for tok in GRADIO_TOKENS:
55
+ assert tok.lower() not in lowered, f"{name} must not know about Gradio"
56
+
57
+
58
+ def test_lipsync_uses_audiocontext_clock():
59
+ s = src("lipsync.js")
60
+ assert "currentTime" in s
61
+ assert "setTimeout" not in s
62
+ assert "requestAnimationFrame" not in s # the stage owns the loop; the player is a tick fn
63
+
64
+
65
+ def test_module_urls_are_deps_pinned():
66
+ s = src("vrm-stage.js")
67
+ assert "https://esm.sh/three@0.185.1" in s
68
+ assert "https://esm.sh/@pixiv/three-vrm@3.5.5?deps=three@0.185.1" in s
69
+ assert "importmap" not in s
70
+
71
+
72
+ def test_only_facade_assigns_window_avatar():
73
+ """One facade builder. Two transports cannot drift if only one of them writes the object."""
74
+ for p in AVATAR.glob("*.js"):
75
+ assigns = re.search(r"window\.Avatar\s*=", p.read_text(encoding="utf-8"))
76
+ if p.name == "facade.js":
77
+ assert assigns, "facade.js must be the one that installs window.Avatar"
78
+ else:
79
+ assert not assigns, f"{p.name} assigns window.Avatar; only facade.js may"
80
+
81
+
82
+ @pytest.mark.parametrize("name", TRANSPORTS)
83
+ def test_transports_only_do_plumbing(name):
84
+ """A transport builds a stagePort and delegates. It never implements turn behaviour."""
85
+ s = src(name)
86
+ assert "installFacade" in s and "createTurnLoop" in s, (
87
+ f"{name} must use the shared facade/turn loop"
88
+ )
89
+ for member in TURN_SURFACE:
90
+ assert f"function {member}" not in s and f"{member}(text" not in s, (
91
+ f"{name} implements {member}; turn behaviour belongs in avatar/turn-loop.js "
92
+ "or the iframe fallback silently loses it"
93
+ )
94
+
95
+
96
+ @pytest.mark.parametrize(
97
+ "member",
98
+ [
99
+ "mount",
100
+ "speak",
101
+ "replay",
102
+ "setListening",
103
+ "setThinking",
104
+ "on",
105
+ "getDebug",
106
+ "startListening",
107
+ "stopListening",
108
+ "dispatchTurn",
109
+ "requestSlower",
110
+ ],
111
+ )
112
+ def test_surface_member_is_declared_once(member):
113
+ """Every promised method must resolve in facade.js's surface list."""
114
+ assert member in avatar_surface()
115
+
116
+
117
+ @pytest.mark.parametrize("member", TURN_SURFACE)
118
+ def test_turn_surface_lives_in_the_shared_module(member):
119
+ assert member in src("turn-loop.js"), (
120
+ f"{member} must be defined in avatar/turn-loop.js so BOTH transports get it"
121
+ )
122
+
123
+
124
+ @pytest.mark.parametrize(
125
+ "key",
126
+ [
127
+ "ready",
128
+ "mountCount",
129
+ "vrmMetaTitle",
130
+ "threeInstanceCount",
131
+ "currentVisemes",
132
+ "blinkValue",
133
+ "clockOffset",
134
+ "transport",
135
+ ],
136
+ )
137
+ def test_debug_contract_keys_present(key):
138
+ joined = src("facade.js") + src("turn-loop.js") + src("vrm-stage.js")
139
+ assert key in joined, f"__debug.{key} is required by 01-VALIDATION.md Observable Signals"