Spaces:
Running on Zero
test(02-07): rehearsal script; furigana at three layers, and a per-thread Sudachi tokenizer
Browse files- scripts/rehearse_deployed.py: free port, DISABLE_GPU=1 app.py, poll /config,
run the given pytest selection with --space-url, stop the app, exit with
pytest's code (3 with the app's last log lines when it never came up)
- test_facade_parity.py::test_ruby_under_both_transports[inline|iframe]: a real
typed turn puts ruby on the learner's line and the avatar's under both
transports, gated against the Python oracle over the page's own tokens
- test_avatar_loop.py: deployed rows test_furigana_rendered,
test_language_assets_loaded, test_furigana_modes (both preferences across a
reload), plus transcript_dump/wait_for_ruby so a missing rt reports why
- nlp/tokenizer.py: the Tokenizer is now per THREAD over one shared Dictionary.
A shared sudachipy.Tokenizer raises RuntimeError: Already borrowed under
concurrency (5 of 8 threads measured), which this plan made reachable - the
host analyses the learner's line while the turn's analyse stage runs, and the
lost analysis shipped a directive with tokens: [] and an unannotated line
- tests/test_analyzer.py::test_analyze_is_thread_safe: 32 concurrent analyses
all succeed and agree; mutation-tested against the shared-tokenizer revision
- scripts/rehearse_deployed.py +191 -0
- src/japanese_avatar/nlp/tokenizer.py +38 -5
- tests/e2e/test_avatar_loop.py +274 -0
- tests/e2e/test_facade_parity.py +140 -0
- tests/test_analyzer.py +29 -0
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rehearse the deployed suite against a LOCAL app.py, on a free port, with one command.
|
| 2 |
+
|
| 3 |
+
.venv/Scripts/python.exe scripts/rehearse_deployed.py tests/e2e/test_avatar_loop.py -q
|
| 4 |
+
.venv/Scripts/python.exe scripts/rehearse_deployed.py tests/e2e/test_avatar_loop.py \
|
| 5 |
+
-q -k "furigana or language_assets"
|
| 6 |
+
|
| 7 |
+
The deployed rows (``pytestmark = pytest.mark.deployed``) skip unless pytest is given
|
| 8 |
+
``--space-url``. Until the owner pushes, the only honest way to run them is against a real
|
| 9 |
+
app.py serving the working tree - and "start the app by hand on 7860, remember to export
|
| 10 |
+
DISABLE_GPU, remember to kill it" is exactly the ritual that produces a stale process, a
|
| 11 |
+
port clash with the parity suite's own apps, or a rehearsal that silently ran against
|
| 12 |
+
yesterday's build. So it is a script, and every later plan in this phase reuses it.
|
| 13 |
+
|
| 14 |
+
What it guarantees:
|
| 15 |
+
|
| 16 |
+
* a FREE port, taken by binding ``('127.0.0.1', 0)`` and reading the number back, so two
|
| 17 |
+
rehearsals (or a rehearsal and the parity suite) never collide. Nothing is hard-coded.
|
| 18 |
+
* ``DISABLE_GPU=1`` - the Phase 1 kill switch. Locally there is no ZeroGPU allocator, and
|
| 19 |
+
a rehearsal must never reach ``@spaces.GPU``.
|
| 20 |
+
* the app is up before pytest starts: ``/config`` is polled until it answers 200, which on
|
| 21 |
+
a cold working tree means the Sudachi dictionary, the compact JMdict and the CTranslate2
|
| 22 |
+
translator have all been read (``Blocks.load`` warms them) - a wait measured in tens of
|
| 23 |
+
seconds, not a sleep.
|
| 24 |
+
* the app is stopped afterwards, whatever pytest did, and its tree is killed if it ignores
|
| 25 |
+
the polite request. A rehearsal leaves no listener behind.
|
| 26 |
+
|
| 27 |
+
Exit code: pytest's own, so this is drop-in for CI - except 3, which means the app never
|
| 28 |
+
came up; its last log lines are printed so the reason is on screen rather than in a pipe.
|
| 29 |
+
|
| 30 |
+
The URL handed to pytest is a loopback one, which keeps ``docs/LATENCY.md``'s rule in
|
| 31 |
+
force: the latency harness writes to tmp_path for a non-Space URL, so a rehearsal can
|
| 32 |
+
never overwrite the Space's recorded numbers.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import collections
|
| 38 |
+
import os
|
| 39 |
+
import socket
|
| 40 |
+
import subprocess
|
| 41 |
+
import sys
|
| 42 |
+
import threading
|
| 43 |
+
import time
|
| 44 |
+
import urllib.error
|
| 45 |
+
import urllib.request
|
| 46 |
+
from pathlib import Path
|
| 47 |
+
|
| 48 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 49 |
+
|
| 50 |
+
# The app has to import Gradio, build the Blocks, and warm the tokenizer, the compact
|
| 51 |
+
# JMdict and the translator before /config answers. Measured ~30-60 s on this fleet from a
|
| 52 |
+
# warm page cache; 180 s is the headroom a cold one needs.
|
| 53 |
+
BOOT_TIMEOUT_S = 180
|
| 54 |
+
POLL_S = 0.5
|
| 55 |
+
STOP_GRACE_S = 15
|
| 56 |
+
LOG_TAIL_LINES = 40
|
| 57 |
+
APP_NEVER_CAME_UP = 3
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def free_port() -> int:
|
| 61 |
+
"""A port the OS says is free right now. Bind to 0, read it back, release it."""
|
| 62 |
+
with socket.socket() as probe:
|
| 63 |
+
probe.bind(("127.0.0.1", 0))
|
| 64 |
+
return int(probe.getsockname()[1])
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _drain(stream, sink: collections.deque) -> None:
|
| 68 |
+
"""Read the child's merged output forever, keeping only the tail.
|
| 69 |
+
|
| 70 |
+
A pipe nobody reads fills and blocks the writer: Gradio logs every request, so an app
|
| 71 |
+
left unread would deadlock partway through a long suite. The deque bounds the memory.
|
| 72 |
+
"""
|
| 73 |
+
for line in iter(stream.readline, ""):
|
| 74 |
+
sink.append(line.rstrip("\n"))
|
| 75 |
+
stream.close()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def start_app(port: int) -> tuple[subprocess.Popen, collections.deque]:
|
| 79 |
+
env = {
|
| 80 |
+
**os.environ,
|
| 81 |
+
"DISABLE_GPU": "1",
|
| 82 |
+
"GRADIO_SERVER_NAME": "127.0.0.1",
|
| 83 |
+
"GRADIO_SERVER_PORT": str(port),
|
| 84 |
+
"GRADIO_ANALYTICS_ENABLED": "False",
|
| 85 |
+
"PYTHONIOENCODING": "utf-8",
|
| 86 |
+
}
|
| 87 |
+
proc = subprocess.Popen( # noqa: S603 - sys.executable and this repo's own app.py
|
| 88 |
+
[sys.executable, "app.py"],
|
| 89 |
+
cwd=str(REPO_ROOT),
|
| 90 |
+
env=env,
|
| 91 |
+
stdout=subprocess.PIPE,
|
| 92 |
+
stderr=subprocess.STDOUT,
|
| 93 |
+
text=True,
|
| 94 |
+
encoding="utf-8",
|
| 95 |
+
errors="replace",
|
| 96 |
+
bufsize=1,
|
| 97 |
+
)
|
| 98 |
+
log: collections.deque = collections.deque(maxlen=400)
|
| 99 |
+
threading.Thread(target=_drain, args=(proc.stdout, log), daemon=True).start()
|
| 100 |
+
return proc, log
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def wait_for_config(port: int, proc: subprocess.Popen) -> float:
|
| 104 |
+
"""Block until GET /config answers 200. Returns the seconds it took.
|
| 105 |
+
|
| 106 |
+
``/config`` rather than ``/``: Gradio 6 serves an SSR shell for the root before the app
|
| 107 |
+
is fully assembled, while /config is the app's own description.
|
| 108 |
+
"""
|
| 109 |
+
url = f"http://127.0.0.1:{port}/config"
|
| 110 |
+
deadline = time.monotonic() + BOOT_TIMEOUT_S
|
| 111 |
+
t0 = time.monotonic()
|
| 112 |
+
while time.monotonic() < deadline:
|
| 113 |
+
if proc.poll() is not None:
|
| 114 |
+
raise RuntimeError(f"app.py exited with {proc.returncode} before serving {url}")
|
| 115 |
+
try:
|
| 116 |
+
with urllib.request.urlopen(url, timeout=5) as response: # noqa: S310 - loopback
|
| 117 |
+
if response.status == 200:
|
| 118 |
+
return time.monotonic() - t0
|
| 119 |
+
except (urllib.error.URLError, TimeoutError, ConnectionError, OSError):
|
| 120 |
+
time.sleep(POLL_S)
|
| 121 |
+
raise RuntimeError(f"app.py did not answer {url} within {BOOT_TIMEOUT_S}s")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def stop_app(proc: subprocess.Popen) -> None:
|
| 125 |
+
"""Terminate, then kill the whole tree if it lingers. Gradio's server thread has
|
| 126 |
+
ignored a terminate here before, and a stray listener would poison the next run."""
|
| 127 |
+
if proc.poll() is not None:
|
| 128 |
+
return
|
| 129 |
+
proc.terminate()
|
| 130 |
+
try:
|
| 131 |
+
proc.wait(timeout=STOP_GRACE_S)
|
| 132 |
+
return
|
| 133 |
+
except subprocess.TimeoutExpired:
|
| 134 |
+
pass
|
| 135 |
+
if os.name == "nt":
|
| 136 |
+
subprocess.run( # noqa: S603, S607 - fixed argv, no shell
|
| 137 |
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
| 138 |
+
capture_output=True,
|
| 139 |
+
check=False,
|
| 140 |
+
)
|
| 141 |
+
else:
|
| 142 |
+
proc.kill()
|
| 143 |
+
try:
|
| 144 |
+
proc.wait(timeout=STOP_GRACE_S)
|
| 145 |
+
except subprocess.TimeoutExpired:
|
| 146 |
+
print("[rehearse] WARNING: app.py did not die; check for a stray process", flush=True)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def main(argv: list[str]) -> int:
|
| 150 |
+
if not argv:
|
| 151 |
+
print(__doc__)
|
| 152 |
+
return 2
|
| 153 |
+
port = free_port()
|
| 154 |
+
space_url = f"http://127.0.0.1:{port}"
|
| 155 |
+
print(f"[rehearse] starting DISABLE_GPU=1 app.py on {space_url}", flush=True)
|
| 156 |
+
proc, log = start_app(port)
|
| 157 |
+
try:
|
| 158 |
+
try:
|
| 159 |
+
boot_s = wait_for_config(port, proc)
|
| 160 |
+
except RuntimeError as err:
|
| 161 |
+
print(f"[rehearse] {err}", flush=True)
|
| 162 |
+
print(f"[rehearse] last {LOG_TAIL_LINES} log lines from app.py:", flush=True)
|
| 163 |
+
for line in list(log)[-LOG_TAIL_LINES:]:
|
| 164 |
+
print(f" {line}", flush=True)
|
| 165 |
+
return APP_NEVER_CAME_UP
|
| 166 |
+
print(f"[rehearse] app answered /config after {boot_s:.1f}s", flush=True)
|
| 167 |
+
command = [
|
| 168 |
+
sys.executable,
|
| 169 |
+
"-m",
|
| 170 |
+
"pytest",
|
| 171 |
+
*argv,
|
| 172 |
+
"--space-url",
|
| 173 |
+
space_url,
|
| 174 |
+
"-p",
|
| 175 |
+
"no:cacheprovider",
|
| 176 |
+
]
|
| 177 |
+
print(f"[rehearse] {' '.join(command)}", flush=True)
|
| 178 |
+
result = subprocess.run( # noqa: S603 - argv built here, no shell
|
| 179 |
+
command,
|
| 180 |
+
cwd=str(REPO_ROOT),
|
| 181 |
+
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
|
| 182 |
+
check=False,
|
| 183 |
+
)
|
| 184 |
+
return result.returncode
|
| 185 |
+
finally:
|
| 186 |
+
stop_app(proc)
|
| 187 |
+
print("[rehearse] app stopped", flush=True)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
if __name__ == "__main__":
|
| 191 |
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -2,9 +2,21 @@
|
|
| 2 |
|
| 3 |
Same shape as ``voice.tts``: nothing is built at import time, ``sudachipy`` is imported inside
|
| 4 |
the functions, and the dictionary (``sudachidict_core``, memory-mapped, ~0.1-0.2 s to open) is
|
| 5 |
-
created once by :func:`
|
| 6 |
app startup pay that cost before the first visitor does.
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
:func:`morphemes` is the only place a Sudachi object is touched. It returns plain dicts so that
|
| 9 |
``units.build_units`` and everything downstream is testable without the engine and no Sudachi
|
| 10 |
type ever crosses a function boundary.
|
|
@@ -13,6 +25,7 @@ type ever crosses a function boundary.
|
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import functools
|
|
|
|
| 16 |
import time
|
| 17 |
from typing import Any
|
| 18 |
|
|
@@ -20,17 +33,37 @@ from typing import Any
|
|
| 20 |
#: which ``units.build_units`` joins back together.
|
| 21 |
_SPLIT_MODE = "C"
|
| 22 |
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
@functools.lru_cache(maxsize=1)
|
| 25 |
-
def
|
| 26 |
-
"""The process-wide
|
| 27 |
from sudachipy import Dictionary
|
| 28 |
|
| 29 |
-
return Dictionary(dict="core")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
def warmup() -> float:
|
| 33 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
t0 = time.perf_counter()
|
| 35 |
get_tokenizer()
|
| 36 |
return time.perf_counter() - t0
|
|
|
|
| 2 |
|
| 3 |
Same shape as ``voice.tts``: nothing is built at import time, ``sudachipy`` is imported inside
|
| 4 |
the functions, and the dictionary (``sudachidict_core``, memory-mapped, ~0.1-0.2 s to open) is
|
| 5 |
+
created once by :func:`get_dictionary` and shared read-only across sessions. :func:`warmup` lets
|
| 6 |
app startup pay that cost before the first visitor does.
|
| 7 |
|
| 8 |
+
**The Tokenizer is per THREAD, the Dictionary is per process.** A ``sudachipy.Tokenizer`` is a
|
| 9 |
+
PyO3 object holding a mutable borrow of its internals for the duration of a ``tokenize`` call:
|
| 10 |
+
a second thread entering the same instance gets ``RuntimeError: Already borrowed``, not a wrong
|
| 11 |
+
answer. Measured here (8 threads, one shared tokenizer): 5 of 8 calls raised. Plan 02-07 made
|
| 12 |
+
that reachable in production - the host page now analyses the learner's line while the turn's
|
| 13 |
+
own analyse stage runs, so two requests hit the analyzer concurrently on every typed turn, and
|
| 14 |
+
a lost analysis showed up as a directive carrying ``tokens: []`` and a line with no furigana.
|
| 15 |
+
|
| 16 |
+
The Dictionary is the expensive part (the memory-mapped ~110 MB ``system.dic``) and stays
|
| 17 |
+
shared; ``create()`` is cheap, is called at most once per thread, and is serialised by
|
| 18 |
+
``_BUILD_LOCK`` because it borrows the Dictionary too. Threads that never tokenise pay nothing.
|
| 19 |
+
|
| 20 |
:func:`morphemes` is the only place a Sudachi object is touched. It returns plain dicts so that
|
| 21 |
``units.build_units`` and everything downstream is testable without the engine and no Sudachi
|
| 22 |
type ever crosses a function boundary.
|
|
|
|
| 25 |
from __future__ import annotations
|
| 26 |
|
| 27 |
import functools
|
| 28 |
+
import threading
|
| 29 |
import time
|
| 30 |
from typing import Any
|
| 31 |
|
|
|
|
| 33 |
#: which ``units.build_units`` joins back together.
|
| 34 |
_SPLIT_MODE = "C"
|
| 35 |
|
| 36 |
+
_LOCAL = threading.local()
|
| 37 |
+
_BUILD_LOCK = threading.Lock()
|
| 38 |
+
|
| 39 |
|
| 40 |
@functools.lru_cache(maxsize=1)
|
| 41 |
+
def get_dictionary() -> Any:
|
| 42 |
+
"""The process-wide memory-mapped core dictionary. Opened once; shared read-only."""
|
| 43 |
from sudachipy import Dictionary
|
| 44 |
|
| 45 |
+
return Dictionary(dict="core")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def get_tokenizer() -> Any:
|
| 49 |
+
"""This thread's tokenizer over the shared dictionary. Never share the return value."""
|
| 50 |
+
tokenizer = getattr(_LOCAL, "tokenizer", None)
|
| 51 |
+
if tokenizer is None:
|
| 52 |
+
# create() borrows the Dictionary, so two threads building at once would raise the
|
| 53 |
+
# very error this function exists to prevent. Once per thread; contention is nil.
|
| 54 |
+
with _BUILD_LOCK:
|
| 55 |
+
tokenizer = get_dictionary().create()
|
| 56 |
+
_LOCAL.tokenizer = tokenizer
|
| 57 |
+
return tokenizer
|
| 58 |
|
| 59 |
|
| 60 |
def warmup() -> float:
|
| 61 |
+
"""Open the dictionary now and return the seconds it took (0.0 once it is already open).
|
| 62 |
+
|
| 63 |
+
Pays the memory-mapping cost, which is what the visitor would otherwise wait for; the
|
| 64 |
+
per-thread ``create()`` that follows is microseconds and cannot be pre-paid for a
|
| 65 |
+
request thread that does not exist yet.
|
| 66 |
+
"""
|
| 67 |
t0 = time.perf_counter()
|
| 68 |
get_tokenizer()
|
| 69 |
return time.perf_counter() - t0
|
|
@@ -26,6 +26,7 @@ from urllib.parse import urlparse
|
|
| 26 |
|
| 27 |
import pytest
|
| 28 |
import requests
|
|
|
|
| 29 |
|
| 30 |
from japanese_avatar.nlp.levels import LEVEL_RANK, kanji_levels
|
| 31 |
from japanese_avatar.nlp.ruby import is_kanji
|
|
@@ -1202,3 +1203,276 @@ def expected_rt(tokens: list[dict], mode: str, level: str) -> int:
|
|
| 1202 |
shown += 1
|
| 1203 |
break
|
| 1204 |
return shown
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
import pytest
|
| 28 |
import requests
|
| 29 |
+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
| 30 |
|
| 31 |
from japanese_avatar.nlp.levels import LEVEL_RANK, kanji_levels
|
| 32 |
from japanese_avatar.nlp.ruby import is_kanji
|
|
|
|
| 1203 |
shown += 1
|
| 1204 |
break
|
| 1205 |
return shown
|
| 1206 |
+
|
| 1207 |
+
|
| 1208 |
+
STUDY_TEXT = "日本語を勉強しています。"
|
| 1209 |
+
STUDY_READING_FIRST = "にほんご"
|
| 1210 |
+
|
| 1211 |
+
# host.js binds after boot - the shared template imports host.js and transcript.js once the
|
| 1212 |
+
# facade exists - so `ready` is true for a window during which getDebug() carries no
|
| 1213 |
+
# furigana slice at all. Measured on the local rehearsal: at `ready` it reads null. Every
|
| 1214 |
+
# furigana read below waits for this first, so none of these rows can race the bind.
|
| 1215 |
+
HOST_BOUND = "() => !!(window.Avatar && window.Avatar.__debug && window.Avatar.__debug.furigana)"
|
| 1216 |
+
AVATAR_READY = (
|
| 1217 |
+
"() => !!window.Avatar && window.Avatar.__debug && window.Avatar.__debug.ready === true"
|
| 1218 |
+
)
|
| 1219 |
+
|
| 1220 |
+
FURIGANA = """
|
| 1221 |
+
async () => {
|
| 1222 |
+
const d = await window.Avatar.getDebug();
|
| 1223 |
+
return d.furigana ? JSON.parse(JSON.stringify(d.furigana)) : null;
|
| 1224 |
+
}
|
| 1225 |
+
"""
|
| 1226 |
+
LAST_TOKENS = "async () => (await window.Avatar.getDebug()).lastTokens"
|
| 1227 |
+
RT_COUNTS = """
|
| 1228 |
+
() => ({
|
| 1229 |
+
you: document.querySelectorAll('#transcript-text .turn-you rt').length,
|
| 1230 |
+
avatar: document.querySelectorAll('#transcript-text .turn-avatar rt').length,
|
| 1231 |
+
total: document.querySelectorAll('#transcript-text rt').length,
|
| 1232 |
+
})
|
| 1233 |
+
"""
|
| 1234 |
+
LEARNER_RUBY = "() => document.querySelectorAll('#transcript-text .turn-you rt').length > 0"
|
| 1235 |
+
AVATAR_RUBY = "() => document.querySelectorAll('#transcript-text .turn-avatar rt').length > 0"
|
| 1236 |
+
|
| 1237 |
+
# The learner's line is upgraded to ruby by a second round trip (analyze), issued the
|
| 1238 |
+
# instant the plain echo lands and then racing the turn's own synthesis through one Gradio
|
| 1239 |
+
# queue. The server-side analysis is sub-millisecond (lastAnalyzeMs is printed beside the
|
| 1240 |
+
# wall time, so the difference is visibly queueing); on the public Space the queue is also
|
| 1241 |
+
# where a cold container's warm-up sits. Generous, and RECORDED rather than asserted.
|
| 1242 |
+
LEARNER_RUBY_TIMEOUT_MS = 120_000
|
| 1243 |
+
|
| 1244 |
+
|
| 1245 |
+
def _furigana(page) -> dict:
|
| 1246 |
+
return page.evaluate(FURIGANA)
|
| 1247 |
+
|
| 1248 |
+
|
| 1249 |
+
def _set_reading(page, select_id: str, value: str) -> dict:
|
| 1250 |
+
page.select_option(f"#{select_id}", value)
|
| 1251 |
+
page.wait_for_timeout(150)
|
| 1252 |
+
return _furigana(page)
|
| 1253 |
+
|
| 1254 |
+
|
| 1255 |
+
def transcript_dump(page) -> str:
|
| 1256 |
+
"""Everything needed to tell WHY a line has no ruby, gathered on failure only.
|
| 1257 |
+
|
| 1258 |
+
A bare ``wait_for_function`` timeout says "no rt appeared" and nothing else, and the
|
| 1259 |
+
three causes look identical from Python: the line was never added (the turn carried no
|
| 1260 |
+
subtitle), it was added with an empty token list (``turn`` returns ``[]`` rather than
|
| 1261 |
+
losing the utterance when the analyse stage fails), or the rt is there and the selector
|
| 1262 |
+
is wrong. The markup plus the turn event's own token surfaces separate them in one shot.
|
| 1263 |
+
|
| 1264 |
+
Shared with the both-transports layer, which imports it, so a failure at either layer
|
| 1265 |
+
prints the same evidence.
|
| 1266 |
+
"""
|
| 1267 |
+
markup = page.evaluate(
|
| 1268 |
+
"() => { const el = document.querySelector('#transcript-text');"
|
| 1269 |
+
" return el ? el.innerHTML : '(no #transcript-text)'; }"
|
| 1270 |
+
)
|
| 1271 |
+
turns = page.evaluate(
|
| 1272 |
+
"() => (window.__events || []).filter((e) => e.event === 'turn').map((e) => ({"
|
| 1273 |
+
" subtitle: e.data && e.data.subtitle,"
|
| 1274 |
+
" tokens: e.data && e.data.tokens ? e.data.tokens.map((t) => t.surface) : null,"
|
| 1275 |
+
" speed: e.data && e.data.speed, timings: e.data && e.data.timings }))"
|
| 1276 |
+
)
|
| 1277 |
+
errors = page.evaluate(
|
| 1278 |
+
"() => (window.__events || []).filter((e) => e.event === 'error').map((e) => e.data)"
|
| 1279 |
+
)
|
| 1280 |
+
return "\n".join(
|
| 1281 |
+
[f"transcript={markup!r}", f"turn events={turns!r}", f"error events={errors!r}"]
|
| 1282 |
+
)
|
| 1283 |
+
|
| 1284 |
+
|
| 1285 |
+
def wait_for_ruby(page, predicate: str, timeout_ms: int, what: str) -> None:
|
| 1286 |
+
"""wait_for_function, but a timeout carries the page's own explanation."""
|
| 1287 |
+
try:
|
| 1288 |
+
page.wait_for_function(predicate, timeout=timeout_ms)
|
| 1289 |
+
except PlaywrightTimeoutError:
|
| 1290 |
+
dump = transcript_dump(page)
|
| 1291 |
+
raise AssertionError(f"{what} within {timeout_ms} ms.\n{dump}") from None
|
| 1292 |
+
|
| 1293 |
+
|
| 1294 |
+
def _study_turn(page, events) -> dict:
|
| 1295 |
+
"""One typed turn of the study sentence through the host's own path, timed.
|
| 1296 |
+
|
| 1297 |
+
Not ``_text_turn``: the learner's ruby lands on its own schedule, before speech-end,
|
| 1298 |
+
and the point of this row is that BOTH lines get it - so the two waits are separate
|
| 1299 |
+
and both numbers are reported.
|
| 1300 |
+
"""
|
| 1301 |
+
before = len(events.named(page, "speech-end"))
|
| 1302 |
+
submitted = time.monotonic()
|
| 1303 |
+
_submit_text(page, STUDY_TEXT)
|
| 1304 |
+
wait_for_ruby(page, LEARNER_RUBY, LEARNER_RUBY_TIMEOUT_MS, "no ruby on the learner's line")
|
| 1305 |
+
learner_ms = round((time.monotonic() - submitted) * 1000)
|
| 1306 |
+
ends = events.wait_for(
|
| 1307 |
+
page, "speech-end", timeout_ms=SPEECH_END_TIMEOUT_MS, at_least=before + 1
|
| 1308 |
+
)
|
| 1309 |
+
wait_for_ruby(page, AVATAR_RUBY, SPEECH_END_TIMEOUT_MS, "no ruby on the avatar's line")
|
| 1310 |
+
return {
|
| 1311 |
+
"learner_ruby_ms": learner_ms,
|
| 1312 |
+
"turn_ms": round((time.monotonic() - submitted) * 1000),
|
| 1313 |
+
"duration": ends[-1]["audioDuration"],
|
| 1314 |
+
"analyze_ms": page.evaluate("async () => (await window.Avatar.getDebug()).lastAnalyzeMs"),
|
| 1315 |
+
}
|
| 1316 |
+
|
| 1317 |
+
|
| 1318 |
+
def _reload_ready(page) -> None:
|
| 1319 |
+
"""Reload in place (not a fresh goto) and wait until the host has bound again."""
|
| 1320 |
+
page.reload(timeout=STAGE_ATTACHED_TIMEOUT_MS + 30_000)
|
| 1321 |
+
page.wait_for_selector("#vrm-stage", state="attached", timeout=STAGE_ATTACHED_TIMEOUT_MS)
|
| 1322 |
+
page.wait_for_function(AVATAR_READY, timeout=READY_TIMEOUT_MS)
|
| 1323 |
+
page.wait_for_function(HOST_BOUND, timeout=READY_TIMEOUT_MS)
|
| 1324 |
+
|
| 1325 |
+
|
| 1326 |
+
@pytest.mark.deployed
|
| 1327 |
+
def test_furigana_rendered(page, space_url, warm_space, speech_events, wait_for_avatar_ready):
|
| 1328 |
+
"""JPN-02 / D-01 / D-03: on a first visit, with no control touched, every kanji on the
|
| 1329 |
+
avatar's line AND the learner's own line carries its reading as real ``<ruby><rt>``.
|
| 1330 |
+
|
| 1331 |
+
Three layers of number, because a count alone cannot distinguish rendered ruby from
|
| 1332 |
+
ruby a stylesheet has hidden (research Pitfall 4, the T-pose lesson): the rt COUNT from
|
| 1333 |
+
both the DOM and getDebug(), the rendered rt HEIGHT, and the line HEIGHT with the
|
| 1334 |
+
annotation versus without it. If this fails while
|
| 1335 |
+
tests/e2e/test_transcript_standalone.py passes, the fault is provably the host page -
|
| 1336 |
+
the stylesheet did not load, or the boot template did not hand the module over.
|
| 1337 |
+
"""
|
| 1338 |
+
speech_events.install(page)
|
| 1339 |
+
timings = wait_for_avatar_ready(page, space_url)
|
| 1340 |
+
page.wait_for_function(HOST_BOUND, timeout=READY_TIMEOUT_MS)
|
| 1341 |
+
|
| 1342 |
+
# D-01: untouched defaults, server-rendered on the first paint.
|
| 1343 |
+
assert page.input_value("#furigana-mode") == "always"
|
| 1344 |
+
assert page.input_value("#level-select") == "N5"
|
| 1345 |
+
seeded = _furigana(page)
|
| 1346 |
+
print(
|
| 1347 |
+
f"\n[deployed] ready {timings['ready_seconds']:.1f}s, first frame "
|
| 1348 |
+
f"{timings['first_frame_seconds']:.1f}s; furigana at bind: {seeded}"
|
| 1349 |
+
)
|
| 1350 |
+
assert seeded["mode"] == "always" and seeded["level"] == "N5"
|
| 1351 |
+
assert seeded["storage"] == "ok", "the Space's page could not use localStorage"
|
| 1352 |
+
|
| 1353 |
+
turn = _study_turn(page, speech_events)
|
| 1354 |
+
counts = page.evaluate(RT_COUNTS)
|
| 1355 |
+
always = _furigana(page)
|
| 1356 |
+
tokens = page.evaluate(LAST_TOKENS)
|
| 1357 |
+
print(
|
| 1358 |
+
f"[deployed] {STUDY_TEXT}: rt {counts}; learner ruby {turn['learner_ruby_ms']} ms after "
|
| 1359 |
+
f"Enter (server analyze {turn['analyze_ms']} ms), turn {turn['turn_ms']} ms, audio "
|
| 1360 |
+
f"{turn['duration']}s; furigana {always}"
|
| 1361 |
+
)
|
| 1362 |
+
assert counts["you"] == 2, "D-03: the learner's own line must carry the readings too"
|
| 1363 |
+
assert counts["avatar"] == 2
|
| 1364 |
+
assert always["lastLineRt"] == 2
|
| 1365 |
+
assert always["lastLineKanjiRuns"] == 2
|
| 1366 |
+
assert always["lastLineRtHeightPx"] > 0, "rt exists but rendered with no height"
|
| 1367 |
+
assert always["rtTotal"] == counts["total"] == 4
|
| 1368 |
+
assert expected_rt(tokens, "always", "N5") == 2, tokens
|
| 1369 |
+
first_rt = page.locator("#transcript-text .turn-avatar rt").first.text_content()
|
| 1370 |
+
assert first_rt == STUDY_READING_FIRST, first_rt
|
| 1371 |
+
|
| 1372 |
+
never = _set_reading(page, "furigana-mode", "never")
|
| 1373 |
+
print(
|
| 1374 |
+
f"[deployed] rendered height: always rt {always['lastLineRtHeightPx']} px on a line of "
|
| 1375 |
+
f"{always['lastLineHeightPx']} px; never {never['lastLineHeightPx']} px"
|
| 1376 |
+
)
|
| 1377 |
+
assert never["rtTotal"] == 0
|
| 1378 |
+
assert always["lastLineHeightPx"] > never["lastLineHeightPx"], (
|
| 1379 |
+
"the annotated line is not taller than the bare one; transcript.css did not reach "
|
| 1380 |
+
f"the Space's page (always {always['lastLineHeightPx']} px vs never "
|
| 1381 |
+
f"{never['lastLineHeightPx']} px)"
|
| 1382 |
+
)
|
| 1383 |
+
_set_reading(page, "furigana-mode", "always")
|
| 1384 |
+
|
| 1385 |
+
|
| 1386 |
+
@pytest.mark.deployed
|
| 1387 |
+
def test_language_assets_loaded(page, space_url, warm_space, wait_for_avatar_ready):
|
| 1388 |
+
"""JPN-01 on the Space: the language assets are REAL FILES in the container, not LFS
|
| 1389 |
+
pointers (research Pitfall 7), and the load warm-up ran to completion.
|
| 1390 |
+
|
| 1391 |
+
``languageInfo()`` blocks on the same lock ``Blocks.load`` warms behind (plan 02-06), so
|
| 1392 |
+
this row cannot race the warm-up on a slow cold Space; it waits for it. A 133-byte LFS
|
| 1393 |
+
pointer where model.bin should be fails HERE, with a byte count, rather than as a
|
| 1394 |
+
mystery at some visitor's first tap on an English gloss.
|
| 1395 |
+
"""
|
| 1396 |
+
wait_for_avatar_ready(page, space_url)
|
| 1397 |
+
info = page.evaluate("() => window.Avatar.languageInfo()")
|
| 1398 |
+
assert "error" not in info, info
|
| 1399 |
+
warm = info["warm"]
|
| 1400 |
+
print(
|
| 1401 |
+
f"\n[deployed] languageInfo: jmdict_entries={info['jmdict_entries']} "
|
| 1402 |
+
f"jmdict_bytes={info['jmdict_bytes']} "
|
| 1403 |
+
f"tokenizer_dict_bytes={info['tokenizer_dict_bytes']} "
|
| 1404 |
+
f"mt_model_bytes={info['mt_model_bytes']} rss_mb={info['rss_mb']} "
|
| 1405 |
+
f"cpu_cores={info['cpu_cores']} memory={info['memory']}"
|
| 1406 |
+
)
|
| 1407 |
+
print(
|
| 1408 |
+
f"[deployed] warm-up: total {warm['total_s']}s, rss_delta {warm['rss_delta_mb']} MB vs "
|
| 1409 |
+
f"expected {warm['expected_rss_delta_mb']} MB, errors {warm['errors']}; "
|
| 1410 |
+
f"pins {info['pins']}"
|
| 1411 |
+
)
|
| 1412 |
+
assert info["jmdict_entries"] == 218672
|
| 1413 |
+
assert info["mt_model_bytes"] > 70_000_000, "model.bin is an LFS pointer, not the model"
|
| 1414 |
+
assert info["tokenizer_dict_bytes"] > 100_000_000, "SudachiDict-core is not installed whole"
|
| 1415 |
+
assert isinstance(warm["total_s"], int | float)
|
| 1416 |
+
assert warm["errors"] == []
|
| 1417 |
+
assert info["pins"]["sudachidict_core"] == "20260723"
|
| 1418 |
+
|
| 1419 |
+
|
| 1420 |
+
@pytest.mark.deployed
|
| 1421 |
+
def test_furigana_modes(page, space_url, warm_space, speech_events, wait_for_avatar_ready):
|
| 1422 |
+
"""JPN-02 / D-02 / D-13 / D-14: the three-way control gates on the KANJI axis at the
|
| 1423 |
+
learner's level, and BOTH preferences survive a reload.
|
| 1424 |
+
|
| 1425 |
+
The gated counts come from :func:`expected_rt` over the page's OWN tokens and the pinned
|
| 1426 |
+
kanji list, evaluated in Python: 勉 and 強's levels are the data's to say, and hard-coding
|
| 1427 |
+
them would let a wrong record and a wrong gate agree. The reload is the D-14 proof, and
|
| 1428 |
+
it reads the SELECTS as well as the debug slice - a mode that survives in memory but not
|
| 1429 |
+
on the control is a visitor confusion, not a persisted preference.
|
| 1430 |
+
"""
|
| 1431 |
+
speech_events.install(page)
|
| 1432 |
+
wait_for_avatar_ready(page, space_url)
|
| 1433 |
+
page.wait_for_function(HOST_BOUND, timeout=READY_TIMEOUT_MS)
|
| 1434 |
+
_study_turn(page, speech_events)
|
| 1435 |
+
tokens = page.evaluate(LAST_TOKENS)
|
| 1436 |
+
|
| 1437 |
+
never = _set_reading(page, "furigana-mode", "never")
|
| 1438 |
+
assert never["rtTotal"] == 0 and never["lastLineRt"] == 0
|
| 1439 |
+
assert page.locator("#transcript-text rt").count() == 0
|
| 1440 |
+
|
| 1441 |
+
above_n5 = _set_reading(page, "furigana-mode", "above")
|
| 1442 |
+
want_n5 = expected_rt(tokens, "above", "N5")
|
| 1443 |
+
assert above_n5["mode"] == "above" and above_n5["level"] == "N5"
|
| 1444 |
+
assert above_n5["lastLineRt"] == want_n5, (above_n5, want_n5)
|
| 1445 |
+
|
| 1446 |
+
above_n2 = _set_reading(page, "level-select", "N2")
|
| 1447 |
+
want_n2 = expected_rt(tokens, "above", "N2")
|
| 1448 |
+
assert above_n2["level"] == "N2"
|
| 1449 |
+
assert above_n2["lastLineRt"] == want_n2, (above_n2, want_n2)
|
| 1450 |
+
assert want_n2 <= want_n5, "a higher level cannot annotate MORE"
|
| 1451 |
+
|
| 1452 |
+
_set_reading(page, "level-select", "N5")
|
| 1453 |
+
back = _set_reading(page, "furigana-mode", "always")
|
| 1454 |
+
print(
|
| 1455 |
+
f"\n[deployed] modes: always rt={back['lastLineRt']}; never rt=0; "
|
| 1456 |
+
f"above@N5 rt={above_n5['lastLineRt']} (oracle {want_n5}); "
|
| 1457 |
+
f"above@N2 rt={above_n2['lastLineRt']} (oracle {want_n2})"
|
| 1458 |
+
)
|
| 1459 |
+
assert back["lastLineRt"] == 2
|
| 1460 |
+
|
| 1461 |
+
# D-14: both preferences, across a reload, on the controls and in the renderer.
|
| 1462 |
+
_set_reading(page, "furigana-mode", "never")
|
| 1463 |
+
_set_reading(page, "level-select", "N3")
|
| 1464 |
+
_reload_ready(page)
|
| 1465 |
+
restored = _furigana(page)
|
| 1466 |
+
selects = {
|
| 1467 |
+
"mode": page.input_value("#furigana-mode"),
|
| 1468 |
+
"level": page.input_value("#level-select"),
|
| 1469 |
+
}
|
| 1470 |
+
print(f"[deployed] after reload: selects {selects}, furigana {restored}")
|
| 1471 |
+
assert selects == {"mode": "never", "level": "N3"}
|
| 1472 |
+
assert restored["mode"] == "never" and restored["level"] == "N3"
|
| 1473 |
+
assert restored["storage"] == "ok"
|
| 1474 |
+
|
| 1475 |
+
# Leave the Space as a first visitor should find it.
|
| 1476 |
+
_set_reading(page, "furigana-mode", "always")
|
| 1477 |
+
_set_reading(page, "level-select", "N5")
|
| 1478 |
+
assert _furigana(page)["mode"] == "always"
|
|
@@ -16,9 +16,12 @@ Marked slow but NOT deployed: they run entirely locally.
|
|
| 16 |
|
| 17 |
from __future__ import annotations
|
| 18 |
|
|
|
|
|
|
|
| 19 |
import pytest
|
| 20 |
|
| 21 |
from tests.e2e.conftest import AVATAR_FIRST_FRAME_EXPR, AVATAR_READY_EXPR
|
|
|
|
| 22 |
from tests.e2e.test_stage_standalone import (
|
| 23 |
ARM_DOWN_MAX,
|
| 24 |
FIRST_FRAME_TIMEOUT_MS,
|
|
@@ -649,3 +652,140 @@ def test_language_bridge_under_both_transports(transport, page, gradio_apps, spe
|
|
| 649 |
after = page.evaluate(LANGUAGE_DEBUG)
|
| 650 |
assert after["translateCount"] == 1, "a refused translate is not a translation"
|
| 651 |
assert after["lastTranslateError"] is not None and "characters" in after["lastTranslateError"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
+
import time
|
| 20 |
+
|
| 21 |
import pytest
|
| 22 |
|
| 23 |
from tests.e2e.conftest import AVATAR_FIRST_FRAME_EXPR, AVATAR_READY_EXPR
|
| 24 |
+
from tests.e2e.test_avatar_loop import expected_rt, wait_for_ruby
|
| 25 |
from tests.e2e.test_stage_standalone import (
|
| 26 |
ARM_DOWN_MAX,
|
| 27 |
FIRST_FRAME_TIMEOUT_MS,
|
|
|
|
| 652 |
after = page.evaluate(LANGUAGE_DEBUG)
|
| 653 |
assert after["translateCount"] == 1, "a refused translate is not a translation"
|
| 654 |
assert after["lastTranslateError"] is not None and "characters" in after["lastTranslateError"]
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
# --------------------------------------------------------------- the rendered ruby (02-07)
|
| 658 |
+
#
|
| 659 |
+
# The standalone layer (tests/e2e/test_transcript_standalone.py) already proved the renderer
|
| 660 |
+
# in a browser with no server. This row proves the HOST: that the boot template hands
|
| 661 |
+
# transcript.js to bindHost under BOTH transports, that the stylesheet the app links is the
|
| 662 |
+
# one being applied, and that a real typed turn - the learner's own path through
|
| 663 |
+
# #text-input, not dispatchTurn - puts ruby on the learner's line as well as the avatar's.
|
| 664 |
+
|
| 665 |
+
STUDY_TEXT = "日本語を勉強しています。"
|
| 666 |
+
STUDY_READING_FIRST = "にほんご"
|
| 667 |
+
|
| 668 |
+
# host.js binds after boot (the template imports host.js and transcript.js once the facade
|
| 669 |
+
# exists), so `ready` is TRUE for a short window during which getDebug() carries no
|
| 670 |
+
# furigana slice. Every furigana read waits for this first - measured, not assumed: at
|
| 671 |
+
# `ready` the slice reads null on this fleet.
|
| 672 |
+
HOST_BOUND = "() => !!(window.Avatar && window.Avatar.__debug && window.Avatar.__debug.furigana)"
|
| 673 |
+
|
| 674 |
+
FURIGANA = """
|
| 675 |
+
async () => {
|
| 676 |
+
const d = await window.Avatar.getDebug();
|
| 677 |
+
return d.furigana ? JSON.parse(JSON.stringify(d.furigana)) : null;
|
| 678 |
+
}
|
| 679 |
+
"""
|
| 680 |
+
LAST_TOKENS = "async () => (await window.Avatar.getDebug()).lastTokens"
|
| 681 |
+
RT_COUNTS = """
|
| 682 |
+
() => ({
|
| 683 |
+
you: document.querySelectorAll('#transcript-text .turn-you rt').length,
|
| 684 |
+
avatar: document.querySelectorAll('#transcript-text .turn-avatar rt').length,
|
| 685 |
+
total: document.querySelectorAll('#transcript-text rt').length,
|
| 686 |
+
})
|
| 687 |
+
"""
|
| 688 |
+
LEARNER_RUBY = "() => document.querySelectorAll('#transcript-text .turn-you rt').length > 0"
|
| 689 |
+
AVATAR_RUBY = "() => document.querySelectorAll('#transcript-text .turn-avatar rt').length > 0"
|
| 690 |
+
|
| 691 |
+
# The learner's line is upgraded to ruby by a SECOND round trip (analyze), issued the
|
| 692 |
+
# moment the echo lands and racing the turn's own synthesis through one Gradio queue on a
|
| 693 |
+
# machine already running two apps under SwiftShader. The plan budgeted 5 s; the measured
|
| 694 |
+
# first-turn wall time here is 15-20 s (the server-side analysis itself is sub-millisecond
|
| 695 |
+
# - lastAnalyzeMs is printed beside it, so the difference is visibly queueing, not work).
|
| 696 |
+
# The wait is therefore generous and the number is RECORDED rather than asserted; a
|
| 697 |
+
# threshold would be measuring the fleet, not the feature.
|
| 698 |
+
LEARNER_RUBY_TIMEOUT_MS = 90_000
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def _select(page, select_id: str, value: str) -> dict:
|
| 702 |
+
page.select_option(f"#{select_id}", value)
|
| 703 |
+
page.wait_for_timeout(150)
|
| 704 |
+
return page.evaluate(FURIGANA)
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
@pytest.mark.parametrize("transport", TRANSPORTS)
|
| 708 |
+
def test_ruby_under_both_transports(transport, page, gradio_apps, speech_events):
|
| 709 |
+
"""JPN-02 / D-01 / D-02 / D-03 / D-13: the same ruby, from the same numbers, inline and
|
| 710 |
+
in an iframe.
|
| 711 |
+
|
| 712 |
+
Every claim is a number read from ``getDebug().furigana`` AND cross-checked against the
|
| 713 |
+
DOM, because a count alone cannot tell a rendered annotation from a hidden one: the rt
|
| 714 |
+
height and the always-versus-never line height are what make "invisible ruby" fail
|
| 715 |
+
(research Pitfall 4). The gated counts come from :func:`expected_rt` over the page's own
|
| 716 |
+
``lastTokens`` and the pinned kanji list - 勉 and 強's levels are the data's to say.
|
| 717 |
+
"""
|
| 718 |
+
url = gradio_apps(transport)
|
| 719 |
+
speech_events.install(page)
|
| 720 |
+
page.goto(url)
|
| 721 |
+
page.wait_for_function(AVATAR_READY, timeout=BOOT_TIMEOUT_MS)
|
| 722 |
+
page.wait_for_function(FIRST_FRAME, timeout=FIRST_FRAME_TIMEOUT_MS)
|
| 723 |
+
page.wait_for_function(HOST_BOUND, timeout=BOOT_TIMEOUT_MS)
|
| 724 |
+
assert page.evaluate("() => window.Avatar.__debug.transport") == transport
|
| 725 |
+
|
| 726 |
+
# D-01: the first visit is annotated, with nothing touched.
|
| 727 |
+
assert page.input_value("#furigana-mode") == "always"
|
| 728 |
+
assert page.input_value("#level-select") == "N5"
|
| 729 |
+
seeded = page.evaluate(FURIGANA)
|
| 730 |
+
print(f"[{transport}] furigana at bind: {seeded}")
|
| 731 |
+
assert seeded["mode"] == "always" and seeded["level"] == "N5"
|
| 732 |
+
assert seeded["storage"] == "ok", "localStorage was refused on a plain loopback page"
|
| 733 |
+
|
| 734 |
+
# A real typed turn through the host's own path, so the learner line is exercised.
|
| 735 |
+
ends_before = len(speech_events.named(page, "speech-end"))
|
| 736 |
+
submitted = time.monotonic()
|
| 737 |
+
page.fill("#text-input input", STUDY_TEXT)
|
| 738 |
+
page.press("#text-input input", "Enter")
|
| 739 |
+
wait_for_ruby(page, LEARNER_RUBY, LEARNER_RUBY_TIMEOUT_MS, "no ruby on the learner's line")
|
| 740 |
+
learner_ms = round((time.monotonic() - submitted) * 1000)
|
| 741 |
+
speech_events.wait_for(page, "speech-end", timeout_ms=TURN_TIMEOUT_MS, at_least=ends_before + 1)
|
| 742 |
+
wait_for_ruby(page, AVATAR_RUBY, TURN_TIMEOUT_MS, "the avatar's line never got ruby")
|
| 743 |
+
|
| 744 |
+
counts = page.evaluate(RT_COUNTS)
|
| 745 |
+
always = page.evaluate(FURIGANA)
|
| 746 |
+
tokens = page.evaluate(LAST_TOKENS)
|
| 747 |
+
analyze_ms = page.evaluate("async () => (await window.Avatar.getDebug()).lastAnalyzeMs")
|
| 748 |
+
print(
|
| 749 |
+
f"[{transport}] {STUDY_TEXT}: rt {counts}; learner ruby {learner_ms} ms after Enter "
|
| 750 |
+
f"(server analyze {analyze_ms} ms); furigana {always}"
|
| 751 |
+
)
|
| 752 |
+
assert counts["you"] == 2, "D-03: the learner's own line carries the same readings"
|
| 753 |
+
assert counts["avatar"] == 2
|
| 754 |
+
assert always["lastLineRt"] == 2
|
| 755 |
+
assert always["lastLineRtHeightPx"] > 0, "rt exists but rendered with no height"
|
| 756 |
+
assert always["mode"] == "always" and always["level"] == "N5" and always["storage"] == "ok"
|
| 757 |
+
assert always["rtTotal"] == counts["total"] == 4
|
| 758 |
+
assert (
|
| 759 |
+
page.locator("#transcript-text .turn-avatar rt").first.text_content() == STUDY_READING_FIRST
|
| 760 |
+
)
|
| 761 |
+
assert expected_rt(tokens, "always", "N5") == 2, tokens
|
| 762 |
+
|
| 763 |
+
never = _select(page, "furigana-mode", "never")
|
| 764 |
+
assert never["rtTotal"] == 0 and never["lastLineRt"] == 0
|
| 765 |
+
assert page.locator("#transcript-text rt").count() == 0
|
| 766 |
+
|
| 767 |
+
above_n5 = _select(page, "furigana-mode", "above")
|
| 768 |
+
want_n5 = expected_rt(tokens, "above", "N5")
|
| 769 |
+
assert above_n5["lastLineRt"] == want_n5, (above_n5, want_n5)
|
| 770 |
+
|
| 771 |
+
above_n2 = _select(page, "level-select", "N2")
|
| 772 |
+
want_n2 = expected_rt(tokens, "above", "N2")
|
| 773 |
+
assert above_n2["level"] == "N2"
|
| 774 |
+
assert above_n2["lastLineRt"] == want_n2, (above_n2, want_n2)
|
| 775 |
+
assert want_n2 <= want_n5
|
| 776 |
+
|
| 777 |
+
_select(page, "level-select", "N5")
|
| 778 |
+
back = _select(page, "furigana-mode", "always")
|
| 779 |
+
print(
|
| 780 |
+
f"[{transport}] modes: always rt={always['lastLineRt']} h={always['lastLineHeightPx']} "
|
| 781 |
+
f"rtH={always['lastLineRtHeightPx']}; never rt=0 h={never['lastLineHeightPx']}; "
|
| 782 |
+
f"above@N5 rt={above_n5['lastLineRt']} (oracle {want_n5}); above@N2 "
|
| 783 |
+
f"rt={above_n2['lastLineRt']} (oracle {want_n2}); back to always "
|
| 784 |
+
f"h={back['lastLineHeightPx']}"
|
| 785 |
+
)
|
| 786 |
+
assert back["lastLineRt"] == 2 and back["rtTotal"] == 4
|
| 787 |
+
assert back["lastLineHeightPx"] > never["lastLineHeightPx"], (
|
| 788 |
+
"the ruby line is not taller than the bare line: transcript.css did not reach the "
|
| 789 |
+
f"Gradio page (always {back['lastLineHeightPx']} px vs never "
|
| 790 |
+
f"{never['lastLineHeightPx']} px)"
|
| 791 |
+
)
|
|
@@ -18,6 +18,7 @@ and one compact-JMdict load per session.
|
|
| 18 |
|
| 19 |
from __future__ import annotations
|
| 20 |
|
|
|
|
| 21 |
import functools
|
| 22 |
import importlib.metadata
|
| 23 |
import json
|
|
@@ -214,6 +215,34 @@ def test_analyze_is_fast(analyzer):
|
|
| 214 |
assert mean_ms < 50, f"{mean_ms:.1f} ms per analysis (budget 50 ms; expected < 5 ms)"
|
| 215 |
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
def test_empty_text(analyzer):
|
| 218 |
"""Nothing in, nothing out; whitespace is a non-tappable unit with no level."""
|
| 219 |
assert analyzer("") == []
|
|
|
|
| 18 |
|
| 19 |
from __future__ import annotations
|
| 20 |
|
| 21 |
+
import concurrent.futures
|
| 22 |
import functools
|
| 23 |
import importlib.metadata
|
| 24 |
import json
|
|
|
|
| 215 |
assert mean_ms < 50, f"{mean_ms:.1f} ms per analysis (budget 50 ms; expected < 5 ms)"
|
| 216 |
|
| 217 |
|
| 218 |
+
def test_analyze_is_thread_safe(analyzer):
|
| 219 |
+
"""Plan 02-07: concurrent analyses all succeed and all agree. The Space serves them.
|
| 220 |
+
|
| 221 |
+
``sudachipy.Tokenizer`` is a PyO3 object that takes a mutable borrow for the duration of
|
| 222 |
+
a ``tokenize`` call, so ONE shared instance answers the first caller and raises
|
| 223 |
+
``RuntimeError: Already borrowed`` at every thread that arrives while it is busy - a lost
|
| 224 |
+
analysis, not a wrong one. Before ``nlp.tokenizer`` gave each thread its own tokenizer,
|
| 225 |
+
8 threads on this sentence lost 5 of 8 calls; the deployed symptom was a directive
|
| 226 |
+
carrying ``tokens: []`` and an avatar line rendered with no furigana at all.
|
| 227 |
+
|
| 228 |
+
It became reachable in 02-07 because the host page now analyses the learner's line while
|
| 229 |
+
the turn's own analyse stage runs, so a single typed turn issues two overlapping
|
| 230 |
+
requests - and a busy Space multiplies that by its visitors.
|
| 231 |
+
|
| 232 |
+
A real regression test: reverting tokenizer.get_tokenizer to one shared instance fails
|
| 233 |
+
this, and the failure is the exception, not a flaky count.
|
| 234 |
+
"""
|
| 235 |
+
workers = 8
|
| 236 |
+
rounds = 4
|
| 237 |
+
expected = analyzer(LONG_36_MORA)
|
| 238 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
|
| 239 |
+
futures = [pool.submit(analyzer, LONG_36_MORA) for _ in range(workers * rounds)]
|
| 240 |
+
results = [f.result() for f in futures]
|
| 241 |
+
assert len(results) == workers * rounds
|
| 242 |
+
for i, units in enumerate(results):
|
| 243 |
+
assert units == expected, f"thread {i} disagreed with the single-threaded analysis"
|
| 244 |
+
|
| 245 |
+
|
| 246 |
def test_empty_text(analyzer):
|
| 247 |
"""Nothing in, nothing out; whitespace is a non-tappable unit with no level."""
|
| 248 |
assert analyzer("") == []
|