@bongos/core 1.19.664 → 1.19.665

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.
@@ -28,15 +28,130 @@ const HTML = read('studio.html');
28
28
  const HTML_NC = HTML.replace(/<!--[\s\S]*?-->/g, '');
29
29
  const JS = read('studio.js');
30
30
  const CSS = read('studio.css');
31
+ // The file's comments legitimately NAME routes and rules it must not call (the
32
+ // header explains what the room does not do, and the allowlist documents each
33
+ // entry), so the route invariants below scan the CODE. Comments only — string
34
+ // contents are untouched, which is where a real route would live.
35
+ const JS_NC = JS.replace(/^\s*\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
31
36
 
32
- test('the Studio writes NOTHING — the goal\'s no-live-CMS rule, at the surface', () => {
33
- // Every craft act (coach the grader, remake copy, propose wording) happens on
34
- // the surface that owns it (the task record, the copy desk) as a claimed,
35
- // graded, shipped task. This room only READS and ROUTES. A POST/PATCH/DELETE
36
- // here would be the live-CMS write path the goal forbids (criterion
37
- // artist-edits-land-as-tasks) so it must not exist.
38
- const writes = JS.match(/\.request\(\s*['"](POST|PATCH|PUT|DELETE)['"]/g) || [];
39
- assert.deepEqual(writes, [], `studio.js must issue no writes; found ${writes.join(', ')}`);
37
+ // ---------------------------------------------------------------------------
38
+ // What the room may write — NARROWED, not dropped (task 1003829).
39
+ //
40
+ // This test used to assert studio.js issued NO write at all. Task 1003829
41
+ // relaxed that, deliberately and only this far: "The Studio's no-write posture
42
+ // is relaxed only for the artist's own verdict on their own queue — it still
43
+ // gates nothing and still holds no deploy." So the invariant becomes an
44
+ // ALLOWLIST. The thing goal 1000074 actually forbids a live CMS, a room that
45
+ // edits a shipped string or asset — is what the second test below pins, and it
46
+ // is the half that must never be relaxed again.
47
+ // ---------------------------------------------------------------------------
48
+
49
+ test('the Studio writes ONLY the artist\'s verdict on their own review queue', () => {
50
+ // Each permitted write moves the REVIEW TASK (dismiss it, note on it, file
51
+ // its follow-up and close it against that). Every one of them is a route the
52
+ // board's ordinary triage already had; none of them touches the product.
53
+ const allowed = [
54
+ /^POST \$\{API\}\/tasks\/\$\{reviewId\}\/abandon$/, // looks good → dismissed
55
+ /^PATCH \$\{API\}\/tasks\/\$\{reviewId\}$/, // note it → the words, on the review
56
+ /^POST \$\{API\}\/tasks$/, // needs work→ the follow-up task
57
+ /^POST \$\{API\}\/tasks\/\$\{reviewId\}\/merge$/, // …and the review closed against it
58
+ ];
59
+ // Every write site, as "<VERB> <route expression>" — the verb from the
60
+ // request call, the route from the WRITE_ROUTES entry it names.
61
+ const routes = new Map(
62
+ [...JS.matchAll(/^\s*(\w+):\s*\([^)]*\)\s*=>\s*`([^`]+)`,/gm)].map((m) => [m[1], m[2]])
63
+ );
64
+ // EVERY write site in the file, with the URL argument it was given — so a
65
+ // write cannot dodge the allowlist by naming its URL inline. That totality is
66
+ // what makes this test a wall and not a sample.
67
+ const sites = [...JS_NC.matchAll(/\.request\(\s*['"](POST|PATCH|PUT|DELETE)['"]\s*,\s*([^,)]+)/g)]
68
+ .map(([, verb, arg]) => {
69
+ const named = /^WRITE_ROUTES\.(\w+)\(/.exec(arg.trim());
70
+ return { verb, arg: arg.trim(), route: named ? routes.get(named[1]) : null };
71
+ });
72
+ assert.equal(sites.length, 4, `exactly the four verdict writes; found ${sites.length}`);
73
+ for (const s of sites) {
74
+ assert.ok(s.route, `every write names a WRITE_ROUTES entry, never an inline URL: ${s.verb} ${s.arg}`);
75
+ const site = `${s.verb} ${s.route}`;
76
+ assert.ok(allowed.some((re) => re.test(site)), `write outside the allowlist: ${site}`);
77
+ }
78
+ });
79
+
80
+ test('the Studio still edits no live string and no asset — the no-live-CMS rule', () => {
81
+ // The half that is NOT relaxed (criterion artist-edits-land-as-tasks). A copy
82
+ // change or an asset change reaches people only as a claimed, graded, shipped
83
+ // task, so this room must never call the routes that would write one directly.
84
+ const forbidden = [
85
+ /copy-desk\/proposals/, // wording — belongs to the copy desk, under a claim
86
+ /copy-desk\/flags\/[^'"`]*\/close/, // someone else's flag verdict — the copy desk's own act
87
+ /\/visual\b/, // POST/DELETE /tasks/:id/visual — the asset itself
88
+ /\/confirm\b/, /\/ship\b/, /\/grade\b/, /\/claims\b/, // the ship pipeline: it gates nothing
89
+ ];
90
+ for (const re of forbidden) {
91
+ assert.ok(!re.test(JS_NC), `the Studio must not write ${re}`);
92
+ }
93
+ // "Needs work" files a TASK and says so: the follow-up is ordinary work that
94
+ // somebody claims, not an edit this room performs.
95
+ assert.match(JS, /never edits a live string or asset/, 'the follow-up brief says what the room did not do');
96
+ });
97
+
98
+ test('the verdict is offered only to a viewer the routes will accept', () => {
99
+ // The task 1003751 lesson: a card must not print a verb its route refuses.
100
+ // All four verdict routes floor at metic (modules/government/catalog.js), so
101
+ // the weights are drawn only above that floor and everyone else is told where
102
+ // the verdict is recorded instead. Cosmetic — ADR 0016, the server enforces.
103
+ assert.match(JS, /RANK_ORDER\.metic/, 'the offer is gated on the routes\' own floor');
104
+ assert.match(JS, /function canVerdict\(\)/, 'and the gate is one named predicate');
105
+ assert.match(JS, /if \(!canVerdict\(\)\)/, 'read both by the renderer and by the handler');
106
+ assert.match(JS, /studio-verdict__sealed/, 'a viewer below the floor gets a sentence, not three dead buttons');
107
+ });
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');
113
+ 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');
115
+ // The degrade: a review with no visual (or one that could not be read) draws
116
+ // 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');
118
+ // The reviewed task is read for the card in focus, once — not 50 times up front.
119
+ assert.match(JS, /p\.hydrated = true;/, 'the read is made once per review');
120
+ assert.match(JS, /\$\{API\}\/tasks\/\$\{encodeURIComponent\(p\.shippedId\)\}/, 'and it reads the SHIPPED task the review names');
121
+ });
122
+
123
+ test('a security_sensitive task\'s visual is suppressed, as the server suppresses it', () => {
124
+ // modules/lifecycle/task-visuals.js isPubliclyViewable makes a
125
+ // security_sensitive task's image the one visual the platform does not
126
+ // re-publish; the public feed's SQL agrees. The room agrees too — it keeps the
127
+ // id and drops the rest, so the card degrades to the copy it always showed.
128
+ assert.match(JS, /security_sensitive === true \? \{ id: t\.id \}/, 'the row is stripped to its id when it is sensitive');
129
+ });
130
+
131
+ test('a note APPENDS to a freshly-read brief — it can never blank one', () => {
132
+ // PATCH description REPLACES the field. Appending to a base that is stale, or
133
+ // that a narrowed list projection simply did not carry, would delete the
134
+ // cascade's brief instead of adding to it — so the base is re-read and the
135
+ // write is skipped entirely when that read fails.
136
+ assert.match(JS_NC, /const cur = await api\.request\('GET', `\$\{API\}\/tasks\/\$\{p\.taskId\}`\);/, 'the current brief is re-read');
137
+ assert.match(JS_NC, /if \(base === null\) \{/, 'and a failed re-read writes nothing');
138
+ assert.match(JS_NC, /description: `\$\{base\}\$\{block\}`/, 'the write is base + the note, in that order');
139
+ // Ordering, stated as code: the GET must precede the PATCH in the function.
140
+ const fn = /async function verdictNote[\s\S]*?\n \}/.exec(JS_NC)[0];
141
+ assert.ok(fn.indexOf("request('GET'") < fn.indexOf("request('PATCH'"), 'read before write');
142
+ });
143
+
144
+ test('the three weights are the whole verdict, and two of them require words', () => {
145
+ // The room asks for nothing else (the task's DONE WHEN): one field, three
146
+ // answers. "looks good" may be wordless; a note with no note and a follow-up
147
+ // with no brief are refused inline rather than filed empty.
148
+ assert.match(JS, /const VERDICTS = \{ good: verdictGood, note: verdictNote, work: verdictWork \};/, 'exactly three weights');
149
+ assert.match(JS, /if \(!said\) return verdictError\('Write what you saw first/, 'a note needs words');
150
+ assert.match(JS, /if \(!said\) return verdictError\('Write what needs work first/, 'a follow-up needs a brief');
151
+ // The follow-up is idempotent on (source, source_ref), so a double-click
152
+ // resolves to the same task instead of filing two.
153
+ assert.match(JS, /source_ref: `studio-needs-work-on-review-\$\{p\.taskId\}`/, 'the follow-up is keyed for idempotency');
154
+ assert.match(JS, /let busy = false;/, 'and one verdict runs at a time');
40
155
  });
41
156
 
42
157
  test('the reviews read is bounded and correct', () => {
@@ -88,6 +203,318 @@ test('the accent is spent only where the rule allows', () => {
88
203
  assert.ok(accentFills.length <= 1, `studio.css spends the accent on at most the lamp; found ${accentFills.length}`);
89
204
  });
90
205
 
206
+ // ---------------------------------------------------------------------------
207
+ // Boot the REAL studio.js under a DOM stub, with the REAL hall-kit.js beside it
208
+ // (the bootShell pattern below, pointed at the page script). The static-source
209
+ // assertions above pin the wiring; these render the card and read what it
210
+ // actually says, which is the only way to pin a DEGRADE — "no picture" and
211
+ // "picture suppressed" both have to produce a review that still reads as one.
212
+ // ---------------------------------------------------------------------------
213
+ // `onWrite` decides what each non-GET answers, so a test can drive the happy
214
+ // path AND the refusals. Default: every write succeeds, and POST /tasks mints
215
+ // NEW_TASK_ID so the "needs work" pair can be followed to its merge.
216
+ const NEW_TASK_ID = '2001';
217
+ 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
+ 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');
223
+ const els = new Map();
224
+ const el = (id) => {
225
+ if (!els.has(id)) els.set(id, { id, innerHTML: '', textContent: '', hidden: false, value: '' });
226
+ return els.get(id);
227
+ };
228
+ const calls = [];
229
+ const request = async (method, url, body) => {
230
+ calls.push({ method, url, body });
231
+ if (method !== 'GET') {
232
+ const custom = onWrite && onWrite({ method, url, body });
233
+ if (custom) return custom;
234
+ if (/\/tasks$/.test(url)) return { ok: true, data: { task: { id: NEW_TASK_ID } } };
235
+ return { ok: true, data: { ok: true } };
236
+ }
237
+ if (/\/me$/.test(url)) return { ok: true, data: { builder: { display_name: 'Artist A', rank } } };
238
+ if (/\/tasks\?discipline=artist/.test(url)) return { ok: true, data: { tasks: reviews } };
239
+ if (/\/copy-desk\/queue/.test(url)) return flags ? { ok: true, data: { flags } } : { ok: false, data: {} };
240
+ // GET /tasks/:id answers for BOTH kinds of row the room reads by id: the
241
+ // reviewed (shipped) task the card's picture comes from, and the review
242
+ // itself, which "note it" re-reads before appending. `shipped` is the
243
+ // per-test control for the first; the queue rows serve the second, as the
244
+ // real route would. An id in neither is a 404, which is its own test.
245
+ const m = /\/tasks\/(\d+)$/.exec(url);
246
+ if (m && shipped[m[1]]) return { ok: true, data: { task: shipped[m[1]] } };
247
+ const review = m && reviews.find((r) => String(r.id) === m[1]);
248
+ if (review) return { ok: true, data: { task: review } };
249
+ return { ok: false, data: {} };
250
+ };
251
+ const handlers = [];
252
+ const window = {
253
+ BongosClient: { createClient: () => ({ request }) },
254
+ OTB: {
255
+ escapeHtml: (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
256
+ $: (sel) => el(String(sel).replace(/^#/, '')),
257
+ toast: () => {},
258
+ errMessage: (d) => (d && d.error && d.error.message) || '',
259
+ },
260
+ OTBKit: kit,
261
+ scrollTo: () => {},
262
+ };
263
+ const document = { addEventListener: (t, h) => handlers.push({ t, h }) };
264
+ const sandbox = { window, document, console, setTimeout, clearTimeout, Date, encodeURIComponent };
265
+ sandbox.globalThis = sandbox;
266
+ vm.createContext(sandbox);
267
+ vm.runInContext(JS, sandbox, { filename: 'studio.js' });
268
+ const settle = async (n = 8) => { for (let i = 0; i < n; i++) await new Promise((r) => setImmediate(r)); };
269
+ // The boot IIFE awaits /me, then the two queue reads, then paint() fires the
270
+ // lazy reviewed-task read which repaints.
271
+ await settle();
272
+ // Fire the page's OWN delegated click handler with the element it looks for,
273
+ // so the verdict flows are exercised rather than read. `say` fills the words
274
+ // field first, exactly as a person would.
275
+ const fire = async (attr, value, { say = null } = {}) => {
276
+ if (say !== null) el('studio-words').value = say;
277
+ const node = { dataset: { [attr]: value } };
278
+ const ev = { target: { closest: (sel) => (sel === `[data-${attr}]` ? node : null) } };
279
+ for (const { t, h } of handlers) if (t === 'click') h(ev);
280
+ await settle();
281
+ };
282
+ return {
283
+ focus: () => el('studio-focus').innerHTML,
284
+ err: () => ({ text: el('studio-verdict-err').textContent, hidden: el('studio-verdict-err').hidden }),
285
+ field: () => el('studio-words').value,
286
+ writes: () => calls.filter((c) => c.method !== 'GET'),
287
+ calls,
288
+ verdict: (kind, opts) => fire('verdict', kind, opts),
289
+ skip: () => fire('skip', ''),
290
+ settle,
291
+ };
292
+ }
293
+
294
+ const REVIEW = {
295
+ id: '1900',
296
+ title: 'Artist review: the copy and visuals that shipped in task 1899',
297
+ description: 'STEP 1 — coach the grader.',
298
+ status: 'backlog',
299
+ source: 'cascade',
300
+ source_ref: 'cascade-artist-review-on-ship-1899',
301
+ goal_id: '1000074',
302
+ version_id: 'BONGOS-V2',
303
+ created_at: '2026-09-04T12:30:00.000Z',
304
+ };
305
+ const VISUAL_URL = '/api/bongos/task-visuals/task-1899-0011223344556677.png';
306
+
307
+ test('a review WITH a visual shows the picture, and folds the steps behind the door', async () => {
308
+ const { focus, calls } = await bootStudio({
309
+ reviews: [REVIEW],
310
+ shipped: { 1899: { id: '1899', visual_url: VISUAL_URL, visual_alt: 'The new hero, dark mode', security_sensitive: false } },
311
+ });
312
+ const html = focus();
313
+ assert.match(html, /class="studio-plate"/, 'the plate is drawn');
314
+ assert.match(html, /src="\/api\/bongos\/task-visuals\/task-1899-0011223344556677\.png"/, 'from the reviewed task\'s own visual');
315
+ assert.match(html, /studio-plate__caption">The new hero, dark mode/, 'with visual_alt as the caption');
316
+ // With a picture on it the card is the picture, one line of framing, and the
317
+ // verdict — the steps describe what happens after the door, not before it.
318
+ assert.ok(!/studio-steps/.test(html), 'the two steps are not between the artist and their answer');
319
+ assert.match(html, /Say what you see/, 'one short line of framing instead');
320
+ assert.match(html, /data-verdict="good"/, 'and the three weights are there');
321
+ // ONE extra read for the card in focus, not one per queued review.
322
+ const reads = calls.filter((c) => /\/tasks\/\d+$/.test(c.url));
323
+ assert.equal(reads.length, 1, `one reviewed-task read; made ${reads.length}`);
324
+ });
325
+
326
+ test('a review with NO visual degrades to the copy it always showed', async () => {
327
+ const { focus } = await bootStudio({
328
+ reviews: [REVIEW],
329
+ shipped: { 1899: { id: '1899', visual_url: null, security_sensitive: false } },
330
+ });
331
+ const html = focus();
332
+ assert.ok(!/studio-plate/.test(html), 'no empty frame');
333
+ assert.ok(!/<img/.test(html), 'and no image at all');
334
+ assert.match(html, /studio-steps/, 'both craft steps are back');
335
+ assert.match(html, /coaches the grader on how it judged the work/, 'as is the full framing paragraph');
336
+ assert.match(html, /data-verdict="good"/, 'the verdict is still offered — this is still a review');
337
+ });
338
+
339
+ test('a security_sensitive reviewed task shows no picture, and still reads as a review', async () => {
340
+ const { focus } = await bootStudio({
341
+ reviews: [REVIEW],
342
+ shipped: { 1899: { id: '1899', visual_url: VISUAL_URL, visual_alt: 'the exploit', security_sensitive: true } },
343
+ });
344
+ const html = focus();
345
+ assert.ok(!/task-visuals/.test(html), 'the URL never reaches the page');
346
+ assert.ok(!/the exploit/.test(html), 'nor does the alt text');
347
+ assert.match(html, /studio-steps/, 'it degrades to the copy, exactly like a review with no visual');
348
+ });
349
+
350
+ test('a reviewed task that cannot be read leaves a working review card', async () => {
351
+ // A 404/403 on the reviewed task (it was merged, or the viewer cannot see it)
352
+ // must cost the artist nothing: the card is the one it always was.
353
+ const { focus } = await bootStudio({ reviews: [REVIEW], shipped: {} });
354
+ const html = focus();
355
+ assert.ok(!/studio-plate/.test(html));
356
+ assert.match(html, /studio-steps/);
357
+ assert.match(html, /The copy and visuals that shipped in task #1899/);
358
+ });
359
+
360
+ test('below the routes\' floor the card offers a sentence, not three refusals', async () => {
361
+ const { focus } = await bootStudio({
362
+ reviews: [REVIEW],
363
+ shipped: { 1899: { id: '1899', visual_url: VISUAL_URL, visual_alt: 'a', security_sensitive: false } },
364
+ rank: 'xenos',
365
+ });
366
+ const html = focus();
367
+ assert.ok(!/data-verdict=/.test(html), 'no weight is drawn');
368
+ assert.match(html, /studio-verdict__sealed/, 'the sentence is');
369
+ assert.match(html, /open it to dismiss it, note what you saw, or file the follow-up/);
370
+ // The picture is NOT rank-gated — looking is every craft's business.
371
+ assert.match(html, /studio-plate/, 'and the artist can still see the work');
372
+ });
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // The verdict flows, FIRED rather than read. The static assertions above pin
376
+ // the wiring and the allowlist; these click the page's own delegated handler
377
+ // and inspect what it actually sent and what state it left behind — the splice
378
+ // math in retire(), the double-submit guard, and the exact request bodies.
379
+ // ---------------------------------------------------------------------------
380
+ const SHIPPED_WITH_VISUAL = { 1899: { id: '1899', visual_url: VISUAL_URL, visual_alt: 'The new hero', security_sensitive: false } };
381
+ const three = () => [
382
+ { ...REVIEW, id: '1900', source_ref: 'cascade-artist-review-on-ship-1899' },
383
+ { ...REVIEW, id: '1901', source_ref: 'cascade-artist-review-on-ship-1898', created_at: '2026-09-03T00:00:00.000Z' },
384
+ { ...REVIEW, id: '1902', source_ref: 'cascade-artist-review-on-ship-1897', created_at: '2026-09-02T00:00:00.000Z' },
385
+ ];
386
+
387
+ test('FIRE looks good: one abandon, carrying who said so, and the stone is gone', async () => {
388
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL });
389
+ assert.match(s.focus(), /1 of 3/);
390
+ await s.verdict('good', { say: 'The hero lands.' });
391
+ const w = s.writes();
392
+ assert.equal(w.length, 1, `exactly one write; got ${JSON.stringify(w.map((c) => c.method + ' ' + c.url))}`);
393
+ assert.match(w[0].url, /\/tasks\/1900\/abandon$/, 'the REVIEW is abandoned, not the reviewed task');
394
+ assert.match(w[0].body.reason, /Reviewed in the studio by Artist A/, 'the reason names who judged it');
395
+ assert.match(w[0].body.reason, /The hero lands\./, 'and carries the optional words');
396
+ // retire() dropped it and the count re-read: 3 → 2, and the NEXT review is in
397
+ // focus rather than the list renumbering around a hole.
398
+ assert.match(s.focus(), /1 of 2/, 'the garden shrank and did not skip');
399
+ assert.match(s.focus(), /task #1898/, 'the next stone is the one that was behind it');
400
+ });
401
+
402
+ test('FIRE looks good with no words: still one abandon, no empty-words refusal', async () => {
403
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL });
404
+ await s.verdict('good');
405
+ assert.equal(s.writes().length, 1, '"looks good" is the one weight that needs no words');
406
+ assert.ok(!/undefined|null/.test(s.writes()[0].body.reason), 'and says nothing about absent words');
407
+ });
408
+
409
+ test('FIRE note it: the brief is re-read, appended, and the review STAYS in the garden', async () => {
410
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL });
411
+ await s.verdict('note', { say: 'The sub-line reads like a brochure.' });
412
+ const w = s.writes();
413
+ assert.equal(w.length, 1);
414
+ assert.equal(w[0].method, 'PATCH');
415
+ assert.match(w[0].url, /\/tasks\/1900$/);
416
+ // The cascade's own brief survives, with the note after it — the failure mode
417
+ // here is a PATCH that REPLACES the body, so assert both halves are present.
418
+ assert.match(w[0].body.description, /^STEP 1 — coach the grader\./, 'the original brief is still the start of the field');
419
+ assert.match(w[0].body.description, /ARTIST NOTE \(\d{4}-\d{2}-\d{2}, Artist A, from the studio\)/, 'the note is stamped and attributed');
420
+ assert.match(w[0].body.description, /The sub-line reads like a brochure\.$/, 'and ends with what was written');
421
+ // A note is not a dismissal.
422
+ assert.match(s.focus(), /1 of 3/, 'the review is still waiting');
423
+ assert.equal(s.field(), '', 'the field is cleared for the next thought');
424
+ assert.equal(s.err().hidden, true);
425
+ });
426
+
427
+ test('FIRE note it with no words: nothing is sent at all', async () => {
428
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL });
429
+ await s.verdict('note', { say: ' ' });
430
+ assert.deepEqual(s.writes(), [], 'no write');
431
+ assert.deepEqual(s.calls.filter((c) => /\/tasks\/1900$/.test(c.url)), [], 'and not even the re-read');
432
+ assert.match(s.err().text, /Write what you saw first/);
433
+ assert.equal(s.err().hidden, false);
434
+ });
435
+
436
+ test('FIRE needs work: a task in the reviewed work\'s goal, then the review closed against it', async () => {
437
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL });
438
+ await s.verdict('work', { say: 'The plate is muddy at 320.' });
439
+ const w = s.writes();
440
+ assert.equal(w.length, 2, 'the pair: create, then merge');
441
+ const [create, merge] = w;
442
+ assert.match(create.url, /\/tasks$/);
443
+ assert.equal(create.body.goal_id, 1000074, 'the follow-up lands in the reviewed work\'s goal, as a number');
444
+ assert.equal(create.body.version_id, 'BONGOS-V2');
445
+ assert.equal(create.body.discipline, 'artist');
446
+ assert.equal(create.body.source_ref, 'studio-needs-work-on-review-1900', 'keyed so a double-click cannot file twice');
447
+ assert.match(create.body.description, /The plate is muddy at 320\./, 'the artist\'s words are the brief');
448
+ assert.match(create.body.description, /never edits a live string or asset/, 'and the brief says this is ordinary work');
449
+ assert.match(merge.url, /\/tasks\/1900\/merge$/, 'the REVIEW is the one closed');
450
+ assert.equal(merge.body.into_task_id, Number(NEW_TASK_ID), 'against the task just filed');
451
+ assert.match(s.focus(), /1 of 2/, 'and the review left the garden');
452
+ });
453
+
454
+ test('FIRE needs work when the merge fails: the filed task is NAMED, not lost', async () => {
455
+ // The half-done case is the one that matters: the follow-up exists, so the
456
+ // artist must be told its id rather than left thinking nothing happened.
457
+ const s = await bootStudio({
458
+ reviews: three(),
459
+ shipped: SHIPPED_WITH_VISUAL,
460
+ onWrite: ({ url }) => (/\/merge$/.test(url) ? { ok: false, data: { error: { code: 'x', message: 'no' } } } : null),
461
+ });
462
+ await s.verdict('work', { say: 'Muddy.' });
463
+ assert.equal(s.writes().length, 2, 'both halves were attempted');
464
+ assert.match(s.err().text, new RegExp(`Task ${NEW_TASK_ID} was filed`), 'the id is in the message');
465
+ assert.match(s.focus(), /1 of 3/, 'and the review stays put rather than vanishing unclosed');
466
+ });
467
+
468
+ test('FIRE a refused abandon: the review stays, and the server\'s own sentence is shown', async () => {
469
+ const s = await bootStudio({
470
+ reviews: three(),
471
+ shipped: SHIPPED_WITH_VISUAL,
472
+ onWrite: () => ({ ok: false, data: { error: { code: 'rank_forbidden', message: 'metic or above' } } }),
473
+ });
474
+ await s.verdict('good');
475
+ assert.equal(s.err().text, 'metic or above', 'the route\'s message, not a generic one');
476
+ assert.match(s.focus(), /1 of 3/, 'nothing was retired on a refusal');
477
+ });
478
+
479
+ test('FIRE two clicks: the second is swallowed, so a double-click files once', async () => {
480
+ // The busy flag, exercised: the write is held open, both clicks land, and only
481
+ // one request is made. Without the guard "needs work" files two tasks.
482
+ let release;
483
+ const held = new Promise((r) => { release = r; });
484
+ const s = await bootStudio({
485
+ reviews: three(),
486
+ shipped: SHIPPED_WITH_VISUAL,
487
+ onWrite: async () => { await held; return { ok: true, data: { ok: true } }; },
488
+ });
489
+ const first = s.verdict('good');
490
+ await s.verdict('good');
491
+ assert.equal(s.writes().length, 1, 'the second click did not start a second write');
492
+ release({ ok: true, data: { ok: true } });
493
+ await first;
494
+ await s.settle();
495
+ assert.equal(s.writes().length, 1);
496
+ });
497
+
498
+ test('FIRE below the floor: a forged click writes nothing', async () => {
499
+ // The weights are not drawn for a xenos — but the handler is delegated, so
500
+ // canVerdict() is re-checked in runVerdict rather than trusted to the render.
501
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL, rank: 'xenos' });
502
+ await s.verdict('good', { say: 'fine' });
503
+ await s.verdict('work', { say: 'not fine' });
504
+ assert.deepEqual(s.writes(), [], 'the room sends nothing it was not allowed to offer');
505
+ });
506
+
507
+ test('FIRE skip: it advances and writes nothing, and the last skip ends quietly', async () => {
508
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL });
509
+ await s.skip();
510
+ assert.match(s.focus(), /2 of 3/);
511
+ await s.skip();
512
+ await s.skip();
513
+ assert.deepEqual(s.writes(), [], 'skipping is not a verdict');
514
+ // Nothing was tended, so the emptied room reads as quiet, not as finished.
515
+ assert.match(s.focus(), /Nothing waits right now|tended everything/);
516
+ });
517
+
91
518
  // Boot the real shell.js under a DOM stub (the hall_nav.mjs pattern) whose
