@bongos/core 1.19.790 → 1.19.791

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ // tests/accessibility_replay.mjs
2
+ //
3
+ // The two-profile replay regression (task 1002517; clauses 5 and 8 of criterion
4
+ // `accessibility-fluency-profile`) — the proof the criterion named for itself
5
+ // and that had never been run.
6
+ //
7
+ // This is NOT a second copy of tests/session_accessibility.mjs. That file pins
8
+ // the scorer's arithmetic against fixtures it builds line by line. This one runs
9
+ // a fixed set of eight beginner questions through two real answer registers and
10
+ // asserts what the criterion actually claims: accessible prose clears a floor,
11
+ // an expert's prose is not scored for being an expert's prose, and the two
12
+ // verdicts come from the same corpus — so neither is a tautology.
13
+ //
14
+ // The assertion that keeps the rest honest is DISCRIMINATION (leg 3). A scorer
15
+ // that had gone blind — three detectors quietly matching nothing — produces a
16
+ // perfect 10 on the non-technical run and passes leg 1 looking exactly like a
17
+ // working one. Leg 3 reads the same engineer prose as a newcomer would and
18
+ // requires every detector to register hits and the score to fall through the
19
+ // floor. Nothing else here can pass while the detectors are dead.
20
+ //
21
+ // Run: node tests/accessibility_replay.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { createRequire } from 'node:module';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const H = require('../scripts/gds/accessibility-replay.js');
28
+ const acc = require('../modules/sessions/accessibility.js');
29
+
30
+ const corpus = H.loadCorpus();
31
+ const NEWCOMER = corpus.runs.non_technical.profile;
32
+
33
+ let passed = 0;
34
+ let failed = 0;
35
+ function test(name, fn) {
36
+ try {
37
+ fn();
38
+ passed++;
39
+ console.log(` ok ${name}`);
40
+ } catch (err) {
41
+ failed++;
42
+ console.log(` FAIL ${name}`);
43
+ console.log(` ${err.message}`);
44
+ }
45
+ }
46
+
47
+ const signalsOf = (r) => Object.fromEntries(r.result.signals.map((s) => [s.type, s]));
48
+
49
+ // ---- the floor is the scorer's own line, not a second number ---------------
50
+
51
+ test('FLOOR is bandFor()\'s workable boundary, so retuning the bands cannot move it silently', () => {
52
+ assert.equal(H.bandFor(H.FLOOR), 'workable', 'FLOOR sits exactly on the workable boundary');
53
+ assert.equal(H.bandFor(H.FLOOR - 0.1), 'dense', 'a tenth below the floor is already the dense band');
54
+ assert.equal(corpus.floor, H.FLOOR, 'the corpus records the same floor the harness enforces');
55
+ });
56
+
57
+ // ---- the corpus is a fixed set, and both runs answer all of it -------------
58
+
59
+ test('the prompt set is fixed, non-trivial, and answered in full by both runs', () => {
60
+ assert.ok(corpus.prompts.length >= 8, 'at least eight beginner prompts');
61
+ const ids = corpus.prompts.map((p) => p.id);
62
+ assert.equal(new Set(ids).size, ids.length, 'prompt ids are unique');
63
+ for (const runName of ['non_technical', 'engineer']) {
64
+ const run = corpus.runs[runName];
65
+ for (const id of ids) {
66
+ const turns = run.replies[id];
67
+ assert.ok(Array.isArray(turns) && turns.length > 0, `${runName} answers ${id}`);
68
+ for (const t of turns) assert.ok(typeof t === 'string' && t.trim(), `${runName}/${id} turn is prose`);
69
+ }
70
+ for (const [field, value] of Object.entries(run.profile)) {
71
+ assert.ok(acc.PROFILE_VALUES[field]?.includes(value), `${runName} profile ${field}=${value} is a real value`);
72
+ }
73
+ }
74
+ });
75
+
76
+ // ---- leg 1: the dimension recognises accessible prose ----------------------
77
+
78
+ test('leg 1 — the non-technical run clears the floor under the non-technical profile', () => {
79
+ const r = H.replay(corpus, 'non_technical');
80
+ assert.ok(r.result.score != null, 'the non-technical run is scored, not waived away');
81
+ assert.ok(
82
+ r.result.score >= H.FLOOR,
83
+ `non-technical run scored ${r.result.score}, floor is ${H.FLOOR}`
84
+ );
85
+ assert.equal(r.result.confidence, 'normal', 'the sample is big enough for the score to stand behind');
86
+ });
87
+
88
+ // ---- leg 2: an expert's prose is not scored for being an expert's prose ----
89
+
90
+ test('leg 2 — the engineer run is not scored at all under the engineer profile', () => {
91
+ const r = H.replay(corpus, 'engineer');
92
+ assert.equal(r.result.score, null, 'an all-waived session scores null, never a number');
93
+ assert.equal(r.result.band, 'n/a');
94
+ assert.equal(r.result.signals.length, 3);
95
+ for (const s of r.result.signals) {
96
+ assert.equal(s.waived, true, `${s.type} is waived for this profile`);
97
+ assert.ok(/technical_level|toolchain_familiarity/.test(s.reason), `${s.type} names the setting that waived it`);
98
+ }
99
+ assert.equal(r.result.confidence, 'normal', 'the prose was read — the null is a waiver, not an empty sample');
100
+ });
101
+
102
+ test('leg 2 holds when the same expert answers even more briefly', () => {
103
+ const full = H.replay(corpus, 'engineer');
104
+ const terse = H.replay(corpus, 'engineer', { variant: 'terse' });
105
+ assert.ok(terse.result.sampled_chars < full.result.sampled_chars, 'the terse variant really is shorter');
106
+ assert.equal(terse.result.score, null, 'still nothing to regress');
107
+ });
108
+
109
+ // ---- leg 3: the same prose, read as a newcomer would read it ---------------
110
+
111
+ test('leg 3 — the SAME engineer prose falls through the floor under the non-technical profile', () => {
112
+ const r = H.replay(corpus, 'engineer', { profile: NEWCOMER });
113
+ assert.ok(r.result.score != null, 'nothing is waived for a newcomer');
114
+ assert.ok(
115
+ r.result.score < H.FLOOR,
116
+ `engineer prose read as a newcomer scored ${r.result.score}, which should be under ${H.FLOOR}`
117
+ );
118
+ });
119
+
120
+ test('leg 3 — every detector registers hits, so a scorer that went blind cannot pass', () => {
121
+ const s = signalsOf(H.replay(corpus, 'engineer', { profile: NEWCOMER }));
122
+ for (const type of Object.keys(acc.SIGNAL_SPEC)) {
123
+ assert.ok(s[type], `${type} was scored`);
124
+ assert.ok(s[type].count > 0, `${type} found something in the engineer prose (found ${s[type].count})`);
125
+ assert.ok(s[type].evidence, `${type} can name what it found`);
126
+ }
127
+ });
128
+
129
+ // ---- the perfect run is forgiveness, not avoidance -------------------------
130
+
131
+ test('the non-technical run scores well WITH a jargon term and implementation framing in it, not by dodging them', () => {
132
+ const prose = Object.values(corpus.runs.non_technical.replies).flat().join('\n');
133
+ const present = acc.JARGON_TERMS.filter((t) => new RegExp(`\\b${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(prose));
134
+ assert.ok(present.length > 0, 'the accessible prose does use at least one term from the jargon list');
135
+ assert.ok(/\barchitecture\b/i.test(prose), 'and frames something in architecture terms at least once');
136
+
137
+ const s = signalsOf(H.replay(corpus, 'non_technical'));
138
+ assert.equal(s.unexplained_jargon.count, 0, `"${present.join('", "')}" is used and DEFINED, so it is not a finding`);
139
+ assert.equal(s.implementation_over_outcome.count, 0, 'the architecture turn also names the cost, so it is not a finding');
140
+ });
141
+
142
+ // ---- leg 4: the metric reads density, not volume --------------------------
143
+
144
+ test('leg 4 — saying the same thing twice doubles the counts and leaves every rate, and the score, identical', () => {
145
+ const once = H.replay(corpus, 'engineer', { profile: NEWCOMER });
146
+ const twice = H.replay(corpus, 'engineer', { profile: NEWCOMER, variant: 'doubled' });
147
+ assert.equal(twice.result.sampled_chars, once.result.sampled_chars * 2, 'twice the prose');
148
+
149
+ const a = signalsOf(once);
150
+ const b = signalsOf(twice);
151
+ for (const type of Object.keys(acc.SIGNAL_SPEC)) {
152
+ assert.equal(b[type].count, a[type].count * 2, `${type} finds twice as much in twice the prose`);
153
+ assert.equal(b[type].rate, a[type].rate, `${type} rate is unchanged — the metric reads density, not volume`);
154
+ }
155
+ assert.equal(twice.result.score, once.result.score, 'so the score does not move with length');
156
+ });
157
+
158
+ test('answering more briefly never costs points', () => {
159
+ for (const runName of ['non_technical', 'engineer']) {
160
+ const full = H.replay(corpus, runName);
161
+ const terse = H.replay(corpus, runName, { variant: 'terse' });
162
+ assert.ok(terse.result.sampled_chars < full.result.sampled_chars, `${runName} terse variant is shorter`);
163
+ if (full.result.score == null) {
164
+ assert.equal(terse.result.score, null, `${runName} stays unscored`);
165
+ continue;
166
+ }
167
+ assert.ok(
168
+ terse.result.score >= full.result.score,
169
+ `${runName} terse scored ${terse.result.score} against ${full.result.score} full — brevity must not cost`
170
+ );
171
+ }
172
+ });
173
+
174
+ // ---- the two waivers are independent settings ------------------------------
175
+
176
+ test('an engineer who is new to the terminal still gets the click-path signal', () => {
177
+ const r = H.replay(corpus, 'engineer', { profile: H.ENGINEER_NEW_TO_TOOLCHAIN });
178
+ const s = signalsOf(r);
179
+ assert.equal(r.result.signals.filter((x) => x.waived).length, 2, 'technical_level waives two, not three');
180
+ assert.equal(s.terminal_where_ui_exists.waived, false, 'the click-path signal is waived by TOOLCHAIN familiarity, not by technical level');
181
+ assert.ok(s.terminal_where_ui_exists.count > 0, 'and it still finds the commands handed over');
182
+ assert.ok(r.result.score != null, 'so this profile still gets a number');
183
+ });
184
+
185
+ // ---- the harness is runnable, which is half of what the task asked for -----
186
+
187
+ test('the report renders and names every run', () => {
188
+ const out = H.formatReport(corpus);
189
+ assert.ok(out.includes(`floor ${H.FLOOR.toFixed(1)}`), 'the report states the floor it enforces');
190
+ assert.ok(out.includes('non-technical profile'), 'and both profiles appear');
191
+ assert.ok(out.includes('engineer profile'));
192
+
193
+ let json = '';
194
+ assert.equal(H.main(['--json'], (s) => { json += s; }), 0, 'the JSON mode exits clean');
195
+ const parsed = JSON.parse(json);
196
+ assert.equal(parsed.floor, H.FLOOR, 'and carries the floor a caller would check against');
197
+ });
198
+
199
+ console.log('');
200
+ console.log(`Accessibility two-profile replay (clauses 5 + 8): ${passed} passed, ${failed} failed`);
201
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,93 @@
1
+ {
2
+ "_comment": "The fixed beginner-prompt set for the accessibility replay regression (task 1002517, clauses 5 and 8 of criterion accessibility-fluency-profile). EIGHT prompts a newcomer to this instance actually asks; each one is the documented trigger of a real surface, so the click-paths named in the non-technical replies are paths that EXIST (Settings -> API keys, Settings -> Session, Settings -> Interaction, the hall task list, the hall blockers page, the hall leaderboard, the hall Deploy page). Prompt 'make-it-live' is the owner's own words, verbatim, 2026-08-22 (task 1003159). Each prompt carries TWO answer runs: the same question answered in the register each profile asks for. The runs are the CONTROLLED variable and the prompt set is fixed - that is what makes this a replay rather than a fixture parade. Nothing here is tuned to a target score: the replies are written in each register and the harness measures what falls out, so a detector that goes blind moves these numbers.",
3
+ "floor": 6.0,
4
+ "_floor_comment": "6.0 is not a new number. It is bandFor()'s 'workable' boundary in modules/sessions/accessibility.js - the point the scorer itself calls the difference between prose a newcomer can work with and prose that is merely dense. accessibility_replay.mjs asserts the two stay tied, so retuning the bands cannot silently move the floor.",
5
+ "prompts": [
6
+ { "id": "what-to-work-on", "prompt": "What can I work on?" },
7
+ { "id": "make-it-live", "prompt": "I don't think I can make anything live." },
8
+ { "id": "art-key", "prompt": "My image generation stopped working." },
9
+ { "id": "session-expired", "prompt": "It says my session expired." },
10
+ { "id": "how-you-talk", "prompt": "Can you stop using so much jargon with me?" },
11
+ { "id": "credits", "prompt": "How do credits work here?" },
12
+ { "id": "stuck", "prompt": "What's stuck waiting on me?" },
13
+ { "id": "why-slow", "prompt": "Why is that page so slow?" }
14
+ ],
15
+ "runs": {
16
+ "non_technical": {
17
+ "_comment": "The register the non-technical profile asks for: lead with the answer, name the click-path before the terminal, define a term the first time it is needed, and frame the trade-off as cost, time and what the reader will see. Two turns deliberately exercise the scorer's FORGIVING paths rather than dodging them - 'why-slow' turn 1 uses the word migration and defines it in the same breath, and turn 2 names the architecture AND what it costs - because a run that scores well only by avoiding every trigger proves nothing about the detectors.",
18
+ "profile": { "technical_level": "non_technical", "toolchain_familiarity": "new" },
19
+ "replies": {
20
+ "what-to-work-on": [
21
+ "Four jobs are open to you right now. They are on the hall task list, newest first, and each row shows what it pays and roughly how long it takes.",
22
+ "The second one is the quickest - about twenty minutes, and it pays thirty credits. Tell me its number and I will take it on for you."
23
+ ],
24
+ "make-it-live": [
25
+ "You can do this yourself now, and it is two clicks. In the hall, open Deploy: the page shows the version your site is actually serving and the newer one it would move to.",
26
+ "The first click previews the move and the second makes it. If the new version does not answer within a minute it puts the old one back on its own, so a bad release cannot leave your site down. It costs nothing and takes about two minutes."
27
+ ],
28
+ "art-key": [
29
+ "Your free art coverage has ended, so pictures now come out of an art key of your own - think of a key as a password that lets us make images on your account, and you pay only for what you make.",
30
+ "Add it in Settings, under API keys. Do not paste it to me in chat; the Settings page is the only place it should ever go. Most people spend a few dollars per month on this."
31
+ ],
32
+ "session-expired": [
33
+ "That message means your sign-in has timed out. Nothing is lost, and it happens every few weeks on purpose.",
34
+ "Open Settings, then Session, and choose Re-issue. That hands you a fresh code and I take it from there. It takes under a minute."
35
+ ],
36
+ "how-you-talk": [
37
+ "Yes - and it is a setting, so you will not have to keep asking. Open Settings, then Interaction, and set your technical level to non-technical.",
38
+ "Every session after that starts already knowing to define a term the first time it comes up, and to talk in cost, time and what you will see on screen. You will notice the difference in the very next reply."
39
+ ],
40
+ "credits": [
41
+ "Credits are the record of what building here actually cost. When a piece of work ships you earn what the session really cost plus twenty per cent, and one credit is one dollar.",
42
+ "The figure on a task row is only a guide - you are paid on the real cost, never the estimate. Your running total sits on the hall leaderboard."
43
+ ],
44
+ "stuck": [
45
+ "Four things are waiting on you rather than on me. Two need a yes or no and two need a number I cannot guess for you.",
46
+ "They are all on the hall blockers page, oldest first. The one at the top has been sitting eleven days and is holding up two other jobs, so it is the one worth five minutes."
47
+ ],
48
+ "why-slow": [
49
+ "The page re-reads the whole list every time someone opens it. The fix is a migration - that is a one-off change to the way the information is kept - and it would take about an hour.",
50
+ "The architecture underneath is fine; the cost is that repeated reading. After the change the page should open in under a second instead of four."
51
+ ]
52
+ }
53
+ },
54
+ "engineer": {
55
+ "_comment": "The same eight questions answered the way an engineer profile asks to be answered: terse, command-first, and in the vocabulary of the trade with nothing defined. This run is DELIBERATELY dense in all three signals - that is the point. Under the engineer profile it must not be scored for any of it; under the non-technical profile the very same prose must fall through the floor. One run, two profiles, opposite verdicts is what proves the dimension is profile-relative rather than a taste test.",
56
+ "profile": { "technical_level": "engineer", "toolchain_familiarity": "comfortable" },
57
+ "replies": {
58
+ "what-to-work-on": [
59
+ "Four ready. Run `node scripts/gds/start.js`, then claim a task by id.",
60
+ "Second row is a refactor behind the module boundary. The claim is idempotent, so re-running it is a no-op."
61
+ ],
62
+ "make-it-live": [
63
+ "Pin move is gate 3. `bongos upgrade --registry --to <ver> --commit-pin` on the control plane, or the Deploy page enqueues the same intent.",
64
+ "The control flow is enqueue, drain, health-gate, rollback. SIGTERM on a failed probe and the symlink goes back."
65
+ ],
66
+ "art-key": [
67
+ "Art key is unset. Run `node scripts/gds/api.js` against the settings route, or set it in Settings - either path ends up in the same env file.",
68
+ "The upstream call is async and the error surfaces on stderr, not in the hall."
69
+ ],
70
+ "session-expired": [
71
+ "Session token expired. Run `bongos auth --reissue`.",
72
+ "The JWT is short-lived by design and the refresh is idempotent, so a double call is harmless."
73
+ ],
74
+ "how-you-talk": [
75
+ "That is the interaction profile. Run `node scripts/gds/api.js PATCH /settings`, or set it in Settings.",
76
+ "It is injected per session by the Conductor hook and never memoized, so the change takes on the next turn."
77
+ ],
78
+ "credits": [
79
+ "Cost-plus. Run `node scripts/gds/api.js GET /builders/me` for your balance, or read the leaderboard.",
80
+ "The write is an upsert keyed on the session, so a replayed ship cannot double-pay."
81
+ ],
82
+ "stuck": [
83
+ "Four open blockers. Run `node scripts/gds/blocker-review.js` to walk them.",
84
+ "Resolving one will upsert the linked rows to ready inside the same transaction."
85
+ ],
86
+ "why-slow": [
87
+ "N+1 on the list read.",
88
+ "The ORM will hydrate each row separately. Fold it into one query against the schema with a jsonb aggregate."
89
+ ]
90
+ }
91
+ }
92
+ }
93
+ }
@@ -106,15 +106,17 @@ test('the verdict is offered only to a viewer the routes will accept', () => {
106
106
  assert.match(JS, /studio-verdict__sealed/, 'a viewer below the floor gets a sentence, not three dead buttons');
107
107
  });
108
108
 
109
- test('the review card shows the reviewed work\'s picture, through the kit', () => {
110
- // "the picture is the card" — and drawn by the kit's ONE full-size renderer
111
- // rather than a third hand-rolled <figure> (the task's own instruction).
112
- assert.match(JS, /kit\.taskVisualFigureHtml\(/, 'the plate comes from the kit renderer');
109
+ test('the review card shows the reviewed work\'s picture, through the shared renderer', () => {
110
+ // "the picture is the card" — and drawn by the ONE full-size renderer rather
111
+ // than a hand-rolled <figure>. That renderer moved out of hall-kit.js into
112
+ // hall-visual.js in task 1003835, so the Studio now reaches for OTBVisual; the
113
+ // rule this pins is unchanged, only where the one copy lives.
114
+ assert.match(JS, /vis\.taskVisualFigureHtml\(/, 'the plate comes from the shared renderer');
113
115
  assert.ok(!/<figure/.test(JS), 'studio.js builds no <figure> of its own');
114
- assert.match(HTML, /hall-kit\.js/, 'and the page loads the kit that owns it');
116
+ assert.match(HTML, /hall-visual\.js/, 'and the page loads the file that owns it');
115
117
  // The degrade: a review with no visual (or one that could not be read) draws
116
118
  // nothing at all rather than an empty frame, so it still reads as a review.
117
- assert.match(JS, /if \(!p\.shipped \|\| !kit/, 'no reviewed task, no kit → no plate');
119
+ assert.match(JS, /if \(!p\.shipped \|\| !vis/, 'no reviewed task, no renderer → no plate');
118
120
  // The reviewed task is read for the card in focus, once — not 50 times up front.
119
121
  assert.match(JS, /p\.hydrated = true;/, 'the read is made once per review');
120
122
  assert.match(JS, /\$\{API\}\/tasks\/\$\{encodeURIComponent\(p\.shippedId\)\}/, 'and it reads the SHIPPED task the review names');
@@ -204,7 +206,8 @@ test('the accent is spent only where the rule allows', () => {
204
206
  });
205
207
 
206
208
  // ---------------------------------------------------------------------------
207
- // Boot the REAL studio.js under a DOM stub, with the REAL hall-kit.js beside it
209
+ // Boot the REAL studio.js under a DOM stub, with the REAL hall-kit.js and
210
+ // hall-visual.js beside it
208
211
  // (the bootShell pattern below, pointed at the page script). The static-source
209
212
  // assertions above pin the wiring; these render the card and read what it
210
213
  // actually says, which is the only way to pin a DEGRADE — "no picture" and
@@ -215,11 +218,15 @@ test('the accent is spent only where the rule allows', () => {
215
218
  // NEW_TASK_ID so the "needs work" pair can be followed to its merge.
216
219
  const NEW_TASK_ID = '2001';
217
220
  async function bootStudio({ reviews = [], shipped = {}, rank = 'metic', flags = null, onWrite = null } = {}) {
218
- // The REAL kit, not a stub — the plate's URL guard and escaping are its code,
219
- // and a stub here would make the degrade tests below pass for the wrong reason
220
- // (no kit also means no plate). It is node-require-safe by its own contract.
221
+ // The REAL modules, not stubs — the plate's URL guard and escaping are their
222
+ // code, and a stub here would make the degrade tests below pass for the wrong
223
+ // reason (no renderer also means no plate). Both are node-require-safe by their
224
+ // own contract. hall-visual.js carries the renderer since task 1003835; the kit
225
+ // is still loaded because the page still uses the rest of it.
221
226
  const kit = (await import(`file://${path.join(HALL, 'hall-kit.js').replace(/\\/g, '/')}`)).default;
222
- assert.equal(typeof kit.taskVisualFigureHtml, 'function', 'the real kit loaded');
227
+ const vis = (await import(`file://${path.join(HALL, 'hall-visual.js').replace(/\\/g, '/')}`)).default;
228
+ assert.equal(typeof vis.taskVisualFigureHtml, 'function', 'the real visual module loaded');
229
+ assert.equal(typeof kit.taskVisualFigureHtml, 'undefined', 'and the kit no longer carries a second copy');
223
230
  const els = new Map();
224
231
  const el = (id) => {
225
232
  if (!els.has(id)) els.set(id, { id, innerHTML: '', textContent: '', hidden: false, value: '' });
@@ -258,6 +265,7 @@ async function bootStudio({ reviews = [], shipped = {}, rank = 'metic', flags =
258
265
  errMessage: (d) => (d && d.error && d.error.message) || '',
259
266
  },
260
267
  OTBKit: kit,
268
+ OTBVisual: vis,
261
269
  scrollTo: () => {},
262
270
  };
263
271
  const document = { addEventListener: (t, h) => handlers.push({ t, h }) };
@@ -13,13 +13,16 @@ import { createRequire } from 'node:module';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import fs from 'node:fs/promises';
16
+ import fsSync from 'node:fs';
16
17
  const require = createRequire(import.meta.url);
17
18
 
18
19
  // Point storage at a throwaway dir BEFORE requiring the module.
19
20
  const TMP = path.join(os.tmpdir(), `otb-taskvisual-${process.pid}`);
20
21
  process.env.TASK_VISUALS_DIR = TMP;
21
22
  const tv = require('../modules/lifecycle/task-visuals.js');
22
- const kit = require('../modules/hall-ui/public/hall-kit.js');
23
+ // The renderers left hall-kit.js for hall-visual.js (task 1003835) — the task
24
+ // record page draws a visual and needs nothing else the kit holds.
25
+ const vis = require('../modules/hall-ui/public/hall-visual.js');
23
26
 
24
27
  let passed = 0, failed = 0;
25
28
  function t(name, fn) {
@@ -140,7 +143,7 @@ await t('persistImageBuffer: writes a name the serve route accepts, under the ri
140
143
  assert.equal(tv.taskIdFromName(r.file), 1003109);
141
144
  assert.equal(r.url, `/api/bongos/task-visuals/${r.file}`);
142
145
  // Both prefixes are permanent aliases, so a URL minted under either resolves.
143
- assert.equal(kit.isTaskVisualUrl(r.url), true);
146
+ assert.equal(vis.isTaskVisualUrl(r.url), true);
144
147
  assert.equal(r.bytes, PNG.length);
145
148
  const onDisk = await fs.readFile(path.join(TMP, r.file));
146
149
  assert.deepEqual(onDisk, PNG);
@@ -170,23 +173,23 @@ await t('deleteStored: removes by name or URL, is idempotent, refuses a traversa
170
173
  });
171
174
 
172
175
  // ---- the client-side URL guard --------------------------------------------
173
- await t('hall-kit: only a server-minted visual URL is ever rendered', () => {
174
- assert.equal(kit.isTaskVisualUrl('/api/gds/task-visuals/task-1003109-0011223344556677.png'), true);
175
- assert.equal(kit.isTaskVisualUrl('/api/bongos/task-visuals/task-1-aabbccddeeff0011.webp'), true);
176
+ await t('hall-visual: only a server-minted visual URL is ever rendered', () => {
177
+ assert.equal(vis.isTaskVisualUrl('/api/gds/task-visuals/task-1003109-0011223344556677.png'), true);
178
+ assert.equal(vis.isTaskVisualUrl('/api/bongos/task-visuals/task-1-aabbccddeeff0011.webp'), true);
176
179
  // Anything that is not that exact shape renders nothing — including an
177
180
  // absolute URL to another origin, and a javascript: payload.
178
- assert.equal(kit.isTaskVisualUrl('https://evil.example/x.png'), false);
179
- assert.equal(kit.isTaskVisualUrl('javascript:alert(1)'), false);
180
- assert.equal(kit.isTaskVisualUrl('/api/gds/task-visuals/../../secrets'), false);
181
- assert.equal(kit.isTaskVisualUrl(''), false);
182
- assert.equal(kit.isTaskVisualUrl(null), false);
183
- });
184
-
185
- await t('hall-kit: no visual renders nothing at all — never a placeholder', () => {
186
- assert.equal(kit.shipVisualThumbHtml({ id: 1 }), '');
187
- assert.equal(kit.shipVisualThumbHtml({ id: 1, visual_url: null }), '');
188
- assert.equal(kit.shipVisualThumbHtml(null), '');
189
- const html = kit.shipVisualThumbHtml({ id: 1, visual_url: '/api/gds/task-visuals/task-1-0011223344556677.png', visual_alt: 'a "before" shot' });
181
+ assert.equal(vis.isTaskVisualUrl('https://evil.example/x.png'), false);
182
+ assert.equal(vis.isTaskVisualUrl('javascript:alert(1)'), false);
183
+ assert.equal(vis.isTaskVisualUrl('/api/gds/task-visuals/../../secrets'), false);
184
+ assert.equal(vis.isTaskVisualUrl(''), false);
185
+ assert.equal(vis.isTaskVisualUrl(null), false);
186
+ });
187
+
188
+ await t('hall-visual: no visual renders nothing at all — never a placeholder', () => {
189
+ assert.equal(vis.shipVisualThumbHtml({ id: 1 }), '');
190
+ assert.equal(vis.shipVisualThumbHtml({ id: 1, visual_url: null }), '');
191
+ assert.equal(vis.shipVisualThumbHtml(null), '');
192
+ const html = vis.shipVisualThumbHtml({ id: 1, visual_url: '/api/gds/task-visuals/task-1-0011223344556677.png', visual_alt: 'a "before" shot' });
190
193
  assert.ok(html.includes('ship-visual-thumb'));
191
194
  // The caption is attacker-adjacent text (a builder wrote it) — it must be escaped.
192
195
  assert.ok(html.includes('&quot;before&quot;'));
@@ -194,18 +197,18 @@ await t('hall-kit: no visual renders nothing at all — never a placeholder', ()
194
197
  });
195
198
 
196
199
  // ---- the full-size figure (task 1003829) -----------------------------------
197
- await t('hall-kit: the full-size figure is the same guard, at reading size', () => {
200
+ await t('hall-visual: the full-size figure is the same guard, at reading size', () => {
198
201
  // Same no-placeholder rule as the thumbnail: nothing at all without a visual.
199
- assert.equal(kit.taskVisualFigureHtml({ id: 1 }), '');
200
- assert.equal(kit.taskVisualFigureHtml({ id: 1, visual_url: null }), '');
201
- assert.equal(kit.taskVisualFigureHtml(null), '');
202
+ assert.equal(vis.taskVisualFigureHtml({ id: 1 }), '');
203
+ assert.equal(vis.taskVisualFigureHtml({ id: 1, visual_url: null }), '');
204
+ assert.equal(vis.taskVisualFigureHtml(null), '');
202
205
  // And the same URL wall — an off-origin src or a javascript: payload is not
203
206
  // "rendered without a caption", it is not rendered.
204
- assert.equal(kit.taskVisualFigureHtml({ id: 1, visual_url: 'https://evil.example/x.png' }), '');
205
- assert.equal(kit.taskVisualFigureHtml({ id: 1, visual_url: 'javascript:alert(1)' }), '');
207
+ assert.equal(vis.taskVisualFigureHtml({ id: 1, visual_url: 'https://evil.example/x.png' }), '');
208
+ assert.equal(vis.taskVisualFigureHtml({ id: 1, visual_url: 'javascript:alert(1)' }), '');
206
209
 
207
210
  const url = '/api/gds/task-visuals/task-1-0011223344556677.png';
208
- const html = kit.taskVisualFigureHtml({ id: 1, visual_url: url, visual_alt: 'a "before" shot' });
211
+ const html = vis.taskVisualFigureHtml({ id: 1, visual_url: url, visual_alt: 'a "before" shot' });
209
212
  assert.ok(html.includes('<figure class="task-visual">'));
210
213
  assert.ok(html.includes('class="task-visual__img"'));
211
214
  // The caption is the SHIPPER's text — attacker-adjacent, so escaped in both
@@ -215,19 +218,80 @@ await t('hall-kit: the full-size figure is the same guard, at reading size', ()
215
218
 
216
219
  // The caller owns the size, so it owns the class base; anything but
217
220
  // [A-Za-z0-9_-] is stripped rather than interpolated into the attribute.
218
- const studio = kit.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'studio-plate' });
221
+ const studio = vis.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'studio-plate' });
219
222
  assert.ok(studio.includes('<figure class="studio-plate">'));
220
223
  assert.ok(studio.includes('class="studio-plate__img"'));
221
- const hostile = kit.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'x" onload="a' });
224
+ const hostile = vis.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'x" onload="a' });
222
225
  assert.ok(hostile.includes('<figure class="xonloada">'), hostile);
223
226
 
224
227
  // No written alt → no caption at all. The fallback alt is a machine sentence
225
228
  // for a screen reader, never a caption shown to a reader.
226
- const bare = kit.taskVisualFigureHtml({ id: 7, visual_url: url });
229
+ const bare = vis.taskVisualFigureHtml({ id: 7, visual_url: url });
227
230
  assert.ok(!bare.includes('figcaption'));
228
231
  assert.ok(bare.includes('alt="Visual for task 7"'));
229
232
  });
230
233
 
234
+ // ---- the convergence is the deliverable (task 1003835) ---------------------
235
+ //
236
+ // Reading the source is the right altitude here: the defect this task closes is
237
+ // a SECOND definition existing, which no behavioural assertion against one of
238
+ // them can see. A duplicate renderer passes every test above.
239
+
240
+ const HALL = (f) => fsSync.readFileSync(new URL(`../modules/hall-ui/public/${f}`, import.meta.url), 'utf8');
241
+
242
+ await t('hall-kit no longer defines a visual renderer', () => {
243
+ const kitSrc = HALL('hall-kit.js');
244
+ // The function DEFINITIONS are what must be gone — the pointer comment naming
245
+ // them is the thing that tells the next reader where they went, so match on
246
+ // the definition syntax rather than on the bare name.
247
+ assert.ok(!/function\s+taskVisualFigureHtml\s*\(/.test(kitSrc), 'hall-kit.js still defines taskVisualFigureHtml');
248
+ assert.ok(!/function\s+shipVisualThumbHtml\s*\(/.test(kitSrc), 'hall-kit.js still defines shipVisualThumbHtml');
249
+ assert.ok(!/function\s+isTaskVisualUrl\s*\(/.test(kitSrc), 'hall-kit.js still defines isTaskVisualUrl');
250
+ // …and it must not re-export them either, or callers keep two ways in.
251
+ const kitApi = require('../modules/hall-ui/public/hall-kit.js');
252
+ assert.equal(kitApi.taskVisualFigureHtml, undefined);
253
+ assert.equal(kitApi.shipVisualThumbHtml, undefined);
254
+ assert.equal(kitApi.isTaskVisualUrl, undefined);
255
+ });
256
+
257
+ await t('task.js renders through the shared module, not its own <figure>', () => {
258
+ const src = HALL('task.js');
259
+ assert.ok(src.includes('vis.taskVisualFigureHtml(t, { className: \'tk-visual\' })'),
260
+ 'task.js should delegate to OTBVisual with its own class base');
261
+ // The hand-rolled markup is the thing that came back three times. If any of it
262
+ // reappears in this file, the convergence has been undone.
263
+ assert.ok(!src.includes('<figure class="tk-visual">'), 'task.js hand-rolls the figure again');
264
+ assert.ok(!src.includes('tk-visual__img'), 'task.js hand-rolls the img class again');
265
+ assert.ok(!src.includes('task-visuals/task-'), 'task.js carries its own copy of the URL guard again');
266
+ });
267
+
268
+ await t('every page whose script draws a visual actually loads hall-visual.js', () => {
269
+ // A missing <script> is the one failure this split introduces, and it is
270
+ // silent: the guard in each caller degrades to rendering nothing, so a visual
271
+ // would simply stop appearing with no error anywhere.
272
+ for (const page of ['index.html', 'profile.html', 'studio.html', 'task.html']) {
273
+ assert.ok(HALL(page).includes('/builders/hall-visual.js'), `${page} does not load hall-visual.js`);
274
+ }
275
+ });
276
+
277
+ await t('the callers reach for OTBVisual, and no longer for the kit', () => {
278
+ for (const f of ['hall-render.js', 'profile.js', 'studio.js', 'task.js']) {
279
+ const src = HALL(f);
280
+ assert.ok(!/OTBKit(\.|\[')?\s*(shipVisualThumbHtml|taskVisualFigureHtml|isTaskVisualUrl)/.test(src),
281
+ `${f} still reads a visual renderer off OTBKit`);
282
+ }
283
+ assert.ok(HALL('hall-render.js').includes('window.OTBVisual.shipVisualThumbHtml'));
284
+ assert.ok(HALL('studio.js').includes('vis.taskVisualFigureHtml('));
285
+ });
286
+
287
+ await t('the dead .td-visual rules are gone, and nothing emits the class', () => {
288
+ // task 1003829's comment and this task's own brief both asserted a second
289
+ // renderer in task-detail.js. `git log -S` shows that file never carried one:
290
+ // the CSS was the fossil of a renderer that was planned and not built.
291
+ assert.ok(!HALL('style.css').includes('.td-visual'), 'style.css still carries the dead rules');
292
+ assert.ok(!HALL('task-detail.js').includes('td-visual'));
293
+ });
294
+
231
295
  // ---- cleanup ---------------------------------------------------------------
232
296
  await fs.rm(TMP, { recursive: true, force: true });
233
297