WolfDavid commited on
Commit
ac630fa
·
1 Parent(s): 16f28bd

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 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 = {"turn_id", "audio_url", "timeline", "subtitle", "expression", "speed", "timings"}
 
 
 
 
 
 
 
 
 
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 ``blocks.turn`` is walked to show the probe is
13
- unreachable from it.
 
 
 
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 _reachable_from_turn() -> tuple[set[str], dict[str, ast.FunctionDef]]:
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. Over-approximating
 
 
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 "turn" in functions, "blocks.turn is not defined"
110
 
111
  reachable: set[str] = set()
112
- frontier = ["turn"]
113
  while frontier:
114
  name = frontier.pop()
115
  if name in reachable:
116
  continue
117
  reachable.add(name)
118
- for called in _called_names(functions[name]):
119
- if called in functions and called not in reachable:
120
- frontier.append(called)
 
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