WolfDavid commited on
Commit
69dc99a
·
1 Parent(s): 03d5ccf

feat(02-09): inline EN reveal with per-line cache, loading and error states, at three layers

Browse files

- transcript.js: an EN control and a hidden .en div on EVERY line (D-18), the
idle/loading/shown/collapsed/error state machine in setTranslation(), the
onTranslateRequest hook, and debug.translation {requests, cacheHits, lastMs,
lastState, lastLineId, lastError, states}
- host.js: the decision and the money - a per-line session Map (D-17) in front of the
single avatar.translate() call, collapse on a second tap, cache hit on a third, and
a {error} rendered under the line with the control left usable
- transcript.css: the control as an inline chip that outranks the theme's button rules,
and .en[hidden] so a framework display:block cannot make "never shown unrequested" false
- transcript-harness.html: a fake 60 ms translator, __translationDebug, __failTranslation
- seam: test_translation_cache_lives_in_the_host (one call site, the Map in the host, no
translate() and no third Map in the renderer); .analyze( and .translate( added to the
host's required call list
- standalone: test_en_control_state_machine, the sequence recorded by a MutationObserver
- both transports: test_translation_reveal_under_both_transports - a cached re-show with
the request counter recording []
- deployed: test_translation_reveal, plus the over-long {error} contract

avatar/host.js CHANGED
@@ -29,6 +29,14 @@
29
  // the renderer's bindPointer once and publishes transcript.debug.popover on __debug. The
30
  // lookup itself is a pure client action: the glosses ride inside the tokens the line
31
  // already holds, so a tap issues no request under either transport.
 
 
 
 
 
 
 
 
32
 
33
  /** Matches REARM_TAIL_MS in mic.js: the controls re-enable when the mic may re-arm. */
34
  const REENABLE_AFTER_SPEECH_MS = 200;
@@ -68,6 +76,17 @@ const POPOVER_DEBUG_SEED = {
68
  closeCount: 0,
69
  };
70
 
 
 
 
 
 
 
 
 
 
 
 
71
  /** Enter finishes a Japanese IME composition before it submits; this skips that Enter. */
72
  function isComposing(event) {
73
  return event.isComposing || event.keyCode === 229;
@@ -139,9 +158,11 @@ export function bindHost(avatar, doc = document, transcriptModule = null) {
139
  // merges the turn loop's and the stage's keys over the top and leaves this one intact.
140
  const furigana = transcript ? transcript.debug.furigana : { ...FURIGANA_DEBUG_SEED };
141
  const popover = transcript ? transcript.debug.popover : { ...POPOVER_DEBUG_SEED };
 
142
  if (avatar.__debug) {
143
  avatar.__debug.furigana = furigana;
144
  avatar.__debug.popover = popover;
 
145
  }
146
 
147
  let spoken = false; // whether anything has been said yet, for replay/slower
@@ -294,6 +315,46 @@ export function bindHost(avatar, doc = document, transcriptModule = null) {
294
  // Plan 02-08: the tap wiring for the lookup card lives in the renderer, so the page and
295
  // the standalone harness install exactly the same handlers. Called once, at bind.
296
  transcript.bindPointer();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  }
298
 
299
  // ------------------------------------------------------------------- avatar -> page
 
29
  // the renderer's bindPointer once and publishes transcript.debug.popover on __debug. The
30
  // lookup itself is a pure client action: the glosses ride inside the tokens the line
31
  // already holds, so a tap issues no request under either transport.
32
+ //
33
+ // Plan 02-09: the EN reveal is the other way round. The renderer owns the control and its
34
+ // states; THIS file owns the decision and the money - the per-line session cache (D-17) is
35
+ // a Map right here, and the facade's translate() is called from exactly one place in the
36
+ // whole codebase. A tap on a line that has already been translated therefore cannot reach the
37
+ // network by any path, which is the claim the deployed request counter checks. The cache
38
+ // is per page load and per line id: nothing is persisted (D-17 says session only), and a
39
+ // line id is never reused, so a stale entry is not a state this can reach.
40
 
41
  /** Matches REARM_TAIL_MS in mic.js: the controls re-enable when the mic may re-arm. */
42
  const REENABLE_AFTER_SPEECH_MS = 200;
 
76
  closeCount: 0,
77
  };
78
 
79
+ /** The EN reveal's numbers (plan 02-09), seeded for the same reason. */
80
+ const TRANSLATION_DEBUG_SEED = {
81
+ requests: 0,
82
+ cacheHits: 0,
83
+ lastMs: null,
84
+ lastState: 'idle',
85
+ lastLineId: null,
86
+ lastError: null,
87
+ states: {},
88
+ };
89
+
90
  /** Enter finishes a Japanese IME composition before it submits; this skips that Enter. */
91
  function isComposing(event) {
92
  return event.isComposing || event.keyCode === 229;
 
158
  // merges the turn loop's and the stage's keys over the top and leaves this one intact.
159
  const furigana = transcript ? transcript.debug.furigana : { ...FURIGANA_DEBUG_SEED };
160
  const popover = transcript ? transcript.debug.popover : { ...POPOVER_DEBUG_SEED };
161
+ const translation = transcript ? transcript.debug.translation : { ...TRANSLATION_DEBUG_SEED };
162
  if (avatar.__debug) {
163
  avatar.__debug.furigana = furigana;
164
  avatar.__debug.popover = popover;
165
+ avatar.__debug.translation = translation;
166
  }
167
 
168
  let spoken = false; // whether anything has been said yet, for replay/slower
 
315
  // Plan 02-08: the tap wiring for the lookup card lives in the renderer, so the page and
316
  // the standalone harness install exactly the same handlers. Called once, at bind.
317
  transcript.bindPointer();
318
+
319
+ // ------------------------------------------------------------- the EN reveal (02-09)
320
+ //
321
+ // D-16: never shown unrequested, collapsed again on a second tap. D-17: cached per
322
+ // line, in the browser, for the session only. The three branches below are the whole
323
+ // policy, and the ONLY one of them that can reach the network is the third.
324
+ //
325
+ // A failed translation is reported under the line rather than in the status strip: it
326
+ // belongs to the line it failed for, and it leaves the control enabled so the learner
327
+ // can simply tap again. There is no unlockAudio() here - no audio path starts on this
328
+ // button, and calling it would make every EN tap look like a gesture that wanted sound.
329
+ const translations = new Map(); // lineId -> { text, ms }
330
+ transcript.onTranslateRequest = (lineId, text, state) => {
331
+ if (state === 'shown') {
332
+ transcript.setTranslation(lineId, 'collapsed');
333
+ return;
334
+ }
335
+ const cached = translations.get(lineId);
336
+ if (cached) {
337
+ translation.cacheHits += 1;
338
+ transcript.setTranslation(lineId, 'shown', cached.text);
339
+ return;
340
+ }
341
+ translation.requests += 1;
342
+ transcript.setTranslation(lineId, 'loading');
343
+ // The ONE network call the whole reveal can make, kept on one line so the seam test
344
+ // and the plan's acceptance grep are looking at the CALL and not at a comment above it.
345
+ avatar.translate(text, lineId)
346
+ .then((result) => {
347
+ translations.set(lineId, { text: result.text, ms: result.ms });
348
+ translation.lastMs = result.ms;
349
+ translation.lastError = null;
350
+ transcript.setTranslation(lineId, 'shown', result.text);
351
+ })
352
+ .catch((err) => {
353
+ const message = String(err?.message ?? err);
354
+ translation.lastError = message;
355
+ transcript.setTranslation(lineId, 'error', `translation failed: ${message}`);
356
+ });
357
+ };
358
  }
359
 
360
  // ------------------------------------------------------------------- avatar -> page
avatar/transcript-harness.html CHANGED
@@ -70,8 +70,34 @@
70
  // The same objects the renderer writes to, not copies: a test reads live numbers.
71
  window.__transcriptDebug = t.debug.furigana;
72
  window.__popoverDebug = t.debug.popover;
 
73
  window.__transcriptReady = false;
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  document.getElementById('furigana-mode').addEventListener('change', (e) => {
76
  t.setMode(e.target.value);
77
  });
 
70
  // The same objects the renderer writes to, not copies: a test reads live numbers.
71
  window.__transcriptDebug = t.debug.furigana;
72
  window.__popoverDebug = t.debug.popover;
73
+ window.__translationDebug = t.debug.translation;
74
  window.__transcriptReady = false;
75
 
76
+ // A FAKE translator (plan 02-09). The per-line cache lives in host.js, not here, so
77
+ // this harness deliberately models only the state machine: every request costs the
78
+ // same 60 ms and returns '[EN] ' + the line, which is why a third tap here shows the
79
+ // loading state again where the real page shows a cache hit. What it proves is the
80
+ // part that belongs to the renderer - loading -> shown -> collapsed -> shown - with
81
+ // no server, no host framework and no network at all.
82
+ window.__translateDelayMs = 60;
83
+ window.__translateCalls = [];
84
+ t.onTranslateRequest = (lineId, text, state) => {
85
+ window.__translateCalls.push({ lineId, text, state });
86
+ if (state === 'shown') {
87
+ t.setTranslation(lineId, 'collapsed');
88
+ return;
89
+ }
90
+ t.setTranslation(lineId, 'loading');
91
+ setTimeout(
92
+ () => t.setTranslation(lineId, 'shown', `[EN] ${text}`),
93
+ window.__translateDelayMs
94
+ );
95
+ };
96
+ // The error state has no path through the real page's control (the server refuses a
97
+ // line before it is ever rendered), so the harness is where it is exercised.
98
+ window.__failTranslation = (lineId, message) =>
99
+ t.setTranslation(lineId, 'error', `translation failed: ${message}`);
100
+
101
  document.getElementById('furigana-mode').addEventListener('change', (e) => {
102
  t.setMode(e.target.value);
103
  });
avatar/transcript.css CHANGED
@@ -66,6 +66,52 @@
66
  border-bottom-color: #c62828;
67
  }
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  /* ---------------------------------------------- the lookup card (plan 02-08, research Q6)
70
  *
71
  * A positioned div, not the Popover API: iOS 16 Safari lacks it and its top-layer behaviour
 
66
  border-bottom-color: #c62828;
67
  }
68
 
69
+ /* ------------------------------------------------- the EN reveal (plan 02-09, D-15/D-16)
70
+ *
71
+ * Every selector is prefixed with #transcript-text on purpose. The control is a real
72
+ * <button> living inside a gr.HTML value, so Gradio's own stylesheet has an opinion about
73
+ * it (full width, its own font, its own padding); an id + class selector outranks the
74
+ * theme's class selectors, and the resets below are what keep the control an inline chip
75
+ * beside the sentence rather than a block that pushes the line apart. The [hidden] rule is
76
+ * there for the same reason: a framework `div { display: block }` beats the UA's
77
+ * [hidden] { display: none }, and a translation that is "hidden" but visible would make
78
+ * "never shown unrequested" (D-16) false while every attribute still read correctly.
79
+ */
80
+ #transcript-text .en-toggle {
81
+ font-size: 0.75em;
82
+ margin-left: 0.4em;
83
+ padding: 0 0.4em;
84
+ vertical-align: middle;
85
+ display: inline;
86
+ width: auto;
87
+ min-width: 0;
88
+ min-height: 0;
89
+ border: 1px solid rgba(0, 0, 0, 0.3);
90
+ border-radius: 3px;
91
+ background: transparent;
92
+ color: inherit;
93
+ font-family: inherit;
94
+ line-height: 1.6;
95
+ cursor: pointer;
96
+ }
97
+ #transcript-text .en-toggle[aria-busy="true"] {
98
+ opacity: 0.6;
99
+ }
100
+ #transcript-text .en-toggle:disabled {
101
+ cursor: default;
102
+ }
103
+ #transcript-text .en {
104
+ margin: 0.1em 0 0.4em 1.5em;
105
+ color: #333;
106
+ font-size: 0.92em;
107
+ }
108
+ #transcript-text .en[hidden] {
109
+ display: none;
110
+ }
111
+ #transcript-text .en.en-error {
112
+ color: #b00020;
113
+ }
114
+
115
  /* ---------------------------------------------- the lookup card (plan 02-08, research Q6)
116
  *
117
  * A positioned div, not the Popover API: iOS 16 Safari lacks it and its top-layer behaviour
avatar/transcript.js CHANGED
@@ -15,7 +15,15 @@
15
  // data with no round trip (D-14), and a tap on a word (plan 02-08) reads its reading, its
16
  // dictionary form, its level and its JMdict glosses straight out of the record the line
17
  // already holds - the lookup issues no request of any kind (D-05 / D-07, research § Q3).
18
- // Plan 02-09 adds the translation reveal under each line.
 
 
 
 
 
 
 
 
19
  //
20
  // The card is a positioned <div>, not the Popover API: iOS 16 Safari is still in the field
21
  // without it and its top-layer behaviour inside the Hub's cross-origin frame is unverified
@@ -48,6 +56,15 @@ const LEVEL_LABELS = { 'N1+': 'N1+ / beyond lists', name: 'name' };
48
  /** D-07: the card shows the first two or three senses, English glosses only. */