92
519
  // #nav-studio records classList toggles, so applyCraftEmphasis is tested for
93
520
  // what it DOES, not just that it exists.
@@ -163,7 +163,12 @@ const HALL_SCRIPTS = ['modules/hall-ui/public/builders.js', 'modules/hall-ui/pub
163
163
  // it needs both the tier order (its `rank` sort lens) and the known-rank test
164
164
  // (the badge variant class). Browser code cannot require db-kernel, so it takes
165
165
  // the pinned-copy shape builders.js already takes.
166
- 'modules/hall-ui/public/roster.js'];
166
+ 'modules/hall-ui/public/roster.js',
167
+ // task 1003829: the Studio's verdict row offers four writes that all floor at
168
+ // METIC server-side, so the room reads the tier order to decide whether to draw
169
+ // them at all. Browser code cannot require db-kernel, so it takes the same
170
+ // pinned-copy shape builders.js and roster.js take.
171
+ 'modules/hall-ui/public/studio.js'];
167
172
  // Filtered INSIDE the test, not at module scope: extractRankMaps is declared
168
173
  // further down, so calling it up here is a temporal-dead-zone crash.
169
174
  const staticMapFiles = () => HALL_SCRIPTS.filter(
@@ -198,6 +203,8 @@ const MAP_ALLOWLIST = new Set([
198
203
  'modules/hall-ui/public/goals-render.js',
199
204
  // task 1003441 — see the HALL_SCRIPTS note above: pinned, not unchecked.
200
205
  'modules/hall-ui/public/roster.js',
206
+ // task 1003829 — see the HALL_SCRIPTS note above: pinned, not unchecked.
207
+ 'modules/hall-ui/public/studio.js',
201
208
  ]);
202
209
  // Where a rank-ladder array literal is allowed to exist.
203
210
  const LADDER_ALLOWLIST = new Set([
@@ -193,6 +193,41 @@ await t('hall-kit: no visual renders nothing at all — never a placeholder', ()
193
193
  assert.ok(!html.includes('"before"'));
194
194
  });
195
195
 
196
+ // ---- the full-size figure (task 1003829) -----------------------------------
197
+ await t('hall-kit: the full-size figure is the same guard, at reading size', () => {
198
+ // 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
+ // And the same URL wall — an off-origin src or a javascript: payload is not
203
+ // "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)' }), '');
206
+
207
+ 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' });
209
+ assert.ok(html.includes('<figure class="task-visual">'));
210
+ assert.ok(html.includes('class="task-visual__img"'));
211
+ // The caption is the SHIPPER's text — attacker-adjacent, so escaped in both
212
+ // the caption and the alt.
213
+ assert.ok(html.includes('<figcaption class="task-visual__caption">a &quot;before&quot; shot'));
214
+ assert.ok(!html.includes('"before"'));
215
+
216
+ // The caller owns the size, so it owns the class base; anything but
217
+ // [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' });
219
+ assert.ok(studio.includes('<figure class="studio-plate">'));
220
+ assert.ok(studio.includes('class="studio-plate__img"'));
221
+ const hostile = kit.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'x" onload="a' });
222
+ assert.ok(hostile.includes('<figure class="xonloada">'), hostile);
223
+
224
+ // No written alt → no caption at all. The fallback alt is a machine sentence
225
+ // for a screen reader, never a caption shown to a reader.
226
+ const bare = kit.taskVisualFigureHtml({ id: 7, visual_url: url });
227
+ assert.ok(!bare.includes('figcaption'));
228
+ assert.ok(bare.includes('alt="Visual for task 7"'));
229
+ });
230
+
196
231
  // ---- cleanup ---------------------------------------------------------------
197
232
  await fs.rm(TMP, { recursive: true, force: true });
198
233