WolfDavid commited on
Commit
f743d45
Β·
1 Parent(s): 969caf4

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

Files changed (1) hide show
  1. 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"]