49
  const MAX_SENSES = 3;
50
 
 
 
 
 
 
 
 
 
 
51
  /** The gap between the tapped word and the card, and the inset from the column's edges. */
52
  const ANCHOR_GAP_PX = 6;
53
  const EDGE_INSET_PX = 8;
@@ -123,9 +140,23 @@ export function createTranscript(container, { doc = document } = {}) {
123
  openCount: 0,
124
  closeCount: 0,
125
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  };
127
 
128
- /** lineId -> { el, said, who, text } in insertion order. */
129
  const lines = new Map();
130
  /** lineId -> the retained token records, so a re-render is pure. */
131
  const tokensByLine = new Map();
@@ -213,6 +244,10 @@ export function createTranscript(container, { doc = document } = {}) {
213
  * Append a line as plain text; tokens arrive through setTokens (immediately for the
214
  * avatar's lines, after the analyze round trip for the learner's).
215
  *
 
 
 
 
216
  * @param {{who: 'you'|'avatar'|'slower', text: string}} line
217
  * @returns {{lineId: string, el: Element}}
218
  */
@@ -229,10 +264,31 @@ export function createTranscript(container, { doc = document } = {}) {
229
  const said = doc.createElement('span');
230
  said.className = 'said';
231
  said.textContent = String(text ?? '');
232
- el.append(label, said);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  container.append(el);
234
  container.scrollTop = container.scrollHeight;
235
- lines.set(lineId, { el, said, who, text: String(text ?? '') });
 
236
  measure(lineId);
237
  return { lineId, el };
238
  }
@@ -465,6 +521,66 @@ export function createTranscript(container, { doc = document } = {}) {
465
  return true;
466
  }
467
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  function setMode(value) {
469
  if (!FURIGANA_MODES.includes(value)) return mode;
470
  closePopover(); // Pitfall 10: a re-render replaces the .tok the card is anchored to
@@ -494,6 +610,16 @@ export function createTranscript(container, { doc = document } = {}) {
494
  openPopover,
495
  closePopover,
496
  bindPointer,
 
 
 
 
 
 
 
 
 
 
497
  debug,
498
  };
499
  }
 
15
  // data with no round trip (D-14), and a tap on a word (plan 02-08) reads its reading, its
16
  // dictionary form, its level and its JMdict glosses straight out of the record the line
17
  // already holds - the lookup issues no request of any kind (D-05 / D-07, research § Q3).
18
+ //
19
+ // Plan 02-09 adds the translation reveal under each line (D-15 / D-16 / D-18): every line,
20
+ // the learner's as much as the avatar's, carries an EN control and a hidden `.en` div, and
21
+ // setTranslation() is the whole state machine - idle / loading / shown / collapsed / error,
22
+ // each one a set of attributes a test can read. This module NEVER translates: the button's
23
+ // click calls the onTranslateRequest hook and stops there, so the network call and the
24
+ // per-line session cache (D-17) have exactly one owner, host.js. That division is what
25
+ // makes "a re-show costs zero requests" a fact the request counter can check rather than a
26
+ // claim about where a Map happens to live.
27
  //
28
  // The card is a positioned <div>, not the Popover API: iOS 16 Safari is still in the field
29
  // without it and its top-layer behaviour inside the Hub's cross-origin frame is unverified
 
56
  /** D-07: the card shows the first two or three senses, English glosses only. */
57
  const MAX_SENSES = 3;
58
 
59
+ /**
60
+ * The EN control's states (plan 02-09). `collapsed` differs from `idle` only in that the
61
+ * `.en` div still holds its text: a third tap re-shows it without asking anyone.
62
+ */
63
+ export const TRANSLATION_STATES = ['idle', 'loading', 'shown', 'collapsed', 'error'];
64
+ /** The button's label. The ellipsis is the loading state a learner sees (D-15). */
65
+ const EN_LABEL = 'EN';
66
+ const EN_LOADING_LABEL = 'EN…';
67
+
68
  /** The gap between the tapped word and the card, and the inset from the column's edges. */
69
  const ANCHOR_GAP_PX = 6;
70
  const EDGE_INSET_PX = 8;
 
140
  openCount: 0,
141
  closeCount: 0,
142
  },
