Spaces:
Running on Zero
Running on Zero
test(02-06): language bridge parity under both transports
Browse files- test_language_bridge_under_both_transports, parametrised over inline and
iframe: analyze() returns the golden records for the avatar's and a learner's
line, translate() returns the contract sentence and stamps the debug slice,
languageInfo() reports the loaded assets and a finished warm-up, the turn
event carries the directive's tokens, and refused requests surface as
rejections carrying the server's words
- tests/e2e/test_facade_parity.py +147 -0
tests/e2e/test_facade_parity.py
CHANGED
|
@@ -502,3 +502,150 @@ def test_gesture_unlocks_audio_under_both_transports(
|
|
| 502 |
assert after["turnCount"] == 1
|
| 503 |
assert not after["speaking"] and not after["thinking"]
|
| 504 |
assert numbers["audio_duration"] and numbers["audio_duration"] > 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
assert after["turnCount"] == 1
|
| 503 |
assert not after["speaking"] and not after["thinking"]
|
| 504 |
assert numbers["audio_duration"] and numbers["audio_duration"] > 1.0
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
# ------------------------------------------------------------- the language bridge (02-06)
|
| 508 |
+
|
| 509 |
+
# Every reading here comes from the golden fixture (tests/fixtures/sentences.json): ι£γΉγΎγγ
|
| 510 |
+
# is ONE tappable unit with lemma ι£γΉγ; δ»ζ₯ reads γγγ (not γγγ«γ‘); the particle γ― is
|
| 511 |
+
# plain text (D-11). ι§
γ―γ©γγ§γγγ is the six-sentence MT contract's "station" line.
|
| 512 |
+
EAT = "ι£γΉγΎγγ"
|
| 513 |
+
WEATHER = "δ»ζ₯γ―γγ倩ζ°γ§γγγ"
|
| 514 |
+
STATION = "ι§
γ―γ©γγ§γγγ"
|
| 515 |
+
|
| 516 |
+
REJECTION = """
|
| 517 |
+
async ([name, arg]) => {
|
| 518 |
+
try {
|
| 519 |
+
await window.Avatar[name](arg);
|
| 520 |
+
return null;
|
| 521 |
+
} catch (err) {
|
| 522 |
+
return String((err && err.message) || err);
|
| 523 |
+
}
|
| 524 |
+
}
|
| 525 |
+
"""
|
| 526 |
+
|
| 527 |
+
LANGUAGE_DEBUG = """
|
| 528 |
+
async () => {
|
| 529 |
+
const d = await window.Avatar.getDebug();
|
| 530 |
+
return {
|
| 531 |
+
analyzeCount: d.analyzeCount, lastAnalyzeMs: d.lastAnalyzeMs,
|
| 532 |
+
translateCount: d.translateCount, lastTranslateMs: d.lastTranslateMs,
|
| 533 |
+
lastTranslation: d.lastTranslation, lastTranslateError: d.lastTranslateError,
|
| 534 |
+
lastTokenCount: d.lastTokenCount, languageInfoCached: d.languageInfo !== null,
|
| 535 |
+
transport: d.transport,
|
| 536 |
+
};
|
| 537 |
+
}
|
| 538 |
+
"""
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
def _unit(tokens: list[dict], surface: str) -> dict:
|
| 542 |
+
matches = [t for t in tokens if t["surface"] == surface]
|
| 543 |
+
assert len(matches) == 1, (
|
| 544 |
+
f"{surface!r} is not exactly one unit in {[t['surface'] for t in tokens]}"
|
| 545 |
+
)
|
| 546 |
+
return matches[0]
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
@pytest.mark.parametrize("transport", TRANSPORTS)
|
| 550 |
+
def test_language_bridge_under_both_transports(transport, page, gradio_apps, speech_events):
|
| 551 |
+
"""Plan 02-06: tokens, translation and asset facts cross the bridge identically under
|
| 552 |
+
inline and iframe, proven against live objects before a single DOM node is written.
|
| 553 |
+
|
| 554 |
+
Five things, each a number or an exact string: analyze() returns the golden records;
|
| 555 |
+
translate() returns the contract sentence and stamps the debug slice; languageInfo()
|
| 556 |
+
reports the loaded assets and a finished warm-up; the turn event carries the tokens the
|
| 557 |
+
directive carried; and a refused request surfaces as an ordinary rejection carrying the
|
| 558 |
+
server's own words, never as `undefined`.
|
| 559 |
+
"""
|
| 560 |
+
url = gradio_apps(transport)
|
| 561 |
+
speech_events.install(page)
|
| 562 |
+
page.goto(url)
|
| 563 |
+
page.wait_for_function(AVATAR_READY, timeout=BOOT_TIMEOUT_MS)
|
| 564 |
+
page.wait_for_function(FIRST_FRAME, timeout=FIRST_FRAME_TIMEOUT_MS)
|
| 565 |
+
assert page.evaluate("() => window.Avatar.__debug.transport") == transport
|
| 566 |
+
|
| 567 |
+
# 1. analyze - the avatar's line and a learner-style line.
|
| 568 |
+
eat = page.evaluate("(t) => window.Avatar.analyze(t)", EAT)
|
| 569 |
+
print(f"[{transport}] analyze({EAT!r}): {eat['timings']}")
|
| 570 |
+
assert len(eat["tokens"]) == 1, eat["tokens"]
|
| 571 |
+
token = eat["tokens"][0]
|
| 572 |
+
assert token["lemma"] == "ι£γΉγ" and token["tappable"] is True
|
| 573 |
+
assert token["gloss"], "the gloss rides inside the token (no lookup round trip)"
|
| 574 |
+
assert token["reading"] == "γγΉγΎγγ" and token["jlpt"] == "N5"
|
| 575 |
+
|
| 576 |
+
weather = page.evaluate("(t) => window.Avatar.analyze(t)", WEATHER)
|
| 577 |
+
surfaces = [t["surface"] for t in weather["tokens"]]
|
| 578 |
+
print(f"[{transport}] analyze({WEATHER!r}): {surfaces} in {weather['timings']}")
|
| 579 |
+
assert "".join(surfaces) == WEATHER, "the surfaces must tile the text"
|
| 580 |
+
kyou = _unit(weather["tokens"], "δ»ζ₯")
|
| 581 |
+
assert kyou["reading"] == "γγγ" and kyou["tappable"] is True
|
| 582 |
+
assert _unit(weather["tokens"], "γ―")["tappable"] is False
|
| 583 |
+
|
| 584 |
+
# 2. translate - the contract sentence, filed under a line id, timed on both sides.
|
| 585 |
+
translated = page.evaluate("([t, id]) => window.Avatar.translate(t, id)", [STATION, "L1"])
|
| 586 |
+
print(
|
| 587 |
+
f"[{transport}] translate({STATION!r}) -> {translated['text']!r} in "
|
| 588 |
+
f"{translated['ms']} ms (server {translated['timings']})"
|
| 589 |
+
)
|
| 590 |
+
assert "station" in translated["text"].lower(), translated["text"]
|
| 591 |
+
assert translated["lineId"] == "L1"
|
| 592 |
+
assert translated["ms"] > 0
|
| 593 |
+
assert translated["timings"]["translate_ms"] > 0
|
| 594 |
+
debug = page.evaluate(LANGUAGE_DEBUG)
|
| 595 |
+
print(f"[{transport}] language debug after translate: {debug}")
|
| 596 |
+
assert debug["transport"] == transport
|
| 597 |
+
assert debug["analyzeCount"] == 2 and debug["lastAnalyzeMs"] is not None
|
| 598 |
+
assert debug["translateCount"] == 1 and debug["lastTranslateMs"] > 0
|
| 599 |
+
assert debug["lastTranslation"] == translated["text"]
|
| 600 |
+
assert debug["lastTranslateError"] is None
|
| 601 |
+
|
| 602 |
+
# 3. languageInfo - the assets are loaded and the load warm-up ran to completion.
|
| 603 |
+
info = page.evaluate("() => window.Avatar.languageInfo()")
|
| 604 |
+
print(
|
| 605 |
+
f"[{transport}] languageInfo: jmdict_entries={info['jmdict_entries']} "
|
| 606 |
+
f"jmdict_bytes={info['jmdict_bytes']} tokenizer_dict_bytes={info['tokenizer_dict_bytes']} "
|
| 607 |
+
f"mt_model_bytes={info['mt_model_bytes']} rss_mb={info['rss_mb']} "
|
| 608 |
+
f"cpu_cores={info['cpu_cores']} memory={info['memory']} warm={info['warm']} "
|
| 609 |
+
f"pins={info['pins']}"
|
| 610 |
+
)
|
| 611 |
+
assert info["jmdict_entries"] == 218672
|
| 612 |
+
assert info["mt_model_bytes"] > 70_000_000
|
| 613 |
+
assert info["tokenizer_dict_bytes"] > 100_000_000
|
| 614 |
+
assert isinstance(info["warm"]["total_s"], int | float)
|
| 615 |
+
assert info["warm"]["expected_rss_delta_mb"] == 410
|
| 616 |
+
assert info["warm"]["errors"] == []
|
| 617 |
+
assert page.evaluate(LANGUAGE_DEBUG)["languageInfoCached"] is True
|
| 618 |
+
|
| 619 |
+
# 4. the turn event carries the directive's tokens. Synthesis is slow under SwiftShader,
|
| 620 |
+
# so the promise is parked on the window and the event is waited for with the turn budget.
|
| 621 |
+
page.evaluate(
|
| 622 |
+
"(t) => { window.__turn = window.Avatar.dispatchTurn(t)"
|
| 623 |
+
".catch((e) => ({ error: String((e && e.message) || e) })); }",
|
| 624 |
+
EAT,
|
| 625 |
+
)
|
| 626 |
+
turn_events = speech_events.wait_for(page, "turn", timeout_ms=TURN_TIMEOUT_MS)
|
| 627 |
+
outcome = page.evaluate("() => window.__turn")
|
| 628 |
+
if outcome and outcome.get("error"):
|
| 629 |
+
pytest.importorskip("voicevox_core")
|
| 630 |
+
pytest.fail(f"{transport}: dispatchTurn rejected: {outcome['error']}")
|
| 631 |
+
event_tokens = turn_events[-1]["data"]["tokens"]
|
| 632 |
+
print(
|
| 633 |
+
f"[{transport}] turn event tokens: {[t['surface'] for t in event_tokens]}; "
|
| 634 |
+
f"timings {turn_events[-1]['data']['timings']}"
|
| 635 |
+
)
|
| 636 |
+
assert len(event_tokens) == 1 and event_tokens[0]["lemma"] == "ι£γΉγ"
|
| 637 |
+
assert turn_events[-1]["data"]["timings"]["analyze_ms"] >= 0
|
| 638 |
+
assert page.evaluate(LANGUAGE_DEBUG)["lastTokenCount"] == 1
|
| 639 |
+
_wait_settled(page)
|
| 640 |
+
|
| 641 |
+
# 5. the error contract: the server's {error} arrives as a rejection with its words.
|
| 642 |
+
empty = page.evaluate(REJECTION, ["analyze", ""])
|
| 643 |
+
too_long = page.evaluate(REJECTION, ["translate", "γ" * 201])
|
| 644 |
+
print(
|
| 645 |
+
f"[{transport}] rejections: analyze('') -> {empty!r}; translate(201 chars) -> {too_long!r}"
|
| 646 |
+
)
|
| 647 |
+
assert empty is not None and "text is required" in empty
|
| 648 |
+
assert too_long is not None and "characters" in too_long
|
| 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"]
|