Spaces:
Running on Zero
Running on Zero
feat(02-07): DOM-built ruby transcript renderer, stylesheet and standalone harness
Browse files- avatar/transcript.js: createTranscript (addLine/setTokens/setMode/setLevel), showRt on the kanji axis, LEVEL_RANK/FURIGANA_MODES/LEVELS, debug.furigana numbers (rt count, rt height, line height)
- avatar/transcript.css: line-height 2.1, ruby-position over, rt 0.55em, .tok touch-action
- avatar/transcript-harness.html: fixture tokens -> DOM with no server, 1-based sentence numbers
- tests/e2e/test_transcript_standalone.py: 4 tests incl. the height proof and the oracle-gated modes
- tests/e2e/test_avatar_loop.py: expected_rt, the Python oracle shared by all three layers
- tests/test_transport_seam.py: transcript.js DOM-only, css rules, ruby-not-rp guards
- avatar/transcript-harness.html +90 -0
- avatar/transcript.css +45 -0
- avatar/transcript.js +231 -0
- tests/e2e/test_avatar_loop.py +42 -0
- tests/e2e/test_transcript_standalone.py +193 -0
- tests/test_transport_seam.py +48 -0
avatar/transcript-harness.html
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Transcript harness</title>
|
| 7 |
+
<link rel="stylesheet" href="./transcript.css" />
|
| 8 |
+
<style>
|
| 9 |
+
body {
|
| 10 |
+
margin: 16px;
|
| 11 |
+
font: 16px/1.4 system-ui, sans-serif;
|
| 12 |
+
}
|
| 13 |
+
#transcript-text {
|
| 14 |
+
border: 1px solid #ccc;
|
| 15 |
+
padding: 8px;
|
| 16 |
+
min-height: 2em;
|
| 17 |
+
}
|
| 18 |
+
</style>
|
| 19 |
+
</head>
|
| 20 |
+
<body>
|
| 21 |
+
<!--
|
| 22 |
+
THE STANDALONE LAYER FOR THE TRANSCRIPT (plan 02-07). Open it off a static server
|
| 23 |
+
with no Python app and no host framework: the golden fixture's token records go
|
| 24 |
+
through avatar/transcript.js into real <ruby><rt> DOM, exactly as stage.html does
|
| 25 |
+
for the avatar. If this page renders ruby and the
|
| 26 |
+
deployed page does not, the fault is the host, not the renderer.
|
| 27 |
+
|
| 28 |
+
window.__renderFixture(number, who) takes the 1-BASED sentence number of the 02-05
|
| 29 |
+
table (#9 = 日本語を勉強しています。, #13 = 田中さんは東京に住んでいます。). That is the ONE
|
| 30 |
+
convention every prose reference and every test in 02-07 / 02-08 / 02-09 uses -
|
| 31 |
+
never pass a 0-based index. window.__fixtureUnits(number) returns the same
|
| 32 |
+
sentence's token records for an oracle to compute against.
|
| 33 |
+
|
| 34 |
+
Query parameters: ?narrow=1 sets #transcript-text to 300 px (plan 02-08's phone column).
|
| 35 |
+
-->
|
| 36 |
+
<div id="reading-controls" class="reading-controls">
|
| 37 |
+
<label for="furigana-mode">Furigana</label>
|
| 38 |
+
<select id="furigana-mode" aria-label="Furigana mode">
|
| 39 |
+
<option value="always" selected>always</option>
|
| 40 |
+
<option value="above">above my level</option>
|
| 41 |
+
<option value="never">never</option>
|
| 42 |
+
</select>
|
| 43 |
+
<label for="level-select">My level</label>
|
| 44 |
+
<select id="level-select" aria-label="JLPT level">
|
| 45 |
+
<option value="N5" selected>N5</option>
|
| 46 |
+
<option value="N4">N4</option>
|
| 47 |
+
<option value="N3">N3</option>
|
| 48 |
+
<option value="N2">N2</option>
|
| 49 |
+
</select>
|
| 50 |
+
</div>
|
| 51 |
+
<div id="transcript-text" class="transcript" aria-live="polite"></div>
|
| 52 |
+
|
| 53 |
+
<script type="module">
|
| 54 |
+
import { createTranscript } from './transcript.js';
|
| 55 |
+
|
| 56 |
+
const params = new URLSearchParams(location.search);
|
| 57 |
+
const container = document.getElementById('transcript-text');
|
| 58 |
+
if (params.get('narrow') === '1') container.style.width = '300px';
|
| 59 |
+
|
| 60 |
+
const t = createTranscript(container);
|
| 61 |
+
window.__transcript = t;
|
| 62 |
+
// The same object the renderer writes to, not a copy: a test reads live numbers.
|
| 63 |
+
window.__transcriptDebug = t.debug.furigana;
|
| 64 |
+
window.__transcriptReady = false;
|
| 65 |
+
|
| 66 |
+
document.getElementById('furigana-mode').addEventListener('change', (e) => {
|
| 67 |
+
t.setMode(e.target.value);
|
| 68 |
+
});
|
| 69 |
+
document.getElementById('level-select').addEventListener('change', (e) => {
|
| 70 |
+
t.setLevel(e.target.value);
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
// The static server serves the repo root; the golden fixture is one level up from avatar/.
|
| 74 |
+
const response = await fetch('../tests/fixtures/sentences.json');
|
| 75 |
+
if (!response.ok) throw new Error(`golden fixture: HTTP ${response.status}`);
|
| 76 |
+
const fixture = await response.json();
|
| 77 |
+
const sentences = fixture.sentences;
|
| 78 |
+
window.__fixtures = sentences;
|
| 79 |
+
|
| 80 |
+
window.__fixtureUnits = (number) => sentences[number - 1].units;
|
| 81 |
+
window.__renderFixture = (number, who = 'avatar') => {
|
| 82 |
+
const { text, units } = sentences[number - 1];
|
| 83 |
+
const { lineId } = t.addLine({ who, text });
|
| 84 |
+
t.setTokens(lineId, units);
|
| 85 |
+
return lineId;
|
| 86 |
+
};
|
| 87 |
+
window.__transcriptReady = true;
|
| 88 |
+
</script>
|
| 89 |
+
</body>
|
| 90 |
+
</html>
|
avatar/transcript.css
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* avatar/transcript.css - the transcript's ruby rules (plan 02-07, research § Q5).
|
| 2 |
+
*
|
| 3 |
+
* The numbers here are what make the rendering verifiable: line-height 2.1 leaves room for
|
| 4 |
+
* an rt at 0.55em so consecutive lines never collide, ruby-position is pinned to `over` so
|
| 5 |
+
* a future theme cannot flip it, and the annotation is not selectable so copying a line
|
| 6 |
+
* copies the base text. tests/test_transport_seam.py greps these rules; the browser suites
|
| 7 |
+
* measure their effect (debug.furigana.lastLineRtHeightPx / lastLineHeightPx). Loaded by
|
| 8 |
+
* the app through a <link> inside the transcript's gr.HTML value and by
|
| 9 |
+
* avatar/transcript-harness.html relative to itself.
|
| 10 |
+
*/
|
| 11 |
+
#transcript-text {
|
| 12 |
+
position: relative;
|
| 13 |
+
overflow-x: clip;
|
| 14 |
+
}
|
| 15 |
+
#transcript-text .said {
|
| 16 |
+
line-height: 2.1;
|
| 17 |
+
}
|
| 18 |
+
#transcript-text ruby {
|
| 19 |
+
ruby-position: over;
|
| 20 |
+
ruby-align: center;
|
| 21 |
+
}
|
| 22 |
+
#transcript-text rt {
|
| 23 |
+
font-size: 0.55em;
|
| 24 |
+
user-select: none;
|
| 25 |
+
}
|
| 26 |
+
#transcript-text .tok {
|
| 27 |
+
cursor: pointer;
|
| 28 |
+
touch-action: manipulation;
|
| 29 |
+
border-radius: 3px;
|
| 30 |
+
}
|
| 31 |
+
#transcript-text .tok:hover,
|
| 32 |
+
#transcript-text .tok:focus-visible {
|
| 33 |
+
background: rgba(0, 0, 0, 0.06);
|
| 34 |
+
outline: none;
|
| 35 |
+
}
|
| 36 |
+
#reading-controls {
|
| 37 |
+
display: flex;
|
| 38 |
+
gap: 0.5em;
|
| 39 |
+
align-items: center;
|
| 40 |
+
font-size: 0.9em;
|
| 41 |
+
margin: 0.25em 0;
|
| 42 |
+
}
|
| 43 |
+
#reading-controls select {
|
| 44 |
+
font: inherit;
|
| 45 |
+
}
|
avatar/transcript.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// avatar/transcript.js
|
| 2 |
+
//
|
| 3 |
+
// DOM ONLY. Knows nothing about the facade, the transports or the host framework; builds
|
| 4 |
+
// children with createElement/textContent so glosses and learner text are never parsed as
|
| 5 |
+
// HTML. tests/test_transport_seam.py enforces it.
|
| 6 |
+
//
|
| 7 |
+
// The renderer behind #transcript-text (plan 02-07). host.js hands it the container and
|
| 8 |
+
// feeds it lines and the analyzer's token records; it turns each token's ruby spans into
|
| 9 |
+
// real <ruby><rt> elements, gates the readings by mode and level on the KANJI axis
|
| 10 |
+
// (D-02 / D-13), and publishes the numbers that prove the ruby rendered - the rt count,
|
| 11 |
+
// the rendered rt height and the line height - on debug.furigana, so a stylesheet that
|
| 12 |
+
// hides the annotation cannot pass a count (research Pitfall 4, the T-pose lesson).
|
| 13 |
+
//
|
| 14 |
+
// Every line's tokens are retained, so a mode or level change re-renders every line from
|
| 15 |
+
// data with no round trip (D-14). bindPointer() is the seam plan 02-08 fills with tap
|
| 16 |
+
// handling; plan 02-09 adds the translation reveal under each line.
|
| 17 |
+
//
|
| 18 |
+
// The token record (plan 02-05, nlp/analyzer.TOKEN_KEYS): `surface`, `tappable`, `jlpt`,
|
| 19 |
+
// `kanji_levels` ({kanji: "N5".."N1" | null}) and `ruby` ([[text, rt | null], ...] - one
|
| 20 |
+
// span per kanji run with its reading, kana spans with null). Non-tappable units carry
|
| 21 |
+
// [[surface, null]] and never get an annotation.
|
| 22 |
+
|
| 23 |
+
/** Difficulty order for the gate; mirrors nlp/levels.LEVEL_RANK. */
|
| 24 |
+
export const LEVEL_RANK = { N5: 1, N4: 2, N3: 3, N2: 4, N1: 5 };
|
| 25 |
+
/** The three-way control (D-02). 'above' = "above my level". */
|
| 26 |
+
export const FURIGANA_MODES = ['always', 'above', 'never'];
|
| 27 |
+
/** The level picker's range (D-14). N1 is not offered: at N1 "above my level" is 'never'. */
|
| 28 |
+
export const LEVELS = ['N5', 'N4', 'N3', 'N2'];
|
| 29 |
+
|
| 30 |
+
const LABELS = { you: 'You: ', avatar: 'Avatar: ', slower: 'Avatar (slower): ' };
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* D-02 / D-13: a run gets its rt when the mode says so; 'above' keys on the KANJI axis -
|
| 34 |
+
* any kanji in the run above the learner's level, or unlisted (null, D-10), keeps the whole
|
| 35 |
+
* run annotated. A run whose every kanji is at or below the level renders bare.
|
| 36 |
+
*
|
| 37 |
+
* @param {string} runText the base text of one ruby span
|
| 38 |
+
* @param {object} kanjiLevels the token's {kanji: level | null}
|
| 39 |
+
* @param {string} mode one of FURIGANA_MODES
|
| 40 |
+
* @param {string} level one of LEVELS
|
| 41 |
+
*/
|
| 42 |
+
export function showRt(runText, kanjiLevels, mode, level) {
|
| 43 |
+
if (mode === 'always') return true;
|
| 44 |
+
if (mode === 'never') return false;
|
| 45 |
+
const mine = LEVEL_RANK[level] ?? 1;
|
| 46 |
+
const levels = kanjiLevels || {};
|
| 47 |
+
for (const ch of runText) {
|
| 48 |
+
if (!(ch in levels)) continue; // kana inside a run never happens; defensive
|
| 49 |
+
const lv = levels[ch];
|
| 50 |
+
if (lv == null || (LEVEL_RANK[lv] ?? 99) > mine) return true;
|
| 51 |
+
}
|
| 52 |
+
return false;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* @param {Element} container the project-owned #transcript-text element
|
| 57 |
+
* @param {object} [opts]
|
| 58 |
+
* @param {Document} [opts.doc]
|
| 59 |
+
*/
|
| 60 |
+
export function createTranscript(container, { doc = document } = {}) {
|
| 61 |
+
if (!container) throw new Error('createTranscript: a container element is required');
|
| 62 |
+
|
| 63 |
+
// Every key seeded here so the key set on getDebug() never depends on timing.
|
| 64 |
+
const debug = {
|
| 65 |
+
furigana: {
|
| 66 |
+
mode: 'always',
|
| 67 |
+
level: 'N5',
|
| 68 |
+
storage: 'unknown', // host.js sets 'ok' | 'unavailable' after its localStorage probe
|
| 69 |
+
lines: 0,
|
| 70 |
+
rtTotal: 0,
|
| 71 |
+
lastLineId: null,
|
| 72 |
+
lastLineRt: 0,
|
| 73 |
+
lastLineKanjiRuns: 0,
|
| 74 |
+
lastLineRtHeightPx: 0,
|
| 75 |
+
lastLineHeightPx: 0,
|
| 76 |
+
},
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
/** lineId -> { el, said, who, text } in insertion order. */
|
| 80 |
+
const lines = new Map();
|
| 81 |
+
/** lineId -> the retained token records, so a re-render is pure. */
|
| 82 |
+
const tokensByLine = new Map();
|
| 83 |
+
let count = 0;
|
| 84 |
+
let mode = 'always';
|
| 85 |
+
let level = 'N5';
|
| 86 |
+
|
| 87 |
+
/** The number of [text, rt] spans WITH a reading across a line's tokens: the potential. */
|
| 88 |
+
function kanjiRuns(tokens) {
|
| 89 |
+
let runs = 0;
|
| 90 |
+
for (const token of tokens) {
|
| 91 |
+
for (const span of token.ruby || []) if (span[1]) runs += 1;
|
| 92 |
+
}
|
| 93 |
+
return runs;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function renderTokens(said, tokens) {
|
| 97 |
+
said.replaceChildren();
|
| 98 |
+
tokens.forEach((token, index) => {
|
| 99 |
+
const el = doc.createElement('span');
|
| 100 |
+
if (token.tappable) {
|
| 101 |
+
el.className = 'tok';
|
| 102 |
+
el.dataset.token = String(index);
|
| 103 |
+
el.setAttribute('role', 'button');
|
| 104 |
+
el.tabIndex = 0;
|
| 105 |
+
if (token.jlpt) el.dataset.level = token.jlpt;
|
| 106 |
+
} else {
|
| 107 |
+
el.className = 'plain';
|
| 108 |
+
}
|
| 109 |
+
const spans =
|
| 110 |
+
Array.isArray(token.ruby) && token.ruby.length > 0
|
| 111 |
+
? token.ruby
|
| 112 |
+
: [[String(token.surface ?? ''), null]];
|
| 113 |
+
for (const [text, rt] of spans) {
|
| 114 |
+
if (rt && showRt(text, token.kanji_levels, mode, level)) {
|
| 115 |
+
const ruby = doc.createElement('ruby');
|
| 116 |
+
ruby.append(doc.createTextNode(text));
|
| 117 |
+
const rtEl = doc.createElement('rt');
|
| 118 |
+
rtEl.textContent = rt;
|
| 119 |
+
ruby.append(rtEl);
|
| 120 |
+
el.append(ruby);
|
| 121 |
+
} else {
|
| 122 |
+
el.append(doc.createTextNode(text));
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
said.append(el);
|
| 126 |
+
});
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/**
|
| 130 |
+
* The published numbers, read from the rendered DOM after every (re)render. The line
|
| 131 |
+
* height is the `.turn` block's: an inline `.said` box reports only its own font's
|
| 132 |
+
* content area, which does not grow when an annotation sits above it, while the block
|
| 133 |
+
* that contains the line boxes does - so the block is where "a ruby line is taller than
|
| 134 |
+
* a bare one" is measurable.
|
| 135 |
+
*/
|
| 136 |
+
function measure(lineId) {
|
| 137 |
+
const line = lines.get(lineId);
|
| 138 |
+
const f = debug.furigana;
|
| 139 |
+
f.lines = lines.size;
|
| 140 |
+
f.rtTotal = container.querySelectorAll('rt').length;
|
| 141 |
+
if (!line) return;
|
| 142 |
+
f.lastLineId = lineId;
|
| 143 |
+
f.lastLineRt = line.said.querySelectorAll('rt').length;
|
| 144 |
+
f.lastLineKanjiRuns = kanjiRuns(tokensByLine.get(lineId) || []);
|
| 145 |
+
const rt = line.said.querySelector('rt');
|
| 146 |
+
f.lastLineRtHeightPx = rt ? rt.getBoundingClientRect().height : 0;
|
| 147 |
+
f.lastLineHeightPx = line.el.getBoundingClientRect().height;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function render(lineId) {
|
| 151 |
+
const line = lines.get(lineId);
|
| 152 |
+
if (!line) return;
|
| 153 |
+
const tokens = tokensByLine.get(lineId);
|
| 154 |
+
if (tokens && tokens.length > 0) renderTokens(line.said, tokens);
|
| 155 |
+
else line.said.textContent = line.text; // no tokens (yet, or the analysis failed)
|
| 156 |
+
measure(lineId);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
function renderAll() {
|
| 160 |
+
for (const lineId of lines.keys()) render(lineId);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/**
|
| 164 |
+
* Append a line as plain text; tokens arrive through setTokens (immediately for the
|
| 165 |
+
* avatar's lines, after the analyze round trip for the learner's).
|
| 166 |
+
*
|
| 167 |
+
* @param {{who: 'you'|'avatar'|'slower', text: string}} line
|
| 168 |
+
* @returns {{lineId: string, el: Element}}
|
| 169 |
+
*/
|
| 170 |
+
function addLine({ who, text }) {
|
| 171 |
+
count += 1;
|
| 172 |
+
const lineId = `L${count}`;
|
| 173 |
+
const el = doc.createElement('div');
|
| 174 |
+
el.className = `turn turn-${who}`;
|
| 175 |
+
el.dataset.line = lineId;
|
| 176 |
+
el.dataset.who = who;
|
| 177 |
+
const label = doc.createElement('span');
|
| 178 |
+
label.className = 'who';
|
| 179 |
+
label.textContent = LABELS[who] ?? LABELS.avatar;
|
| 180 |
+
const said = doc.createElement('span');
|
| 181 |
+
said.className = 'said';
|
| 182 |
+
said.textContent = String(text ?? '');
|
| 183 |
+
el.append(label, said);
|
| 184 |
+
container.append(el);
|
| 185 |
+
container.scrollTop = container.scrollHeight;
|
| 186 |
+
lines.set(lineId, { el, said, who, text: String(text ?? '') });
|
| 187 |
+
measure(lineId);
|
| 188 |
+
return { lineId, el };
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/**
|
| 192 |
+
* Attach (or replace) a line's tokens and rebuild its `.said` children from them.
|
| 193 |
+
* @returns {boolean} whether the line exists
|
| 194 |
+
*/
|
| 195 |
+
function setTokens(lineId, tokens) {
|
| 196 |
+
if (!lines.has(lineId)) return false;
|
| 197 |
+
tokensByLine.set(lineId, Array.isArray(tokens) ? tokens : []);
|
| 198 |
+
render(lineId);
|
| 199 |
+
return true;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
function setMode(value) {
|
| 203 |
+
if (!FURIGANA_MODES.includes(value)) return mode;
|
| 204 |
+
mode = value;
|
| 205 |
+
debug.furigana.mode = mode;
|
| 206 |
+
renderAll();
|
| 207 |
+
return mode;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function setLevel(value) {
|
| 211 |
+
if (!LEVELS.includes(value)) return level;
|
| 212 |
+
level = value;
|
| 213 |
+
debug.furigana.level = level;
|
| 214 |
+
renderAll();
|
| 215 |
+
return level;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
return {
|
| 219 |
+
addLine,
|
| 220 |
+
setTokens,
|
| 221 |
+
setMode,
|
| 222 |
+
setLevel,
|
| 223 |
+
getMode: () => mode,
|
| 224 |
+
getLevel: () => level,
|
| 225 |
+
getTokens: (lineId) => tokensByLine.get(lineId) || null,
|
| 226 |
+
bindPointer() {
|
| 227 |
+
// plan 02-08: tap handling for .tok (the lookup popover) is installed here.
|
| 228 |
+
},
|
| 229 |
+
debug,
|
| 230 |
+
};
|
| 231 |
+
}
|
tests/e2e/test_avatar_loop.py
CHANGED
|
@@ -27,6 +27,8 @@ from urllib.parse import urlparse
|
|
| 27 |
import pytest
|
| 28 |
import requests
|
| 29 |
|
|
|
|
|
|
|
| 30 |
from tests.e2e.test_stage_standalone import (
|
| 31 |
ARM_DOWN_MAX,
|
| 32 |
FIRST_FRAME_TIMEOUT_MS,
|
|
@@ -1160,3 +1162,43 @@ def test_first_tap_is_audible(
|
|
| 1160 |
assert after["turnCount"] == 2
|
| 1161 |
assert after["lastSubtitle"] == TURN_TEXT
|
| 1162 |
assert second["duration"] > 0.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
import pytest
|
| 28 |
import requests
|
| 29 |
|
| 30 |
+
from japanese_avatar.nlp.levels import LEVEL_RANK, kanji_levels
|
| 31 |
+
from japanese_avatar.nlp.ruby import is_kanji
|
| 32 |
from tests.e2e.test_stage_standalone import (
|
| 33 |
ARM_DOWN_MAX,
|
| 34 |
FIRST_FRAME_TIMEOUT_MS,
|
|
|
|
| 1162 |
assert after["turnCount"] == 2
|
| 1163 |
assert after["lastSubtitle"] == TURN_TEXT
|
| 1164 |
assert second["duration"] > 0.5
|
| 1165 |
+
|
| 1166 |
+
|
| 1167 |
+
# ================================================================ furigana (plan 02-07)
|
| 1168 |
+
#
|
| 1169 |
+
# The three-layer rule again: the rendered ruby is a NUMBER - rt count, rendered rt height,
|
| 1170 |
+
# line height - read from getDebug().furigana and from the DOM, at the standalone layer
|
| 1171 |
+
# (tests/e2e/test_transcript_standalone.py), under both transports
|
| 1172 |
+
# (test_facade_parity.py::test_ruby_under_both_transports) and here. The gate's expected
|
| 1173 |
+
# count is computed by an independent Python oracle over the page's own tokens and the
|
| 1174 |
+
# pinned kanji list, never hard-coded: 勉 / 強's levels are the data's to say.
|
| 1175 |
+
|
| 1176 |
+
|
| 1177 |
+
def expected_rt(tokens: list[dict], mode: str, level: str) -> int:
|
| 1178 |
+
"""The Python oracle of ``transcript.js::showRt`` over a line's token records.
|
| 1179 |
+
|
| 1180 |
+
``always`` shows every kanji run that has a reading; ``never`` none; ``above`` shows a
|
| 1181 |
+
run when ANY kanji in it is above the learner's level or on no list (D-02 / D-10 /
|
| 1182 |
+
D-13, the kanji axis). Written against ``data/jlpt/kanji_levels.json`` through
|
| 1183 |
+
:func:`japanese_avatar.nlp.levels.kanji_levels` and :func:`is_kanji` - not against the
|
| 1184 |
+
token's own ``kanji_levels`` field - so the browser and the oracle agree only if both
|
| 1185 |
+
the record and the gate are right.
|
| 1186 |
+
"""
|
| 1187 |
+
if mode == "never":
|
| 1188 |
+
return 0
|
| 1189 |
+
runs = [text for token in tokens for text, rt in token["ruby"] if rt]
|
| 1190 |
+
if mode == "always":
|
| 1191 |
+
return len(runs)
|
| 1192 |
+
assert mode == "above", mode
|
| 1193 |
+
table = kanji_levels()
|
| 1194 |
+
mine = LEVEL_RANK[level]
|
| 1195 |
+
shown = 0
|
| 1196 |
+
for text in runs:
|
| 1197 |
+
for ch in text:
|
| 1198 |
+
if not is_kanji(ch):
|
| 1199 |
+
continue
|
| 1200 |
+
listed = table.get(ch)
|
| 1201 |
+
if listed is None or LEVEL_RANK[listed] > mine:
|
| 1202 |
+
shown += 1
|
| 1203 |
+
break
|
| 1204 |
+
return shown
|
tests/e2e/test_transcript_standalone.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The transcript renderer, proven in a real browser with no Python app and no host framework.
|
| 2 |
+
|
| 3 |
+
avatar/transcript-harness.html is opened off the static server; fixture token records from
|
| 4 |
+
tests/fixtures/sentences.json go through avatar/transcript.js into real <ruby><rt> DOM. If
|
| 5 |
+
these pass and the deployed furigana rows fail, the fault is provably the host page (the
|
| 6 |
+
stylesheet did not load, the module was not handed over), not the renderer - the same
|
| 7 |
+
division of blame tests/e2e/test_stage_standalone.py gives the avatar.
|
| 8 |
+
|
| 9 |
+
Every claim is a number (research Pitfall 4, "green count, invisible ruby"): the rt COUNT,
|
| 10 |
+
the rendered rt HEIGHT and the line HEIGHT with ruby versus bare, all read from
|
| 11 |
+
transcript.js's published debug object and asserted here before either browser layer that
|
| 12 |
+
needs a server. Marked slow but NOT deployed.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import pytest
|
| 21 |
+
|
| 22 |
+
from tests.e2e.test_avatar_loop import expected_rt
|
| 23 |
+
|
| 24 |
+
pytestmark = pytest.mark.slow
|
| 25 |
+
|
| 26 |
+
FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "sentences.json"
|
| 27 |
+
READY_TIMEOUT_MS = 15_000
|
| 28 |
+
|
| 29 |
+
# 1-BASED sentence numbers of the 02-05 table - the harness's one convention. #9 has two
|
| 30 |
+
# kanji runs (日本語, 勉強) around a bare particle and a bare inflection; #13 and #1 make up
|
| 31 |
+
# the three-line re-render check.
|
| 32 |
+
STUDY = 9
|
| 33 |
+
STUDY_TEXT = "日本語を勉強しています。"
|
| 34 |
+
STUDY_READING_FIRST = "にほんご"
|
| 35 |
+
TANAKA = 13
|
| 36 |
+
HELLO = 1
|
| 37 |
+
|
| 38 |
+
DEBUG = "() => JSON.parse(JSON.stringify(window.__transcriptDebug))"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _units(number: int) -> list[dict]:
|
| 42 |
+
"""The fixture's token records for a 1-based sentence number, for the Python oracle."""
|
| 43 |
+
sentences = json.loads(FIXTURE.read_text(encoding="utf-8"))["sentences"]
|
| 44 |
+
return sentences[number - 1]["units"]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _open_harness(page, static_server, query: str = ""):
|
| 48 |
+
page.goto(f"{static_server}/avatar/transcript-harness.html{query}")
|
| 49 |
+
page.wait_for_function("() => window.__transcriptReady === true", timeout=READY_TIMEOUT_MS)
|
| 50 |
+
assert page.evaluate("() => window.__fixtures[8].text") == STUDY_TEXT, (
|
| 51 |
+
"fixture #9 is not 日本語を勉強しています。; convention or golden file moved"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _render(page, number: int, who: str = "avatar") -> str:
|
| 56 |
+
return page.evaluate("([n, who]) => window.__renderFixture(n, who)", [number, who])
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _debug(page) -> dict:
|
| 60 |
+
return page.evaluate(DEBUG)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _set(page, select_id: str, value: str) -> dict:
|
| 64 |
+
page.select_option(f"#{select_id}", value)
|
| 65 |
+
return _debug(page)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_ruby_renders_with_numbers(page, static_server):
|
| 69 |
+
"""D-01: the first line renders with every kanji annotated and nothing touched.
|
| 70 |
+
|
| 71 |
+
Two kanji runs -> two <rt>, each with a rendered height, on a line that is one
|
| 72 |
+
`.turn-avatar`; the readings are real DOM text (in textContent - which is why <rp> is
|
| 73 |
+
never emitted), the tappable units carry the accessibility floor and the particles are
|
| 74 |
+
plain spans.
|
| 75 |
+
"""
|
| 76 |
+
_open_harness(page, static_server)
|
| 77 |
+
line_id = _render(page, STUDY, "avatar")
|
| 78 |
+
debug = _debug(page)
|
| 79 |
+
print(f"[standalone] {STUDY_TEXT} as avatar -> {line_id}: {debug}")
|
| 80 |
+
|
| 81 |
+
assert line_id == "L1"
|
| 82 |
+
assert page.locator("#transcript-text .turn-avatar").count() == 1
|
| 83 |
+
assert page.locator("#transcript-text .turn").count() == 1
|
| 84 |
+
assert debug["lastLineId"] == "L1"
|
| 85 |
+
assert debug["lastLineKanjiRuns"] == 2
|
| 86 |
+
assert debug["lastLineRt"] == 2, debug
|
| 87 |
+
assert debug["rtTotal"] == 2
|
| 88 |
+
assert debug["lastLineRtHeightPx"] > 0, "rt exists but rendered with no height - hidden ruby"
|
| 89 |
+
assert debug["lastLineHeightPx"] > 0
|
| 90 |
+
|
| 91 |
+
said = page.locator("#transcript-text .turn .said")
|
| 92 |
+
assert STUDY_READING_FIRST in said.text_content()
|
| 93 |
+
assert page.locator("#transcript-text .turn .who").text_content() == "Avatar: "
|
| 94 |
+
ruby = page.locator("#transcript-text ruby").first
|
| 95 |
+
assert "日本語" in ruby.inner_text()
|
| 96 |
+
assert page.locator("#transcript-text rt").first.text_content() == STUDY_READING_FIRST
|
| 97 |
+
|
| 98 |
+
toks = page.locator("#transcript-text .tok")
|
| 99 |
+
assert toks.count() == 2, "日本語 and 勉強しています are the two tappable units"
|
| 100 |
+
for i in range(toks.count()):
|
| 101 |
+
assert toks.nth(i).get_attribute("role") == "button"
|
| 102 |
+
assert toks.nth(i).get_attribute("tabindex") == "0"
|
| 103 |
+
assert toks.nth(i).get_attribute("data-token") is not None
|
| 104 |
+
assert page.locator("#transcript-text .plain").count() >= 2, "を and 。 are plain (D-11)"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_furigana_modes_gate_on_kanji_axis(page, static_server):
|
| 108 |
+
"""D-02 / D-13 / D-14: never -> 0; above@N5 and above@N2 equal the independent oracle
|
| 109 |
+
(computed from the pinned kanji list, never hard-coded); always -> 2 again; and the
|
| 110 |
+
always line is measurably taller than the never line - the rendered-height proof."""
|
| 111 |
+
_open_harness(page, static_server)
|
| 112 |
+
_render(page, STUDY, "avatar")
|
| 113 |
+
units = _units(STUDY)
|
| 114 |
+
always = _debug(page)
|
| 115 |
+
assert always["mode"] == "always" and always["level"] == "N5"
|
| 116 |
+
assert always["lastLineRt"] == expected_rt(units, "always", "N5") == 2
|
| 117 |
+
|
| 118 |
+
never = _set(page, "furigana-mode", "never")
|
| 119 |
+
assert never["mode"] == "never"
|
| 120 |
+
assert never["lastLineRt"] == 0 and never["rtTotal"] == 0
|
| 121 |
+
assert page.locator("#transcript-text rt").count() == 0
|
| 122 |
+
assert never["lastLineRtHeightPx"] == 0
|
| 123 |
+
|
| 124 |
+
above_n5 = _set(page, "furigana-mode", "above")
|
| 125 |
+
want_n5 = expected_rt(units, "above", "N5")
|
| 126 |
+
assert above_n5["mode"] == "above" and above_n5["level"] == "N5"
|
| 127 |
+
assert above_n5["lastLineRt"] == want_n5, (above_n5, want_n5)
|
| 128 |
+
|
| 129 |
+
above_n2 = _set(page, "level-select", "N2")
|
| 130 |
+
want_n2 = expected_rt(units, "above", "N2")
|
| 131 |
+
assert above_n2["level"] == "N2"
|
| 132 |
+
assert above_n2["lastLineRt"] == want_n2, (above_n2, want_n2)
|
| 133 |
+
assert want_n2 <= want_n5
|
| 134 |
+
|
| 135 |
+
back = _set(page, "furigana-mode", "always")
|
| 136 |
+
assert back["lastLineRt"] == 2 and back["rtTotal"] == 2
|
| 137 |
+
print(
|
| 138 |
+
f"[standalone] modes: always rt={always['lastLineRt']} h={always['lastLineHeightPx']} "
|
| 139 |
+
f"rtH={always['lastLineRtHeightPx']}; never rt=0 h={never['lastLineHeightPx']}; "
|
| 140 |
+
f"above@N5 rt={above_n5['lastLineRt']} (oracle {want_n5}); "
|
| 141 |
+
f"above@N2 rt={above_n2['lastLineRt']} (oracle {want_n2})"
|
| 142 |
+
)
|
| 143 |
+
assert always["lastLineHeightPx"] > never["lastLineHeightPx"], (
|
| 144 |
+
"the ruby line is not taller than the bare line; the annotation is not being laid out "
|
| 145 |
+
f"above the text (always {always['lastLineHeightPx']} px vs never "
|
| 146 |
+
f"{never['lastLineHeightPx']} px)"
|
| 147 |
+
)
|
| 148 |
+
assert back["lastLineHeightPx"] == always["lastLineHeightPx"]
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_learner_line_gets_ruby_too(page, static_server):
|
| 152 |
+
"""D-03: the learner's own line carries the same ruby as the avatar's."""
|
| 153 |
+
_open_harness(page, static_server)
|
| 154 |
+
line_id = _render(page, STUDY, "you")
|
| 155 |
+
debug = _debug(page)
|
| 156 |
+
line = page.locator(f"#transcript-text [data-line='{line_id}']")
|
| 157 |
+
assert line.count() == 1
|
| 158 |
+
assert "turn-you" in line.get_attribute("class")
|
| 159 |
+
assert line.get_attribute("data-who") == "you"
|
| 160 |
+
assert line.locator(".who").text_content() == "You: "
|
| 161 |
+
assert debug["lastLineRt"] == 2
|
| 162 |
+
assert line.locator("rt").count() == 2
|
| 163 |
+
assert debug["lastLineRtHeightPx"] > 0
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def test_rerender_keeps_line_count(page, static_server):
|
| 167 |
+
"""A mode flip re-renders every line from retained tokens: no duplicates, no losses."""
|
| 168 |
+
_open_harness(page, static_server)
|
| 169 |
+
ids = [_render(page, HELLO, "avatar"), _render(page, STUDY, "you"), _render(page, TANAKA)]
|
| 170 |
+
assert ids == ["L1", "L2", "L3"]
|
| 171 |
+
before = _debug(page)
|
| 172 |
+
assert before["lines"] == 3
|
| 173 |
+
total_always = before["rtTotal"]
|
| 174 |
+
assert total_always == sum(
|
| 175 |
+
expected_rt(_units(n), "always", "N5") for n in (HELLO, STUDY, TANAKA)
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
_set(page, "furigana-mode", "never")
|
| 179 |
+
_set(page, "level-select", "N3")
|
| 180 |
+
after = _set(page, "furigana-mode", "above")
|
| 181 |
+
assert after["lines"] == 3
|
| 182 |
+
assert page.locator("#transcript-text .turn").count() == 3
|
| 183 |
+
assert [
|
| 184 |
+
el.get_attribute("data-line") for el in page.locator("#transcript-text .turn").all()
|
| 185 |
+
] == ids
|
| 186 |
+
assert after["rtTotal"] == sum(
|
| 187 |
+
expected_rt(_units(n), "above", "N3") for n in (HELLO, STUDY, TANAKA)
|
| 188 |
+
)
|
| 189 |
+
assert after["lastLineId"] == "L3"
|
| 190 |
+
|
| 191 |
+
restored = _set(page, "furigana-mode", "always")
|
| 192 |
+
assert restored["rtTotal"] == total_always
|
| 193 |
+
assert page.locator("#transcript-text .turn").count() == 3
|
tests/test_transport_seam.py
CHANGED
|
@@ -39,6 +39,11 @@ AUDIO_IN = ["mic.js", "asr.js"]
|
|
| 39 |
# it must import no avatar module, be imported by none, and implement no turn behaviour.
|
| 40 |
HOST = "host.js"
|
| 41 |
COMPONENT = AVATAR.parent / "src" / "japanese_avatar" / "ui" / "avatar_component.py"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
def src(name: str) -> str:
|
|
@@ -532,3 +537,46 @@ def test_bridge_calls_take_one_payload():
|
|
| 532 |
assert "bridge.language_info(" in s
|
| 533 |
assert "bridge.analyze(text" not in s, "analyze must pack its arguments into one object"
|
| 534 |
assert "bridge.translate(text" not in s, "translate must pack its arguments into one object"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
# it must import no avatar module, be imported by none, and implement no turn behaviour.
|
| 40 |
HOST = "host.js"
|
| 41 |
COMPONENT = AVATAR.parent / "src" / "japanese_avatar" / "ui" / "avatar_component.py"
|
| 42 |
+
# Plan 02-07. The transcript renderer: DOM only, handed to the host glue by the boot
|
| 43 |
+
# template. It knows neither the facade nor the host framework, and builds ruby with
|
| 44 |
+
# createElement so glosses and learner text are never parsed as markup.
|
| 45 |
+
TRANSCRIPT = "transcript.js"
|
| 46 |
+
TRANSCRIPT_CSS = "transcript.css"
|
| 47 |
|
| 48 |
|
| 49 |
def src(name: str) -> str:
|
|
|
|
| 537 |
assert "bridge.language_info(" in s
|
| 538 |
assert "bridge.analyze(text" not in s, "analyze must pack its arguments into one object"
|
| 539 |
assert "bridge.translate(text" not in s, "translate must pack its arguments into one object"
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
# ------------------------------------------------------------- plan 02-07: the transcript
|
| 543 |
+
#
|
| 544 |
+
# Ruby is built by one DOM-only module. It must import nothing (it is handed the container),
|
| 545 |
+
# reference neither window.Avatar nor the host framework, never write markup as a string,
|
| 546 |
+
# and emit <ruby><rt> without <rp> - the fallback parentheses would leak into every
|
| 547 |
+
# textContent-based assertion and are unnecessary on every target browser (research § Q5).
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def test_transcript_renderer_is_dom_only():
|
| 551 |
+
s = src(TRANSCRIPT)
|
| 552 |
+
assert not re.search(r"^\s*import\s", s, re.M), "transcript.js must import nothing"
|
| 553 |
+
assert "window.Avatar" not in s, "transcript.js must not know the facade"
|
| 554 |
+
assert "innerHTML" not in s, "transcript.js must build children, never parse markup"
|
| 555 |
+
lowered = s.lower()
|
| 556 |
+
for tok in GRADIO_TOKENS:
|
| 557 |
+
assert tok.lower() not in lowered, f"transcript.js references {tok!r}"
|
| 558 |
+
assert "export function createTranscript" in s
|
| 559 |
+
assert "export function showRt" in s
|
| 560 |
+
for name in [*TRANSPORTS, *SHARED, *CORE, *AUDIO_IN, HOST]:
|
| 561 |
+
assert TRANSCRIPT not in src(name), (
|
| 562 |
+
f"{name} references {TRANSCRIPT}; only the boot template may load it"
|
| 563 |
+
)
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
def test_transcript_css_ships_the_measured_rules():
|
| 567 |
+
"""The numbers research § Q5 arrived at, pinned so a theme cannot quietly undo them."""
|
| 568 |
+
css = src(TRANSCRIPT_CSS)
|
| 569 |
+
for rule in (
|
| 570 |
+
"line-height: 2.1",
|
| 571 |
+
"ruby-position: over",
|
| 572 |
+
"font-size: 0.55em",
|
| 573 |
+
"touch-action: manipulation",
|
| 574 |
+
):
|
| 575 |
+
assert rule in css, f"transcript.css lost {rule!r}"
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
def test_transcript_uses_ruby_not_parentheses():
|
| 579 |
+
s = src(TRANSCRIPT)
|
| 580 |
+
assert "createElement('ruby')" in s or 'createElement("ruby")' in s
|
| 581 |
+
assert "createElement('rt')" in s or 'createElement("rt")' in s
|
| 582 |
+
assert "'rp'" not in s and '"rp"' not in s, "no <rp>: it would leak into textContent"
|