143
+ // The EN reveal's numbers (plan 02-09). `requests` and `cacheHits` are written by the
144
+ // host - they are facts about the network, which this module never touches - while
145
+ // `lastState` / `states` are written here, by the state machine itself. A test that
146
+ // wants "shown from the cache" reads cacheHits AND states[lineId], and the two can
147
+ // only agree if the host asked for the state the cache says it served.
148
+ translation: {
149
+ requests: 0,
150
+ cacheHits: 0,
151
+ lastMs: null,
152
+ lastState: 'idle',
153
+ lastLineId: null,
154
+ lastError: null,
155
+ states: {},
156
+ },
157
  };
158
 
159
+ /** lineId -> { el, said, toggle, en, who, text } in insertion order. */
160
  const lines = new Map();
161
  /** lineId -> the retained token records, so a re-render is pure. */
162
  const tokensByLine = new Map();
 
244
  * Append a line as plain text; tokens arrive through setTokens (immediately for the
245
  * avatar's lines, after the analyze round trip for the learner's).
246
  *
247
+ * The EN control and its empty `.en` div are built here, for EVERY line - the learner's
248
+ * as much as the avatar's (D-18) - so "does this line have a translation control" is a
249
+ * structural fact rather than a decision taken per speaker somewhere else.
250
+ *
251
  * @param {{who: 'you'|'avatar'|'slower', text: string}} line
252
  * @returns {{lineId: string, el: Element}}
253
  */
 
264
  const said = doc.createElement('span');
265
  said.className = 'said';
266
  said.textContent = String(text ?? '');
267
+
268
+ // <button type="button" class="en-toggle" data-line="L1" aria-expanded="false"
269
+ // aria-busy="false" aria-controls="en-L1">EN</button>
270
+ // <div class="en" id="en-L1" data-line="L1" hidden></div>
271
+ const toggle = doc.createElement('button');
272
+ toggle.type = 'button';
273
+ toggle.className = 'en-toggle';
274
+ toggle.dataset.line = lineId;
275
+ toggle.textContent = EN_LABEL;
276
+ toggle.setAttribute('aria-expanded', 'false');
277
+ toggle.setAttribute('aria-busy', 'false');
278
+ toggle.setAttribute('aria-controls', `en-${lineId}`);
279
+ toggle.title = 'Show the English translation of this line';
280
+ const en = doc.createElement('div');
281
+ en.className = 'en';
282
+ en.id = `en-${lineId}`;
283
+ en.dataset.line = lineId;
284
+ en.hidden = true;
285
+ toggle.addEventListener('click', () => requestTranslation(lineId));
286
+
287
+ el.append(label, said, toggle, en);
288
  container.append(el);
289
  container.scrollTop = container.scrollHeight;
290
+ lines.set(lineId, { el, said, toggle, en, who, text: String(text ?? '') });
291
+ debug.translation.states[lineId] = 'idle';
292
  measure(lineId);
293
  return { lineId, el };
294
  }
 
521
  return true;
522
  }
523
 
