Spaces:
Running on Zero
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 +61 -0
- avatar/transcript-harness.html +26 -0
- avatar/transcript.css +46 -0
- avatar/transcript.js +130 -4
- tests/e2e/test_avatar_loop.py +173 -0
- tests/e2e/test_facade_parity.py +143 -0
- tests/e2e/test_transcript_standalone.py +145 -2
- tests/test_transport_seam.py +51 -1
|
@@ -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
|
|
@@ -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 |
});
|
|
@@ -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
|
|
@@ -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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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"}
|
|
@@ -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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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"
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}"
|