WolfDavid commited on
Commit
7d7a567
·
1 Parent(s): a4c4b26

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 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"