524
+ // -------------------------------------------------------- the EN reveal (02-09, D-15/16)
525
+
526
+ const translation = debug.translation;
527
+ /** The host's handler; see requestTranslation. Null until someone installs one. */
528
+ let onTranslateRequest = null;
529
+
530
+ /**
531
+ * The button's whole behaviour: report the tap and stop.
532
+ *
533
+ * Deliberately not "translate this line": the hook is handed the line's id, its text and
534
+ * its CURRENT state, and the decision - collapse, serve from the session cache, or spend
535
+ * a round trip - belongs to whoever installed it. That is why this file contains no
536
+ * cache and no network call, and why the deployed row can prove a re-show is free.
537
+ */
538
+ function requestTranslation(lineId) {
539
+ const line = lines.get(lineId);
540
+ if (!line || typeof onTranslateRequest !== 'function') return false;
541
+ onTranslateRequest(lineId, line.text, translation.states[lineId] ?? 'idle');
542
+ return true;
543
+ }
544
+
545
+ /**
546
+ * Move one line's EN control into a state and publish it.
547
+ *
548
+ * The `.en` text is written on `shown` and `error` only, so `collapsed` keeps what was
549
+ * already fetched and a re-show needs nothing from anyone. Every visible consequence is
550
+ * an attribute (`aria-busy`, `aria-expanded`, `disabled`) rather than a class, so the
551
+ * loading state a learner waits through is legible to a screen reader and to a test.
552
+ *
553
+ * @param {string} lineId
554
+ * @param {'idle'|'loading'|'shown'|'collapsed'|'error'} state
555
+ * @param {string} [text] the English (shown) or the message (error)
556
+ * @returns {boolean} whether the line and the state both exist
557
+ */
558
+ function setTranslation(lineId, state, text = '') {
559
+ const line = lines.get(lineId);
560
+ if (!line || !TRANSLATION_STATES.includes(state)) return false;
561
+ const loading = state === 'loading';
562
+ const shown = state === 'shown';
563
+ const error = state === 'error';
564
+
565
+ line.toggle.textContent = loading ? EN_LOADING_LABEL : EN_LABEL;
566
+ line.toggle.disabled = loading;
567
+ line.toggle.setAttribute('aria-busy', loading ? 'true' : 'false');
568
+ line.toggle.setAttribute('aria-expanded', shown ? 'true' : 'false');
569
+ if (shown || error) line.en.textContent = String(text ?? '');
570
+ line.en.classList.toggle('en-error', error);
571
+ line.en.hidden = !(shown || error);
572
+
573
+ translation.states[lineId] = state;
574
+ translation.lastState = state;
575
+ translation.lastLineId = lineId;
576
+ return true;
577
+ }
578
+
579
+ /** The line's current EN state, for a host that would rather ask than remember. */
580
+ function getTranslationState(lineId) {
581
+ return translation.states[lineId] ?? null;
582
+ }
583
+
584
  function setMode(value) {
585
  if (!FURIGANA_MODES.includes(value)) return mode;
586
  closePopover(); // Pitfall 10: a re-render replaces the .tok the card is anchored to
 
610
  openPopover,
611
  closePopover,
612
  bindPointer,
613
+ setTranslation,
614
+ getTranslationState,
615
+ // A property, not a method, so the host reads `transcript.onTranslateRequest = fn`
616
+ // exactly as it would an event handler; anything that is not a function clears it.
617
+ get onTranslateRequest() {
618
+ return onTranslateRequest;
619
+ },
620
+ set onTranslateRequest(fn) {
621
+ onTranslateRequest = typeof fn === 'function' ? fn : null;
622
+ },
623
  debug,
624
  };
625
  }
