Spaces:
Running on Zero
Running on Zero
test(02-06): add failing server-function and directive tests
Browse files- tests/test_server_functions.py: payload shapes, {error} paths, language_info
shape (mt and quick-loop halves), lock-serialised first warm-up, tokens on the
directive, every server function waits for warm_language()
- tests/test_directive.py: DIRECTIVE_KEYS gains tokens
- tests/test_no_gpu_on_turn_path.py: transformers banned; the reachability walk
starts from every server function and merges same-named definitions
- tests/test_directive.py +10 -1
- tests/test_no_gpu_on_turn_path.py +38 -12
- tests/test_server_functions.py +314 -0
tests/test_directive.py
CHANGED
|
@@ -29,7 +29,16 @@ from japanese_avatar.voice.visemes import FRAMERATE, to_frame # noqa: E402
|
|
| 29 |
SRC = Path(__file__).resolve().parents[1] / "src" / "japanese_avatar"
|
| 30 |
|
| 31 |
SHORT_TEXT = "こんにちは"
|
| 32 |
-
DIRECTIVE_KEYS = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
STAGE_KEYS = {"audio_query_ms", "synthesis_ms", "timeline_ms", "encode_ms", "server_total_ms"}
|
| 34 |
ONE_FRAME = 1 / FRAMERATE
|
| 35 |
DATA_URL_PREFIX = "data:audio/wav;base64,"
|
|
|
|
| 29 |
SRC = Path(__file__).resolve().parents[1] / "src" / "japanese_avatar"
|
| 30 |
|
| 31 |
SHORT_TEXT = "こんにちは"
|
| 32 |
+
DIRECTIVE_KEYS = {
|
| 33 |
+
"turn_id",
|
| 34 |
+
"audio_url",
|
| 35 |
+
"timeline",
|
| 36 |
+
"subtitle",
|
| 37 |
+
"expression",
|
| 38 |
+
"speed",
|
| 39 |
+
"timings",
|
| 40 |
+
"tokens", # plan 02-06: the analyzer's records for `subtitle`; [] when analysis failed
|
| 41 |
+
}
|
| 42 |
STAGE_KEYS = {"audio_query_ms", "synthesis_ms", "timeline_ms", "encode_ms", "server_total_ms"}
|
| 43 |
ONE_FRAME = 1 / FRAMERATE
|
| 44 |
DATA_URL_PREFIX = "data:audio/wav;base64,"
|
tests/test_no_gpu_on_turn_path.py
CHANGED
|
@@ -9,8 +9,11 @@ negative is the exact thing SC-4 exists to prevent. So: any decorator whose dott
|
|
| 9 |
``GPU`` anywhere under ``src/japanese_avatar/`` fails, full stop, and ``app.py`` may carry exactly
|
| 10 |
one such function - the ZeroGPU startup probe - only because the platform refuses to run a Space
|
| 11 |
without one (docs/HOSTING.md, first deploy record). That carve-out is spelled out by name rather
|
| 12 |
-
than by loosening the rule, and the call graph from ``
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
|
@@ -32,9 +35,12 @@ APP = REPO_ROOT / "app.py"
|
|
| 32 |
#: path may call it, import it, or be it.
|
| 33 |
GPU_PROBE_NAME = "zerogpu_probe"
|
| 34 |
|
| 35 |
-
BANNED_IMPORT_ROOTS = {"torch", "spaces"}
|
| 36 |
BANNED_IMPORT_PATTERN = re.compile(r"^cuda")
|
| 37 |
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
def _turn_path_modules() -> list[Path]:
|
| 40 |
modules = sorted(SRC.rglob("*.py"))
|
|
@@ -94,33 +100,40 @@ def _called_names(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
|
|
| 94 |
return names
|
| 95 |
|
| 96 |
|
| 97 |
-
def
|
| 98 |
"""Conservative call graph over every function defined in the package plus app.py.
|
| 99 |
|
| 100 |
Names are matched by simple identifier, ignoring module boundaries, so an attribute call
|
| 101 |
-
`tts.synthesize(...)` reaches every function called `synthesize` anywhere
|
|
|
|
|
|
|
| 102 |
reachability is the safe direction for this guard.
|
| 103 |
"""
|
| 104 |
-
functions: dict[str, ast.FunctionDef] = {}
|
| 105 |
for path in [*_turn_path_modules(), APP]:
|
| 106 |
for node in ast.walk(_parse(path)):
|
| 107 |
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
| 108 |
-
functions.setdefault(node.name, node)
|
| 109 |
-
assert
|
| 110 |
|
| 111 |
reachable: set[str] = set()
|
| 112 |
-
frontier = [
|
| 113 |
while frontier:
|
| 114 |
name = frontier.pop()
|
| 115 |
if name in reachable:
|
| 116 |
continue
|
| 117 |
reachable.add(name)
|
| 118 |
-
for
|
| 119 |
-
|
| 120 |
-
|
|
|
|
| 121 |
return reachable, functions
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
def test_no_spaces_gpu_decorator_anywhere_on_turn_path():
|
| 125 |
for path in _turn_path_modules():
|
| 126 |
hits = _gpu_decorated(_parse(path))
|
|
@@ -139,6 +152,19 @@ def test_no_spaces_gpu_decorator_anywhere_on_turn_path():
|
|
| 139 |
assert "turn" in reachable and "synthesize" in reachable, sorted(reachable)
|
| 140 |
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
def test_no_gpu_imports_on_turn_path():
|
| 143 |
for path in _turn_path_modules():
|
| 144 |
roots = _imports(_parse(path))
|
|
|
|
| 9 |
``GPU`` anywhere under ``src/japanese_avatar/`` fails, full stop, and ``app.py`` may carry exactly
|
| 10 |
one such function - the ZeroGPU startup probe - only because the platform refuses to run a Space
|
| 11 |
without one (docs/HOSTING.md, first deploy record). That carve-out is spelled out by name rather
|
| 12 |
+
than by loosening the rule, and the call graph from every server function - ``turn`` and, since
|
| 13 |
+
plan 02-06, ``analyze`` / ``translate`` / ``language_info`` - is walked to show the probe is
|
| 14 |
+
unreachable from each. ``transformers`` joined the banned imports in 02-06: the translation path
|
| 15 |
+
is CTranslate2 + sentencepiece by construction (02-04), and a stray ``transformers`` import would
|
| 16 |
+
pull torch onto the turn path in one line.
|
| 17 |
"""
|
| 18 |
|
| 19 |
from __future__ import annotations
|
|
|
|
| 35 |
#: path may call it, import it, or be it.
|
| 36 |
GPU_PROBE_NAME = "zerogpu_probe"
|
| 37 |
|
| 38 |
+
BANNED_IMPORT_ROOTS = {"torch", "spaces", "transformers"}
|
| 39 |
BANNED_IMPORT_PATTERN = re.compile(r"^cuda")
|
| 40 |
|
| 41 |
+
#: Every gr.HTML server function registered in blocks.py. Each is a root of the reachability walk.
|
| 42 |
+
SERVER_ENTRY_POINTS = ["turn", "analyze", "translate", "language_info"]
|
| 43 |
+
|
| 44 |
|
| 45 |
def _turn_path_modules() -> list[Path]:
|
| 46 |
modules = sorted(SRC.rglob("*.py"))
|
|
|
|
| 100 |
return names
|
| 101 |
|
| 102 |
|
| 103 |
+
def _reachable_from(entry: str) -> tuple[set[str], dict[str, list[ast.FunctionDef]]]:
|
| 104 |
"""Conservative call graph over every function defined in the package plus app.py.
|
| 105 |
|
| 106 |
Names are matched by simple identifier, ignoring module boundaries, so an attribute call
|
| 107 |
+
`tts.synthesize(...)` reaches every function called `synthesize` anywhere - and a name
|
| 108 |
+
defined in several modules (``analyze`` is both ``nlp.analyzer.analyze`` and the server
|
| 109 |
+
function ``ui.blocks.analyze``) contributes the calls of EVERY definition. Over-approximating
|
| 110 |
reachability is the safe direction for this guard.
|
| 111 |
"""
|
| 112 |
+
functions: dict[str, list[ast.FunctionDef]] = {}
|
| 113 |
for path in [*_turn_path_modules(), APP]:
|
| 114 |
for node in ast.walk(_parse(path)):
|
| 115 |
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
| 116 |
+
functions.setdefault(node.name, []).append(node)
|
| 117 |
+
assert entry in functions, f"blocks.{entry} is not defined"
|
| 118 |
|
| 119 |
reachable: set[str] = set()
|
| 120 |
+
frontier = [entry]
|
| 121 |
while frontier:
|
| 122 |
name = frontier.pop()
|
| 123 |
if name in reachable:
|
| 124 |
continue
|
| 125 |
reachable.add(name)
|
| 126 |
+
for definition in functions[name]:
|
| 127 |
+
for called in _called_names(definition):
|
| 128 |
+
if called in functions and called not in reachable:
|
| 129 |
+
frontier.append(called)
|
| 130 |
return reachable, functions
|
| 131 |
|
| 132 |
|
| 133 |
+
def _reachable_from_turn() -> tuple[set[str], dict[str, list[ast.FunctionDef]]]:
|
| 134 |
+
return _reachable_from("turn")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
def test_no_spaces_gpu_decorator_anywhere_on_turn_path():
|
| 138 |
for path in _turn_path_modules():
|
| 139 |
hits = _gpu_decorated(_parse(path))
|
|
|
|
| 152 |
assert "turn" in reachable and "synthesize" in reachable, sorted(reachable)
|
| 153 |
|
| 154 |
|
| 155 |
+
@pytest.mark.parametrize("entry", SERVER_ENTRY_POINTS)
|
| 156 |
+
def test_gpu_probe_unreachable_from_every_server_function(entry):
|
| 157 |
+
"""Plan 02-06: the language server functions are turn-path code too (SC-4 carried forward)."""
|
| 158 |
+
reachable, functions = _reachable_from(entry)
|
| 159 |
+
assert GPU_PROBE_NAME not in reachable, (
|
| 160 |
+
f"{GPU_PROBE_NAME} is reachable from blocks.{entry}: {sorted(reachable)}"
|
| 161 |
+
)
|
| 162 |
+
assert entry in reachable
|
| 163 |
+
# Every server function waits for the language warm-up, so the walk must see it.
|
| 164 |
+
assert "warm_language" in reachable, f"blocks.{entry} does not call warm_language()"
|
| 165 |
+
assert "warm_language" in functions
|
| 166 |
+
|
| 167 |
+
|
| 168 |
def test_no_gpu_imports_on_turn_path():
|
| 169 |
for path in _turn_path_modules():
|
| 170 |
roots = _imports(_parse(path))
|
tests/test_server_functions.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The three language server functions behind the gr.HTML bridge, and the tokens on the directive.
|
| 2 |
+
|
| 3 |
+
Plan 02-06. The bridge rules from 01-08 hold for every function here: ONE positional payload
|
| 4 |
+
(a dict from JS, a list for several arguments, ``[]`` for none), and ``{"error": ...}`` on every
|
| 5 |
+
failure because a raise becomes ``undefined`` in the browser. Two rules are this plan's own:
|
| 6 |
+
|
| 7 |
+
* ``analyze``, ``translate``, ``language_info`` and the analyze stage of ``turn`` all call
|
| 8 |
+
``warm_language()`` first, so a request that lands while ``Blocks.load`` is still filling the
|
| 9 |
+
caches waits on the lock instead of racing ``lru_cache`` misses into a second JMdict load.
|
| 10 |
+
* ``language_info`` never reports a half-filled cache, for the same reason.
|
| 11 |
+
|
| 12 |
+
Quick-loop hygiene: the real ``warm_language()`` loads the translator (+100 MB), so the tests
|
| 13 |
+
that only need the analyzer run with ``blocks.warm_language`` redirected to a dictionary-only
|
| 14 |
+
stub (``dictionary_only_warm``) and the ones that exercise the real warm-up carry the ``mt``
|
| 15 |
+
marker. ``test_server_functions_wait_for_warm_language`` is what proves the real one is called.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import sys
|
| 21 |
+
import threading
|
| 22 |
+
import time
|
| 23 |
+
from types import SimpleNamespace
|
| 24 |
+
|
| 25 |
+
import pytest
|
| 26 |
+
|
| 27 |
+
from japanese_avatar.nlp import warm
|
| 28 |
+
from japanese_avatar.ui import blocks
|
| 29 |
+
from japanese_avatar.ui.blocks import MAX_TEXT_CHARS, analyze, language_info, translate, turn
|
| 30 |
+
|
| 31 |
+
EAT = "食べました"
|
| 32 |
+
STATION = "駅はどこですか。"
|
| 33 |
+
LANGUAGE_INFO_KEYS = {
|
| 34 |
+
"tokenizer_dict_bytes",
|
| 35 |
+
"jmdict_entries",
|
| 36 |
+
"jmdict_bytes",
|
| 37 |
+
"mt_model_bytes",
|
| 38 |
+
"warm",
|
| 39 |
+
"rss_mb",
|
| 40 |
+
"cpu_cores",
|
| 41 |
+
"memory",
|
| 42 |
+
"pins",
|
| 43 |
+
}
|
| 44 |
+
WARM_KEYS = {
|
| 45 |
+
"tokenizer_s",
|
| 46 |
+
"jmdict_s",
|
| 47 |
+
"translator_s",
|
| 48 |
+
"total_s",
|
| 49 |
+
"rss_before_mb",
|
| 50 |
+
"rss_mb",
|
| 51 |
+
"rss_delta_mb",
|
| 52 |
+
"expected_rss_delta_mb",
|
| 53 |
+
"cpu_cores",
|
| 54 |
+
"memory",
|
| 55 |
+
}
|
| 56 |
+
JMDICT_ENTRIES = 218672
|
| 57 |
+
BAD_PAYLOADS = ["", " ", None, [], {"text": "あ" * (MAX_TEXT_CHARS + 1)}]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@pytest.fixture
|
| 61 |
+
def dictionary_only_warm(monkeypatch, analyzer):
|
| 62 |
+
"""Redirect ``blocks.warm_language`` to a stub so the quick loop never loads the translator.
|
| 63 |
+
|
| 64 |
+
The session ``analyzer`` fixture has already built the tokenizer and the compact JMdict, so
|
| 65 |
+
the analysis path is real; only the translator load is skipped. Returns the call log.
|
| 66 |
+
"""
|
| 67 |
+
calls: list[float] = []
|
| 68 |
+
|
| 69 |
+
def stub() -> dict:
|
| 70 |
+
calls.append(time.perf_counter())
|
| 71 |
+
return {"total_s": 0.0, "stub": True}
|
| 72 |
+
|
| 73 |
+
monkeypatch.setattr(blocks, "warm_language", stub)
|
| 74 |
+
return calls
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _one_tappable(tokens: list[dict]) -> dict:
|
| 78 |
+
tappable = [t for t in tokens if t["tappable"]]
|
| 79 |
+
assert len(tappable) == 1, tokens
|
| 80 |
+
return tappable[0]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# --------------------------------------------------------------------------------- analyze
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_analyze_accepts_the_bridge_payload_shapes(dictionary_only_warm):
|
| 87 |
+
for payload in ({"text": EAT}, [EAT], EAT):
|
| 88 |
+
out = analyze(payload)
|
| 89 |
+
assert set(out) == {"tokens", "timings"}, out
|
| 90 |
+
assert len(out["tokens"]) == 1
|
| 91 |
+
assert _one_tappable(out["tokens"])["lemma"] == "食べる"
|
| 92 |
+
assert {"analyze_ms", "server_total_ms"} <= set(out["timings"])
|
| 93 |
+
assert out["timings"]["analyze_ms"] >= 0
|
| 94 |
+
assert len(dictionary_only_warm) == 3, "analyze must wait for warm_language() on every call"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_analyze_rejects_bad_input_with_a_structured_error(dictionary_only_warm):
|
| 98 |
+
for payload in BAD_PAYLOADS:
|
| 99 |
+
out = analyze(payload)
|
| 100 |
+
assert "error" in out and "tokens" not in out, (payload, out)
|
| 101 |
+
assert "characters" in analyze({"text": "あ" * (MAX_TEXT_CHARS + 1)})["error"]
|
| 102 |
+
assert dictionary_only_warm == [], "a rejected payload must not touch the warm-up"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ------------------------------------------------------------------------------- translate
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_translate_rejects_bad_input_with_a_structured_error():
|
| 109 |
+
loaded_before = "ctranslate2" in sys.modules
|
| 110 |
+
for payload in BAD_PAYLOADS:
|
| 111 |
+
out = translate(payload)
|
| 112 |
+
assert "error" in out and "text" not in out, (payload, out)
|
| 113 |
+
assert "characters" in translate({"text": "あ" * (MAX_TEXT_CHARS + 1)})["error"]
|
| 114 |
+
if not loaded_before:
|
| 115 |
+
assert "ctranslate2" not in sys.modules, "rejecting bad input must not load the model"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@pytest.mark.mt
|
| 119 |
+
def test_translate_happy_path():
|
| 120 |
+
out = translate({"text": STATION, "line_id": "L3"})
|
| 121 |
+
assert set(out) == {"text", "line_id", "timings"}, out
|
| 122 |
+
assert "station" in out["text"].lower(), out["text"]
|
| 123 |
+
assert out["line_id"] == "L3"
|
| 124 |
+
assert out["timings"]["translate_ms"] > 0
|
| 125 |
+
as_list = translate([STATION, "L4"])
|
| 126 |
+
assert as_list["line_id"] == "L4" and "station" in as_list["text"].lower()
|
| 127 |
+
plain = translate(STATION)
|
| 128 |
+
assert plain["line_id"] is None and "station" in plain["text"].lower()
|
| 129 |
+
print(f"\ntranslate {STATION!r} -> {out['text']!r} in {out['timings']['translate_ms']:.1f} ms")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# --------------------------------------------------------------------------- language_info
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@pytest.mark.mt
|
| 136 |
+
def test_language_info_shape():
|
| 137 |
+
for payload in ([], None):
|
| 138 |
+
info = language_info(payload) if payload is not None else language_info()
|
| 139 |
+
assert set(info) == LANGUAGE_INFO_KEYS, sorted(info)
|
| 140 |
+
assert info["jmdict_entries"] == JMDICT_ENTRIES
|
| 141 |
+
assert info["mt_model_bytes"] > 70_000_000
|
| 142 |
+
assert info["tokenizer_dict_bytes"] > 100_000_000, "system.dic is ~217 MB"
|
| 143 |
+
assert info["jmdict_bytes"] > 1_000_000
|
| 144 |
+
assert info["pins"]["sudachidict_core"] == "20260723"
|
| 145 |
+
assert set(info["pins"]) == {
|
| 146 |
+
"sudachipy",
|
| 147 |
+
"sudachidict_core",
|
| 148 |
+
"jmdict",
|
| 149 |
+
"jlpt_vocab",
|
| 150 |
+
"opus_mt_revision",
|
| 151 |
+
}
|
| 152 |
+
assert isinstance(info["warm"]["total_s"], int | float)
|
| 153 |
+
assert info["warm"]["expected_rss_delta_mb"] == 410
|
| 154 |
+
print(f"\nlanguage_info: {info}")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_language_info_shape_without_warm(monkeypatch, analyzer):
|
| 158 |
+
"""The quick-loop half: same exact key set, the warm dict is whatever warm_language returns."""
|
| 159 |
+
loaded_before = "ctranslate2" in sys.modules
|
| 160 |
+
stub = {"total_s": 0.0, "stub": True}
|
| 161 |
+
monkeypatch.setattr(blocks, "warm_language", lambda: stub)
|
| 162 |
+
info = language_info([])
|
| 163 |
+
assert set(info) == LANGUAGE_INFO_KEYS, sorted(info)
|
| 164 |
+
assert info["warm"] is stub
|
| 165 |
+
assert info["jmdict_entries"] == JMDICT_ENTRIES
|
| 166 |
+
assert info["mt_model_bytes"] > 70_000_000, "a byte count from disk, not a model load"
|
| 167 |
+
assert info["tokenizer_dict_bytes"] > 100_000_000
|
| 168 |
+
assert info["pins"]["sudachidict_core"] == "20260723"
|
| 169 |
+
assert info["pins"]["jlpt_vocab"] == "2025.08.01.0"
|
| 170 |
+
assert len(info["pins"]["opus_mt_revision"]) == 40
|
| 171 |
+
if not loaded_before:
|
| 172 |
+
assert "ctranslate2" not in sys.modules
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_language_info_never_raises(monkeypatch):
|
| 176 |
+
def boom() -> dict:
|
| 177 |
+
raise RuntimeError("warm-up exploded")
|
| 178 |
+
|
| 179 |
+
monkeypatch.setattr(blocks, "warm_language", boom)
|
| 180 |
+
out = language_info([])
|
| 181 |
+
assert set(out) == {"error"} and "warm-up exploded" in out["error"]
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ------------------------------------------------------------------------------- warm-up
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@pytest.mark.mt
|
| 188 |
+
def test_warm_language_is_idempotent_and_measured():
|
| 189 |
+
first = warm.warm_language()
|
| 190 |
+
assert set(first) >= WARM_KEYS, sorted(first)
|
| 191 |
+
assert first["expected_rss_delta_mb"] == 410
|
| 192 |
+
assert isinstance(first["total_s"], int | float)
|
| 193 |
+
assert warm.warm_language() is first, "the second call must return the same object"
|
| 194 |
+
if first["rss_mb"] is None:
|
| 195 |
+
print("\nwarm_language: rss unreadable on this machine (no /proc, no psutil)")
|
| 196 |
+
else:
|
| 197 |
+
print(
|
| 198 |
+
f"\nwarm_language: rss {first['rss_before_mb']:.0f} -> {first['rss_mb']:.0f} MB, "
|
| 199 |
+
f"delta {first['rss_delta_mb']:.0f} MB (research expected +~410 = 62 mmap + "
|
| 200 |
+
f"241 JMdict + 103 CT2); tokenizer {first['tokenizer_s']:.3f}s "
|
| 201 |
+
f"jmdict {first['jmdict_s']:.3f}s translator {first['translator_s']:.3f}s"
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def test_warm_language_first_call_is_serialised(monkeypatch):
|
| 206 |
+
"""Two concurrent FIRST callers: each component warms exactly once (the lock, not lru_cache)."""
|
| 207 |
+
counts = {"tokenizer": 0, "jmdict": 0, "translator": 0}
|
| 208 |
+
tally_lock = threading.Lock()
|
| 209 |
+
|
| 210 |
+
def slow(name: str):
|
| 211 |
+
def component() -> float:
|
| 212 |
+
with tally_lock:
|
| 213 |
+
counts[name] += 1
|
| 214 |
+
time.sleep(0.05)
|
| 215 |
+
return 0.05
|
| 216 |
+
|
| 217 |
+
return component
|
| 218 |
+
|
| 219 |
+
monkeypatch.setattr(warm, "_warm_tokenizer", slow("tokenizer"))
|
| 220 |
+
monkeypatch.setattr(warm, "_warm_jmdict", slow("jmdict"))
|
| 221 |
+
monkeypatch.setattr(warm, "_warm_translator", slow("translator"))
|
| 222 |
+
warm._warm_once.cache_clear()
|
| 223 |
+
try:
|
| 224 |
+
results: list[dict] = []
|
| 225 |
+
threads = [
|
| 226 |
+
threading.Thread(target=lambda: results.append(warm.warm_language())) for _ in range(2)
|
| 227 |
+
]
|
| 228 |
+
for t in threads:
|
| 229 |
+
t.start()
|
| 230 |
+
for t in threads:
|
| 231 |
+
t.join(timeout=10)
|
| 232 |
+
assert len(results) == 2
|
| 233 |
+
assert results[0] is results[1]
|
| 234 |
+
assert counts == {"tokenizer": 1, "jmdict": 1, "translator": 1}, counts
|
| 235 |
+
assert results[0]["errors"] == []
|
| 236 |
+
finally:
|
| 237 |
+
warm._warm_once.cache_clear() # never leave the stub result where language_info reads it
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def test_warm_language_survives_a_failing_component(monkeypatch):
|
| 241 |
+
"""warm_synthesizer's rule: start-up must not take the page down."""
|
| 242 |
+
|
| 243 |
+
def broken() -> float:
|
| 244 |
+
raise RuntimeError("no model here")
|
| 245 |
+
|
| 246 |
+
monkeypatch.setattr(warm, "_warm_tokenizer", lambda: 0.0)
|
| 247 |
+
monkeypatch.setattr(warm, "_warm_jmdict", lambda: 0.0)
|
| 248 |
+
monkeypatch.setattr(warm, "_warm_translator", broken)
|
| 249 |
+
warm._warm_once.cache_clear()
|
| 250 |
+
try:
|
| 251 |
+
out = warm.warm_language()
|
| 252 |
+
assert out["translator_s"] is None
|
| 253 |
+
assert out["errors"] == ["translator: no model here"]
|
| 254 |
+
assert isinstance(out["total_s"], float)
|
| 255 |
+
finally:
|
| 256 |
+
warm._warm_once.cache_clear()
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def test_rss_mb_is_a_number_or_none():
|
| 260 |
+
value = warm.rss_mb()
|
| 261 |
+
assert value is None or (isinstance(value, float) and value > 10.0), value
|
| 262 |
+
print(f"\nrss_mb() = {value}")
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# ------------------------------------------------------------------- tokens on the directive
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def test_turn_directive_carries_tokens(dictionary_only_warm):
|
| 269 |
+
pytest.importorskip("voicevox_core")
|
| 270 |
+
directive = turn(EAT)
|
| 271 |
+
assert "error" not in directive, directive
|
| 272 |
+
assert len(directive["tokens"]) == 1
|
| 273 |
+
assert _one_tappable(directive["tokens"])["lemma"] == "食べる"
|
| 274 |
+
assert directive["timings"]["analyze_ms"] >= 0
|
| 275 |
+
assert len(dictionary_only_warm) == 1
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def test_turn_speaks_when_analysis_fails(monkeypatch, dictionary_only_warm):
|
| 279 |
+
pytest.importorskip("voicevox_core")
|
| 280 |
+
|
| 281 |
+
def broken(_text: str) -> list[dict]:
|
| 282 |
+
raise RuntimeError("analyzer down")
|
| 283 |
+
|
| 284 |
+
monkeypatch.setattr(blocks.nlp_analyzer, "analyze", broken)
|
| 285 |
+
directive = turn(EAT)
|
| 286 |
+
assert "error" not in directive, directive
|
| 287 |
+
assert directive["tokens"] == []
|
| 288 |
+
assert directive["audio_url"].startswith("data:audio/wav;base64,")
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def test_server_functions_wait_for_warm_language(monkeypatch, analyzer):
|
| 292 |
+
"""analyze, translate and turn each call warm_language() before touching a singleton."""
|
| 293 |
+
counter = {"n": 0}
|
| 294 |
+
|
| 295 |
+
def counting() -> dict:
|
| 296 |
+
counter["n"] += 1
|
| 297 |
+
return {"total_s": 0.0, "stub": True}
|
| 298 |
+
|
| 299 |
+
monkeypatch.setattr(blocks, "warm_language", counting)
|
| 300 |
+
monkeypatch.setattr(blocks.nlp_translate, "translate", lambda text: "Hello.")
|
| 301 |
+
monkeypatch.setattr(
|
| 302 |
+
blocks,
|
| 303 |
+
"synthesize",
|
| 304 |
+
lambda text, speed, timings: SimpleNamespace(wav_bytes=b"RIFF", audio_query={}),
|
| 305 |
+
)
|
| 306 |
+
monkeypatch.setattr(blocks, "build_timeline", lambda query: [])
|
| 307 |
+
monkeypatch.setattr(blocks, "timeline_to_dicts", lambda timeline: [])
|
| 308 |
+
|
| 309 |
+
assert "tokens" in analyze({"text": "こんにちは"})
|
| 310 |
+
assert translate({"text": "こんにちは"})["text"] == "Hello."
|
| 311 |
+
directive = turn({"text": "こんにちは"})
|
| 312 |
+
assert "error" not in directive, directive
|
| 313 |
+
assert directive["tokens"] and directive["tokens"][0]["surface"] == "こんにちは"
|
| 314 |
+
assert counter["n"] == 3, counter
|