tests/e2e/test_avatar_loop.py CHANGED
@@ -1675,3 +1675,176 @@ def test_lookup_popover_mobile(
1675
  assert page.locator("#lookup-popover").is_visible() is False
1676
  finally:
1677
  context.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1675
  assert page.locator("#lookup-popover").is_visible() is False
1676
  finally:
1677
  context.close()
1678
+
1679
+
1680
+ # ------------------------------------------------------- the EN translation reveal (02-09)
1681
+ #
1682
+ # JPN-04 on the page. The parity layer already proved the state machine and the cache under
1683
+ # both transports against a local app; this row is the same sequence against the Space, and
1684
+ # it adds the number a learner actually feels - the wall time from the tap to the English
1685
+ # being on screen, which on the Space's CPU is a translation plus a Gradio round trip and is
1686
+ # the whole reason D-15 demands a loading state. It is PRINTED, never asserted against a
1687
+ # threshold: docs/LATENCY.md § Translation is where the deployed number is recorded, by
1688
+ # test_measure_translation, and a budget here would be measuring the fleet, not the feature.
1689
+
1690
+ TRANSLATE_TEXT = "駅はどこですか。"
1691
+ TRANSLATE_EXPECT = "station"
1692
+ TRANSLATION_BOUND = (
1693
+ "() => !!(window.Avatar && window.Avatar.__debug && window.Avatar.__debug.translation)"
1694
+ )
1695
+ TRANSLATION = """
1696
+ async () => {
1697
+ const d = await window.Avatar.getDebug();
1698
+ return d.translation ? JSON.parse(JSON.stringify(d.translation)) : null;
1699
+ }
1700
+ """
1701
+ AVATAR_EN_TOGGLE = "() => !!document.querySelector('#transcript-text .turn-avatar .en-toggle')"
1702
+ # Installed before the click, so the loading state is RECORDED rather than sampled: a poll
1703
+ # that missed a fast round trip would be indistinguishable from a control that never showed
1704
+ # a loading state at all, which is the one thing D-15 asks for by name.
1705
+ OBSERVE_TOGGLE = """
1706
+ (lineId) => {
1707
+ const btn = document.querySelector(`.en-toggle[data-line="${lineId}"]`);
1708
+ if (!btn) return false;
1709
+ window.__enSeq = [];
1710
+ const record = () => window.__enSeq.push({
1711
+ busy: btn.getAttribute('aria-busy'),
1712
+ expanded: btn.getAttribute('aria-expanded'),
1713
+ text: btn.textContent,
1714
+ disabled: btn.disabled,
1715
+ });
1716
+ record();
1717
+ new MutationObserver(record).observe(btn, {
1718
+ attributes: true, childList: true, subtree: true, characterData: true,
1719
+ });
1720
+ return true;
1721
+ }
1722
+ """
1723
+ EN_SEQUENCE = "() => window.__enSeq || []"
1724
+ EXPANDED = """
1725
+ (id) => {
1726
+ const btn = document.querySelector(`.en-toggle[data-line="${id}"]`);
1727
+ return !!btn && btn.getAttribute('aria-expanded') === 'true';
1728
+ }
1729
+ """
1730
+ REJECT_TRANSLATE = """
1731
+ async ([text, lineId]) => {
1732
+ try {
1733
+ await window.Avatar.translate(text, lineId);
1734
+ return null;
1735
+ } catch (err) {
1736
+ return String((err && err.message) || err);
1737
+ }
1738
+ }
1739
+ """
1740
+ OVERLONG_CHARS = 201
1741
+ TRANSLATE_TIMEOUT_MS = 120_000
1742
+
1743
+
1744
+ def _translation(page) -> dict:
1745
+ return page.evaluate(TRANSLATION)
1746
+
1747
+
1748
+ def _en_toggle(page, line_id: str):
1749
+ return page.locator(f"#transcript-text .en-toggle[data-line='{line_id}']")
1750
+
1751
+
1752
+ @pytest.mark.deployed
1753
+ def test_translation_reveal(
1754
+ page, space_url, warm_space, speech_events, request_counter, wait_for_avatar_ready
1755
+ ):
1756
+ """JPN-04 / D-15 / D-16 / D-17 / D-18: on the deployed page, an EN control under every
1757
+ line reveals the English inline after a loading state, collapses on a second tap, and
1758
+ re-shows from the browser's session cache with zero network requests on a third.
1759
+
1760
+ The over-long line at the end is the ``{error}`` contract the UI's error state is built
1761
+ on: the page's own control can never send one (the line is refused before it exists), so
1762
+ the rejection is exercised through the facade here and the rendered error state is proven
1763
+ in the standalone harness instead.
1764
+ """
1765
+ speech_events.install(page)
1766
+ wait_for_avatar_ready(page, space_url)
1767
+ page.wait_for_function(TRANSLATION_BOUND, timeout=READY_TIMEOUT_MS)
1768
+ seeded = _translation(page)
1769
+ assert seeded["requests"] == 0 and seeded["cacheHits"] == 0 and seeded["states"] == {}
1770
+
1771
+ submitted = time.monotonic()
1772
+ _submit_text(page, TRANSLATE_TEXT)
1773
+ wait_for_ruby(page, AVATAR_EN_TOGGLE, TRANSLATE_TIMEOUT_MS, "the avatar's line never arrived")
1774
+ line_ms = round((time.monotonic() - submitted) * 1000)
1775
+ _controls_rearmed(page, timeout_ms=TRANSLATE_TIMEOUT_MS)
1776
+
1777
+ avatar_line = page.locator("#transcript-text .turn-avatar").last
1778
+ learner_line = page.locator("#transcript-text .turn-you").last
1779
+ avatar_id = avatar_line.get_attribute("data-line")
1780
+ learner_id = learner_line.get_attribute("data-line")
1781
+ assert avatar_line.locator(".en-toggle").count() == 1
1782
+ assert learner_line.locator(".en-toggle").count() == 1, "D-18: the learner's line too"
1783
+ assert page.locator(f"#en-{avatar_id}").is_visible() is False, "D-16: never unrequested"
1784
+
1785
+ # 1. The tap a learner makes, timed from the click to the English being on screen.
1786
+ assert page.evaluate(OBSERVE_TOGGLE, avatar_id) is True
1787
+ tapped = time.monotonic()
1788
+ _en_toggle(page, avatar_id).click()
1789
+ page.wait_for_function(EXPANDED, arg=avatar_id, timeout=TRANSLATE_TIMEOUT_MS)
1790
+ click_to_shown_ms = round((time.monotonic() - tapped) * 1000)
1791
+ sequence = page.evaluate(EN_SEQUENCE)
1792
+ shown = _translation(page)
1793
+ english = page.locator(f"#en-{avatar_id}").text_content()
1794
+ print(
1795
+ f"\n[deployed] {TRANSLATE_TEXT} -> the avatar's line {line_ms} ms after Enter\n"
1796
+ f"[deployed] tap EN -> {english!r} in {click_to_shown_ms} ms "
1797
+ f"(the page's own lastMs {shown['lastMs']} ms)\n"
1798
+ f"[deployed] the control's sequence: {sequence}\n[deployed] translation: {shown}"
1799
+ )
1800
+ assert any(f["busy"] == "true" for f in sequence), (
1801
+ f"no loading state was ever visible - D-15 requires one: {sequence}"
1802
+ )
1803
+ assert any(f["text"] == "EN…" and f["disabled"] for f in sequence), sequence
1804
+ assert shown["requests"] == 1 and shown["cacheHits"] == 0
1805
+ assert shown["lastMs"] > 0 and shown["lastError"] is None
1806
+ assert shown["states"][avatar_id] == "shown"
1807
+ assert page.locator(f"#en-{avatar_id}").is_visible() is True
1808
+ assert TRANSLATE_EXPECT in english.lower(), english
1809
+
1810
+ # 2. Collapse (D-16), without spending a translation.
1811
+ _en_toggle(page, avatar_id).click()
1812
+ collapsed = _translation(page)
1813
+ assert collapsed["states"][avatar_id] == "collapsed" and collapsed["requests"] == 1
1814
+ assert page.locator(f"#en-{avatar_id}").is_visible() is False
1815
+
1816
+ # 3. The cached re-show (D-17): instant, and provably free.
1817
+ with request_counter(page) as seen:
1818
+ re_tapped = time.monotonic()
1819
+ _en_toggle(page, avatar_id).click()
1820
+ page.wait_for_function(EXPANDED, arg=avatar_id, timeout=10_000)
1821
+ cached_ms = round((time.monotonic() - re_tapped) * 1000)
1822
+ during = list(seen)
1823
+ cached = _translation(page)
1824
+ print(f"[deployed] cached re-show in {cached_ms} ms; requests during it: {during}; {cached}")
1825
+ assert during == [], (
1826
+ f"a cached re-show issued {len(during)} request(s); host.js's per-line Map is "
1827
+ f"supposed to answer it without the network: {during}"
1828
+ )
1829
+ assert cached["cacheHits"] == 1 and cached["requests"] == 1
1830
+ assert page.locator(f"#en-{avatar_id}").text_content() == english
1831
+
1832
+ # 4. D-18: the learner's own line has its own control and its own translation.
1833
+ learner_tapped = time.monotonic()
1834
+ _en_toggle(page, learner_id).click()
1835
+ page.wait_for_function(EXPANDED, arg=learner_id, timeout=TRANSLATE_TIMEOUT_MS)
1836
+ learner_ms = round((time.monotonic() - learner_tapped) * 1000)
1837
+ mine = _translation(page)
1838
+ learner_english = page.locator(f"#en-{learner_id}").text_content()
1839
+ print(f"[deployed] the learner's own line -> {learner_english!r} in {learner_ms} ms; {mine}")
1840
+ assert mine["requests"] == 2 and mine["cacheHits"] == 1
1841
+ assert mine["states"] == {avatar_id: "shown", learner_id: "shown"}
1842
+ assert TRANSLATE_EXPECT in learner_english.lower(), learner_english
1843
+
1844
+ # 5. The {error} contract the error state renders. Not reachable through the control.
1845
+ refused = page.evaluate(REJECT_TRANSLATE, ["あ" * OVERLONG_CHARS, "Lx"])
1846
+ after = _translation(page)
1847
+ print(f"[deployed] translate({OVERLONG_CHARS} chars) -> {refused!r}")
1848
+ assert refused is not None and "characters" in refused, refused
1849
+ assert after["requests"] == 2, "a refused translate is not a request the host made"
1850
+ assert after["states"] == {avatar_id: "shown", learner_id: "shown"}
tests/e2e/test_facade_parity.py CHANGED
@@ -870,3 +870,146 @@ def test_lookup_popover_under_both_transports(transport, page, gradio_apps, requ
870
  closed = page.evaluate(POPOVER)
871
  assert closed["open"] is False and closed["closeCount"] == 1
872
  assert card.is_visible() is False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
870
  closed = page.evaluate(POPOVER)
871
  assert closed["open"] is False and closed["closeCount"] == 1
872
  assert card.is_visible() is False
873
+
874
+
875
+ # ------------------------------------------------------- the EN translation reveal (02-09)
876
+ #
877
+ # The host's half of the reveal, which the standalone harness cannot see: the renderer's
878
+ # hook wired to a REAL avatar.translate() round trip, and the per-line session cache that
879
+ # sits in front of it. The claim that matters is the third tap - it must produce the English
880
+ # again with the request counter recording nothing - and it is worth proving under the
881
+ # iframe transport as well, because a cache accidentally living on the wrong side of the
882
+ # transport boundary would still look right inline.
883
+
884
+ STATION_EN = "station"
885
+ TRANSLATION_BOUND = (
886
+ "() => !!(window.Avatar && window.Avatar.__debug && window.Avatar.__debug.translation)"
887
+ )
888
+ TRANSLATION = """
889
+ async () => {
890
+ const d = await window.Avatar.getDebug();
891
+ return d.translation ? JSON.parse(JSON.stringify(d.translation)) : null;
892
+ }
893
+ """
894
+ AVATAR_EN_TOGGLE = "() => !!document.querySelector('#transcript-text .turn-avatar .en-toggle')"
895
+ # Installed before the click, so the loading state is recorded rather than sampled: on a
896
+ # fast local round trip it can be over in tens of milliseconds, and a poll that missed it
897
+ # would be indistinguishable from a control that never showed one at all (D-15).
898
+ OBSERVE_TOGGLE = """
899
+ (lineId) => {
900
+ const btn = document.querySelector(`.en-toggle[data-line="${lineId}"]`);
901
+ if (!btn) return false;
902
+ window.__enSeq = [];
903
+ const record = () => window.__enSeq.push({
904
+ busy: btn.getAttribute('aria-busy'),
905
+ expanded: btn.getAttribute('aria-expanded'),
906
+ text: btn.textContent,
907
+ disabled: btn.disabled,
908
+ });
909
+ record();
910
+ new MutationObserver(record).observe(btn, {
911
+ attributes: true, childList: true, subtree: true, characterData: true,
912
+ });
913
+ return true;
914
+ }
915
+ """
916
+ EN_SEQUENCE = "() => window.__enSeq || []"
917
+ EXPANDED = """
918
+ (id) => {
919
+ const btn = document.querySelector(`.en-toggle[data-line="${id}"]`);
920
+ return !!btn && btn.getAttribute('aria-expanded') === 'true';
921
+ }
922
+ """
923
+ TRANSLATE_TIMEOUT_MS = 60_000
924
+
925
+
926
+ def _translation(page) -> dict:
927
+ return page.evaluate(TRANSLATION)
928
+
929
+
930
+ def _en_toggle(page, line_id: str):
931
+ return page.locator(f"#transcript-text .en-toggle[data-line='{line_id}']")
932
+
933
+
934
+ @pytest.mark.parametrize("transport", TRANSPORTS)
935
+ def test_translation_reveal_under_both_transports(transport, page, gradio_apps, request_counter):
936
+ """JPN-04 / D-15 / D-16 / D-17 / D-18: tap EN under a line and the English appears
937
+ inline after a loading state; tap again and it collapses; tap a third time and it comes
938
+ back from the browser's own cache with ZERO requests - identically inline and in an
939
+ iframe. The learner's own line has its own control and its own translation.
940
+ """
941
+ url = gradio_apps(transport)
942
+ page.goto(url)
943
+ page.wait_for_function(AVATAR_READY, timeout=BOOT_TIMEOUT_MS)
944
+ page.wait_for_function(FIRST_FRAME, timeout=FIRST_FRAME_TIMEOUT_MS)
945
+ page.wait_for_function(TRANSLATION_BOUND, timeout=BOOT_TIMEOUT_MS)
946
+ seeded = _translation(page)
947
+ assert seeded["requests"] == 0 and seeded["cacheHits"] == 0 and seeded["states"] == {}
948
+
949
+ page.fill("#text-input input", STATION)
950
+ page.press("#text-input input", "Enter")
951
+ wait_for_ruby(page, AVATAR_EN_TOGGLE, TURN_TIMEOUT_MS, "the avatar's line never arrived")
952
+ _wait_settled(page)
953
+
954
+ avatar_line = page.locator("#transcript-text .turn-avatar").last
955
+ learner_line = page.locator("#transcript-text .turn-you").last
956
+ avatar_id = avatar_line.get_attribute("data-line")
957
+ learner_id = learner_line.get_attribute("data-line")
958
+ assert avatar_line.locator(".en-toggle").count() == 1
959
+ assert learner_line.locator(".en-toggle").count() == 1, "D-18: the learner's line too"
960
+ assert page.locator(f"#en-{avatar_id}").is_visible() is False, "D-16: never unrequested"
961
+
962
+ # 1. The first tap: a loading state, then the English under the line.
963
+ assert page.evaluate(OBSERVE_TOGGLE, avatar_id) is True
964
+ _en_toggle(page, avatar_id).click()
965
+ page.wait_for_function(EXPANDED, arg=avatar_id, timeout=TRANSLATE_TIMEOUT_MS)
966
+ sequence = page.evaluate(EN_SEQUENCE)
967
+ shown = _translation(page)
968
+ english = page.locator(f"#en-{avatar_id}").text_content()
969
+ print(
970
+ f"\n[{transport}] tap EN on {avatar_id} -> {english!r}\n"
971
+ f"[{transport}] the control's sequence: {sequence}\n"
972
+ f"[{transport}] translation debug: {shown}"
973
+ )
974
+ assert any(f["busy"] == "true" for f in sequence), (
975
+ f"no loading state was ever visible - D-15 requires one: {sequence}"
976
+ )
977
+ assert any(f["text"] == "EN…" and f["disabled"] for f in sequence), sequence
978
+ assert shown["requests"] == 1 and shown["cacheHits"] == 0
979
+ assert shown["lastMs"] > 0 and shown["lastError"] is None
980
+ assert shown["states"][avatar_id] == "shown"
981
+ assert page.locator(f"#en-{avatar_id}").is_visible() is True
982
+ assert STATION_EN in english.lower(), english
983
+
984
+ # 2. The second tap collapses it, with no round trip (D-16).
985
+ _en_toggle(page, avatar_id).click()
986
+ collapsed = _translation(page)
987
+ assert collapsed["states"][avatar_id] == "collapsed"
988
+ assert collapsed["requests"] == 1, "collapsing must not spend a translation"
989
+ assert page.locator(f"#en-{avatar_id}").is_visible() is False
990
+
991
+ # 3. The third tap re-shows it from the cache - the number D-17 is about.
992
+ with request_counter(page) as seen:
993
+ _en_toggle(page, avatar_id).click()
994
+ page.wait_for_function(EXPANDED, arg=avatar_id, timeout=5_000)
995
+ during = list(seen)
996
+ cached = _translation(page)
997
+ print(f"[{transport}] re-show from cache: {cached}; requests during it: {during}")
998
+ assert during == [], (
999
+ f"{transport}: a cached re-show issued {len(during)} request(s); the session cache "
1000
+ f"in host.js is supposed to answer it without the network: {during}"
1001
+ )
1002
+ assert cached["cacheHits"] == 1 and cached["requests"] == 1
1003
+ assert cached["states"][avatar_id] == "shown"
1004
+ assert page.locator(f"#en-{avatar_id}").text_content() == english
1005
+
1006
+ # 4. D-18: the learner's own line translates too, on its own request.
1007
+ _en_toggle(page, learner_id).click()
1008
+ page.wait_for_function(EXPANDED, arg=learner_id, timeout=TRANSLATE_TIMEOUT_MS)
1009
+ mine = _translation(page)
1010
+ learner_english = page.locator(f"#en-{learner_id}").text_content()
1011
+ print(f"[{transport}] the learner's own line -> {learner_english!r}; {mine}")
1012
+ assert mine["requests"] == 2 and mine["cacheHits"] == 1
1013
+ assert mine["states"] == {avatar_id: "shown", learner_id: "shown"}
1014
+ assert page.locator(f"#en-{learner_id}").is_visible() is True
1015
+ assert STATION_EN in learner_english.lower(), learner_english
tests/e2e/test_transcript_standalone.py CHANGED
@@ -40,6 +40,33 @@ LONG = 25
40
 
41
  DEBUG = "() => JSON.parse(JSON.stringify(window.__transcriptDebug))"
42
  POPOVER = "() => JSON.parse(JSON.stringify(window.__popoverDebug))"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  # The narrow column plan 02-08 has to survive: a Pixel-7-width viewport with the harness's
45
  # ?narrow=1 column (300 px wide, 360 px tall, scrolling).
@@ -166,7 +193,7 @@ def test_popover_anchors_and_dismisses(page, static_server):
166
 
167
  # D-06: a tap on empty column space closes it.
168
  cont = _container_rect(page)
169
- line_box = page.locator(f"#transcript-text [data-line='{line_id}']").bounding_box()
170
  page.mouse.click(cont["left"] + cont["width"] - 6, line_box["y"] + line_box["height"] / 2)
171
  closed = _popover(page)
172
  print(
@@ -270,6 +297,120 @@ def test_popover_stays_inside_narrow_column(browser, static_server):
270
  context.close()
271
 
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  def test_ruby_renders_with_numbers(page, static_server):
274
  """D-01: the first line renders with every kanji annotated and nothing touched.
275
 
@@ -358,7 +499,9 @@ def test_learner_line_gets_ruby_too(page, static_server):
358
  _open_harness(page, static_server)
359
  line_id = _render(page, STUDY, "you")
360
  debug = _debug(page)
361
- line = page.locator(f"#transcript-text [data-line='{line_id}']")
 
 
362
  assert line.count() == 1
363
  assert "turn-you" in line.get_attribute("class")
364
  assert line.get_attribute("data-who") == "you"
 
40
 
41
  DEBUG = "() => JSON.parse(JSON.stringify(window.__transcriptDebug))"
42
  POPOVER = "() => JSON.parse(JSON.stringify(window.__popoverDebug))"
43
+ TRANSLATION = "() => JSON.parse(JSON.stringify(window.__translationDebug))"
44
+
45
+ # The EN control's state machine (plan 02-09), recorded rather than polled. The loading
46
+ # state lasts as long as the harness's fake translator takes (60 ms) - long enough to catch
47
+ # with a 10 ms poll, but a poll would make the row's meaning depend on how busy the machine
48
+ # is, and a missed sample is indistinguishable from a control that never went busy at all.
49
+ # A MutationObserver installed BEFORE the click records every attribute the button passed
50
+ # through, so the assertion is about the sequence rather than about a lucky sample.
51
+ OBSERVE_TOGGLE = """
52
+ (lineId) => {
53
+ const btn = document.querySelector(`.en-toggle[data-line="${lineId}"]`);
54
+ if (!btn) return false;
55
+ window.__enSeq = [];
56
+ const record = () => window.__enSeq.push({
57
+ busy: btn.getAttribute('aria-busy'),
58
+ expanded: btn.getAttribute('aria-expanded'),
59
+ text: btn.textContent,
60
+ disabled: btn.disabled,
61
+ });
62
+ record();
63
+ new MutationObserver(record).observe(btn, {
64
+ attributes: true, childList: true, subtree: true, characterData: true,
65
+ });
66
+ return true;
67
+ }
68
+ """
69
+ EN_SEQUENCE = "() => window.__enSeq || []"
70
 
71
  # The narrow column plan 02-08 has to survive: a Pixel-7-width viewport with the harness's
72
  # ?narrow=1 column (300 px wide, 360 px tall, scrolling).
 
193
 
194
  # D-06: a tap on empty column space closes it.
195
  cont = _container_rect(page)
196
+ line_box = page.locator(f"#transcript-text .turn[data-line='{line_id}']").bounding_box()
197
  page.mouse.click(cont["left"] + cont["width"] - 6, line_box["y"] + line_box["height"] / 2)
198
  closed = _popover(page)
199
  print(
 
297
  context.close()
298
 
299
 
300
+ def _translation(page) -> dict:
301
+ """The EN reveal's live numbers, copied out of the object the renderer writes to."""
302
+ return page.evaluate(TRANSLATION)
303
+
304
+
305
+ def test_en_control_state_machine(page, static_server):
306
+ """JPN-04 / D-15 / D-16: every line carries an EN control, and one tap walks it through
307
+ loading -> shown, a second collapses it, a third shows it again.
308
+
309
+ The renderer's half of the reveal, with no server and no host: the harness's fake
310
+ translator resolves after 60 ms, and there is no cache here on purpose, so what this row
311
+ proves is the STATE MACHINE - that the loading state is really entered (aria-busy, the
312
+ ellipsis label and a disabled button, all observed in sequence, not sampled), that the
313
+ English lands in the line's own `.en` div, and that collapsing keeps the text rather
314
+ than throwing it away. The error state is exercised too: it is the one state the real
315
+ page's control cannot reach, because the server refuses an over-long line before it can
316
+ ever be rendered as one.
317
+ """
318
+ _open_harness(page, static_server)
319
+ line_id = _render(page, STUDY, "avatar")
320
+ toggle = page.locator(f"#transcript-text .en-toggle[data-line='{line_id}']")
321
+ en = page.locator(f"#en-{line_id}")
322
+
323
+ seed = _translation(page)
324
+ print(f"[standalone] a fresh line: {seed}")
325
+ assert seed == {
326
+ "requests": 0,
327
+ "cacheHits": 0,
328
+ "lastMs": None,
329
+ "lastState": "idle",
330
+ "lastLineId": None,
331
+ "lastError": None,
332
+ "states": {line_id: "idle"},
333
+ }
334
+ assert toggle.count() == 1, "every line gets exactly one EN control (D-18)"
335
+ assert toggle.text_content() == "EN"
336
+ assert toggle.get_attribute("aria-expanded") == "false"
337
+ assert toggle.get_attribute("aria-busy") == "false"
338
+ assert toggle.get_attribute("aria-controls") == f"en-{line_id}"
339
+ assert en.count() == 1 and en.is_visible() is False, "D-16: never shown unrequested"
340
+
341
+ # 1. Tap: loading, then shown. The sequence is recorded from before the click.
342
+ assert page.evaluate(OBSERVE_TOGGLE, line_id) is True
343
+ toggle.click()
344
+ page.wait_for_function(
345
+ '(id) => document.querySelector(`.en-toggle[data-line="${id}"]`)'
346
+ ".getAttribute('aria-expanded') === 'true'",
347
+ arg=line_id,
348
+ timeout=5_000,
349
+ )
350
+ sequence = page.evaluate(EN_SEQUENCE)
351
+ shown = _translation(page)
352
+ print(f"[standalone] tap EN -> sequence {sequence}; {shown}")
353
+ busy_frames = [f for f in sequence if f["busy"] == "true"]
354
+ assert busy_frames, f"the control never went busy - no loading state was visible: {sequence}"
355
+ assert any(f["text"] == "EN…" for f in busy_frames), sequence
356
+ assert all(f["disabled"] for f in busy_frames), (
357
+ f"the control stayed clickable while loading - a second tap would double-spend: {sequence}"
358
+ )
359
+ assert sequence[-1] == {"busy": "false", "expanded": "true", "text": "EN", "disabled": False}
360
+ assert shown["states"][line_id] == "shown" and shown["lastState"] == "shown"
361
+ assert shown["lastLineId"] == line_id
362
+ assert en.is_visible() is True
363
+ assert en.text_content() == f"[EN] {STUDY_TEXT}"
364
+ assert "en-error" not in (en.get_attribute("class") or "")
365
+ assert len(page.evaluate("() => window.__translateCalls")) == 1
366
+
367
+ # 2. Second tap: collapsed. The text stays in the div, hidden.
368
+ toggle.click()
369
+ collapsed = _translation(page)
370
+ print(f"[standalone] tap again -> {collapsed}")
371
+ assert collapsed["states"][line_id] == "collapsed"
372
+ assert toggle.get_attribute("aria-expanded") == "false"
373
+ assert toggle.get_attribute("aria-busy") == "false"
374
+ assert en.is_visible() is False
375
+ assert en.text_content() == f"[EN] {STUDY_TEXT}", "collapsing must not discard the text"
376
+ assert page.evaluate("() => window.__translateCalls")[-1]["state"] == "shown"
377
+
378
+ # 3. Third tap: shown again. The harness has no cache, so it goes through loading -
379
+ # which is exactly the difference the deployed row measures against the real host.
380
+ toggle.click()
381
+ page.wait_for_function(
382
+ '(id) => document.querySelector(`.en-toggle[data-line="${id}"]`)'
383
+ ".getAttribute('aria-expanded') === 'true'",
384
+ arg=line_id,
385
+ timeout=5_000,
386
+ )
387
+ again = _translation(page)
388
+ print(f"[standalone] tap a third time -> {again}")
389
+ assert again["states"][line_id] == "shown"
390
+ assert en.is_visible() is True
391
+ calls = page.evaluate("() => window.__translateCalls")
392
+ assert [c["state"] for c in calls] == ["idle", "shown", "collapsed"], calls
393
+ assert all(c["text"] == STUDY_TEXT for c in calls), "the hook is handed the line's own text"
394
+
395
+ # 4. The error state: visible, red-classed, and the control left usable.
396
+ page.evaluate("(id) => window.__failTranslation(id, 'the translator is asleep')", line_id)
397
+ failed = _translation(page)
398
+ print(f"[standalone] forced failure -> {failed}; .en = {en.text_content()!r}")
399
+ assert failed["states"][line_id] == "error"
400
+ assert en.is_visible() is True
401
+ assert "en-error" in (en.get_attribute("class") or "")
402
+ assert "the translator is asleep" in en.text_content()
403
+ assert toggle.is_enabled() and toggle.text_content() == "EN"
404
+ assert toggle.get_attribute("aria-expanded") == "false"
405
+
406
+ # A second line gets its own control and its own state; nothing is shared but the map.
407
+ other = _render(page, TANAKA, "you")
408
+ two = _translation(page)
409
+ assert two["states"] == {line_id: "error", other: "idle"}
410
+ assert page.locator("#transcript-text .en-toggle").count() == 2
411
+ assert page.locator(f"#en-{other}").is_visible() is False
412
+
413
+
414
  def test_ruby_renders_with_numbers(page, static_server):
415
  """D-01: the first line renders with every kanji annotated and nothing touched.
416
 
 
499
  _open_harness(page, static_server)
500
  line_id = _render(page, STUDY, "you")
501
  debug = _debug(page)
502
+ # `.turn[data-line]`, not a bare `[data-line]`: since plan 02-09 the EN control and its
503
+ # `.en` div carry the same line id, so three elements answer to it.
504
+ line = page.locator(f"#transcript-text .turn[data-line='{line_id}']")
505
  assert line.count() == 1
506
  assert "turn-you" in line.get_attribute("class")
507
  assert line.get_attribute("data-who") == "you"
tests/test_transport_seam.py CHANGED
@@ -358,7 +358,17 @@ def test_host_glue_is_neither_a_transport_nor_the_turn_loop():
358
  assert f"function {member}" not in host and f"async {member}(" not in host, (
359
  f"host.js implements {member}; turn behaviour belongs in avatar/turn-loop.js"
360
  )
361
- for member in ["dispatchTurn", "requestSlower", "replay", "startListening", "stopListening"]:
 
 
 
 
 
 
 
 
 
 
362
  assert f".{member}(" in host, f"host.js never calls Avatar.{member}()"
363
  for name in [*TRANSPORTS, *SHARED, *CORE, *AUDIO_IN]:
364
  assert HOST not in src(name), f"{name} references {HOST}; only the boot template may"
@@ -690,3 +700,43 @@ def test_host_binds_pointer_once():
690
  )
691
  assert host.count("__debug.popover") == 1
692
  assert "lookup-popover" not in host, "the card is the renderer's DOM, not the host's"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  assert f"function {member}" not in host and f"async {member}(" not in host, (
359
  f"host.js implements {member}; turn behaviour belongs in avatar/turn-loop.js"
360
  )
361
+ # Plan 02-09 adds .analyze( and .translate(: the host is where the page's own calls into
362
+ # the facade are made, and both language round trips are the page's, not the loop's.
363
+ for member in [
364
+ "dispatchTurn",
365
+ "requestSlower",
366
+ "replay",
367
+ "startListening",
368
+ "stopListening",
369
+ "analyze",
370
+ "translate",
371
+ ]:
372
  assert f".{member}(" in host, f"host.js never calls Avatar.{member}()"
373
  for name in [*TRANSPORTS, *SHARED, *CORE, *AUDIO_IN]:
374
  assert HOST not in src(name), f"{name} references {HOST}; only the boot template may"
 
700
  )
701
  assert host.count("__debug.popover") == 1
702
  assert "lookup-popover" not in host, "the card is the renderer's DOM, not the host's"
703
+
704
+
705
+ def test_translation_cache_lives_in_the_host():
706
+ """Plan 02-09 / D-15 / D-17: the EN control is the renderer's, the network call and the
707
+ per-line session cache are the host's, and there is exactly one of each.
708
+
709
+ This is the seam that makes "a re-show costs zero requests" checkable at all. If the
710
+ renderer could translate, or if the host could be persuaded to call ``translate`` from
711
+ a second place, the deployed request counter would still record zero on a re-show and
712
+ prove nothing about where the answer came from.
713
+ """
714
+ host = src(HOST)
715
+ transcript = src(TRANSCRIPT)
716
+
717
+ calls = host.count("avatar.translate(")
718
+ assert "new Map(" in host, "host.js keeps no per-line cache; D-17 puts it in the browser"
719
+ assert host.count("cacheHits") >= 2, "the cache serves without counting itself"
720
+ assert calls == 1, (
721
+ "avatar.translate() must be called from exactly one place in host.js, or the cache "
722
+ f"is not the only gate in front of the network (found {calls})"
723
+ )
724
+ # D-17 is session-only: nothing about a translation is written to browser storage.
725
+ assert "jla.translation" not in host and "jla.en" not in host
726
+
727
+ # The renderer: the control, and nothing behind it.
728
+ assert "en-toggle" in transcript, "transcript.js builds no EN control"
729
+ assert "onTranslateRequest" in transcript, "the renderer must report the tap, not act on it"
730
+ assert "setTranslation" in transcript
731
+ assert ".translate(" not in transcript, (
732
+ "transcript.js calls translate(); the round trip belongs to the host (D-17 gives the "
733
+ "cache exactly one owner)"
734
+ )
735
+ # The renderer's two Maps are structural - the lines and their retained tokens. A third
736
+ # would be a cache growing back here, which is what this count exists to catch.
737
+ assert transcript.count("new Map(") == 2, (
738
+ "transcript.js has grown a third Map; it should hold only `lines` and `tokensByLine` "
739
+ f"(found {transcript.count('new Map(')})"
740
+ )
741
+ for forbidden in ("fetch(", "XMLHttpRequest"):
742
+ assert forbidden not in transcript, f"transcript.js references {forbidden!r}"