@bongos/core 1.19.1066 → 1.19.1068

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,429 @@
1
+ // tests/hall_studio_home.mjs — the studio home (task 1003971 / BV2.TW16, ADR
2
+ // 0341 D2, D5, D8 and D11; docs/design/mocks/tweak-mode/Main.dc.html).
3
+ //
4
+ // Two halves. The pure rules (studio-lib.js) run over what the REAL composers
5
+ // (modules/copy-desk/page-status.js) answer for a small ledger, so the words the
6
+ // studio shows are tested on the routes' own shapes, never on a literal of them.
7
+ // Then the REAL page: studio.html's own body in a mini-DOM, with the real
8
+ // dom-utils.js, tweak-editor-lib.js, studio-lib.js and studio.js in a vm over a
9
+ // stub client, driven the way an artist would: the tally, Resume, the three
10
+ // panels, "take it", and the studio's own words typed over in place.
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import vm from 'node:vm';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { makeRunner, makeEl, parseInto, walk, settle } from './helpers.mjs';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const PUB = path.join(ROOT, 'modules/hall-ui/public');
23
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
24
+ const lib = require('../modules/hall-ui/public/studio-lib.js');
25
+ const pages = require('../modules/copy-desk/pages.js');
26
+ const ps = require('../modules/copy-desk/page-status.js');
27
+
28
+ const { test, summary } = makeRunner();
29
+
30
+ // ── a small ledger, through the real composers ──────────────────────────────
31
+ const ME = '7';
32
+ const NOW = new Date('2026-09-28T12:00:00.000Z');
33
+ const day = (n) => new Date(NOW.getTime() - n * 86400000).toISOString();
34
+ const INV = {
35
+ surfaces: [{ id: 'landing' }, { id: 'builders' }, { id: 'status' }],
36
+ pages: [
37
+ { id: 'landing:index', surface: 'landing', title: '{{productName}}: {{tagline}}', path: '/', files: [] },
38
+ { id: 'landing:projects', surface: 'landing', title: '{{productName}} — projects', path: '/projects', files: [] },
39
+ { id: 'builders:studio', surface: 'builders', title: 'Studio', path: '/studio', files: [] },
40
+ { id: 'builders:work', surface: 'builders', title: 'Tasks', path: '/work', files: [] },
41
+ { id: 'status:index', surface: 'status', title: 'Status', path: '/', files: [] },
42
+ ],
43
+ };
44
+ const page = (id) => INV.pages.find((p) => p.id === id);
45
+ const TASKS = [
46
+ { id: '11', source: 'page-tweak', source_ref: pages.roundSourceRef('landing:index', 1), status: 'shipped', shipped_at: day(9), artist_id: ME, description: pages.roundTaskBody(page('landing:index'), 1) },
47
+ { id: '12', source: 'page-tweak', source_ref: pages.roundSourceRef('status:index', 1), status: 'shipped', shipped_at: day(2), artist_id: ME, description: pages.roundTaskBody(page('status:index'), 1) },
48
+ { id: '13', source: 'page-tweak', source_ref: pages.roundSourceRef('landing:index', 2), status: 'active', holder_id: ME, holder_login: 'me', holder_worktree: false, description: pages.roundTaskBody(page('landing:index'), 2) },
49
+ ];
50
+ const CREDITS = [
51
+ { reason: 'tweak.page_approved:11', delta: 30, recorded_at: day(9) },
52
+ { reason: 'tweak.page_approved:12', delta: 30, recorded_at: day(2) },
53
+ ];
54
+ const ASKS = [{ page_id: 'landing:projects', askers: 2, oldest_ask_at: day(3) }];
55
+ const TALLY = ps.composeTally({ builderId: ME, tasks: TASKS, creditRows: CREDITS, now: NOW });
56
+ const PAGES = ps.composePages({ inventory: INV, readings: null, tasks: TASKS });
57
+ const NEXT = ps.composeNext({ inventory: INV, readings: null, tasks: TASKS, asks: ASKS, builderId: ME });
58
+
59
+ // The studio's own reading (the shape page-reader.js writes): the greeting, the
60
+ // sub-line's template, and a generic holed line that must never win.
61
+ const L = (key, section, text, placement) => ({ key, section, text, placement, file: 'modules/hall-ui/public/studio.html', line: 1, string_id: key });
62
+ const STUDIO_READING = {
63
+ id: 'builders:studio',
64
+ reading_hash: 'rs-1',
65
+ lines: [
66
+ L('L0001', 'skip link', 'Skip to content', 'shared'),
67
+ L('L0002', 'Good evening.', 'Good evening.', 'placed'),
68
+ L('L0003', 'Good evening.', '{…}, on the {…} page, is where you left it. {…} of its {…} lines are yours so far.', 'placed'),
69
+ L('L0004', 'Good evening.', '{…} of {…}', 'placed'),
70
+ L('L0005', 'Good evening.', 'Approval queue', 'placed'),
71
+ ],
72
+ };
73
+ function studioRead({ held = false, draftLines = null } = {}) {
74
+ let description = pages.roundTaskBody(page('builders:studio'), 1);
75
+ if (draftLines) {
76
+ const r = pages.resolveDraft({ readingPage: STUDIO_READING, body: { reading_hash: 'rs-1', lines: draftLines }, savedAt: day(1) });
77
+ assert.equal(r.ok, true, JSON.stringify(r));
78
+ description += '\n\n' + pages.composeDraftBlock(r.draft);
79
+ }
80
+ const round = held ? { id: 70, status: 'active', holder_id: ME, holder_login: 'me', holder_worktree: false, description } : null;
81
+ return ps.composeEditor({ page: page('builders:studio'), readingPage: STUDIO_READING, round, builderId: ME });
82
+ }
83
+ const RESUME_READ = { ok: true, counter: { changed: 12, total: 38 } };
84
+
85
+ // ── the mini-DOM over the page's own markup ─────────────────────────────────
86
+ const HTML = read('studio.html');
87
+ const BODY = /<body[^>]*>([\s\S]*)<\/body>/.exec(HTML)[1].replace(/<script[\s\S]*?<\/script>/g, '');
88
+ const SOURCES = ['dom-utils.js', 'tweak-editor-lib.js', 'studio-lib.js', 'studio.js'].map((f) => [f, read(f)]);
89
+ let boot = null;
90
+
91
+ function augment(el) {
92
+ if (el._aug) return el;
93
+ el._aug = true;
94
+ const classes = () => String(el.className || '').split(/\s+/).filter(Boolean);
95
+ el.classList = {
96
+ add: (c) => { const s = new Set(classes()); s.add(c); el.className = [...s].join(' '); },
97
+ remove: (c) => { el.className = classes().filter((x) => x !== c).join(' '); },
98
+ toggle: (c, force) => { const on = force === undefined ? !classes().includes(c) : !!force; if (on) el.classList.add(c); else el.classList.remove(c); return on; },
99
+ contains: (c) => classes().includes(c),
100
+ };
101
+ for (const attr of ['hidden', 'disabled']) {
102
+ Object.defineProperty(el, attr, { get() { return attr in el.attrs; }, set(v) { if (v) el.attrs[attr] = ''; else delete el.attrs[attr]; }, configurable: true });
103
+ }
104
+ Object.defineProperty(el, 'href', { get() { return el.attrs.href || ''; }, set(v) { el.attrs.href = String(v); }, configurable: true });
105
+ Object.defineProperty(el, 'innerHTML', {
106
+ get() { return el._html === null ? '' : el._html; },
107
+ set(v) { el._html = String(v); parseInto(el, el._html); walk(el, (c) => { augment(c); return null; }); },
108
+ configurable: true,
109
+ });
110
+ el.focus = () => { boot.focused = el; };
111
+ el.blur = () => el.fire('blur', { target: el });
112
+ return el;
113
+ }
114
+
115
+ function bootStudio({ answers = {}, signedIn = true } = {}) {
116
+ const root = augment(makeEl('body', {}));
117
+ root.innerHTML = BODY;
118
+ const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
119
+ const docListeners = {};
120
+ const requests = [];
121
+ const htmlEl = augment(makeEl('html', {}));
122
+ const documentObj = {
123
+ body: root,
124
+ documentElement: htmlEl,
125
+ getElementById: byId,
126
+ querySelector: (sel) => root.querySelector(sel),
127
+ querySelectorAll: (sel) => root.querySelectorAll(sel),
128
+ createElement: (tag) => augment(makeEl(tag, {})),
129
+ addEventListener: (type, fn) => { (docListeners[type] = docListeners[type] || []).push(fn); },
130
+ execCommand() {},
131
+ };
132
+ const client = {
133
+ request: async (method, url, body) => {
134
+ requests.push({ method, url, body });
135
+ const key = `${method} ${url.replace(/^\/api\/bongos/, '')}`;
136
+ if (key === 'GET /me') return signedIn ? ok({ builder: { id: ME, display_name: 'Artist A', rank: 'metic' } }) : { ok: false, status: 401, data: {} };
137
+ const a = answers[key];
138
+ if (typeof a === 'function') return a(body);
139
+ if (a) return a;
140
+ return { status: 404, ok: false, data: { error: { code: 'no_answer', message: key } } };
141
+ },
142
+ };
143
+ const windowObj = {
144
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
145
+ BongosClient: { createClient: () => client },
146
+ location: { host: 'builders.cloudbongos.com', hostname: 'builders.cloudbongos.com', protocol: 'https:', search: '', pathname: '/studio', assign: (u) => { out.navigated = u; } },
147
+ localStorage: { getItem: () => null, setItem() {} },
148
+ addEventListener() {},
149
+ scrollTo() {},
150
+ };
151
+ const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, encodeURIComponent };
152
+ sandbox.globalThis = sandbox;
153
+ vm.createContext(sandbox);
154
+ for (const [name, src] of SOURCES) vm.runInContext(src, sandbox, { filename: name });
155
+ const out = {
156
+ requests, navigated: null, focused: null, el: byId, html: htmlEl,
157
+ writes: () => requests.filter((r) => r.method !== 'GET'),
158
+ click: async (id) => { const el = byId(id); assert.ok(el, `no #${id}`); el.fire('click', { target: el }); await out.settle(); },
159
+ clickIn: async (overlayId, sel) => {
160
+ const overlay = byId(overlayId);
161
+ const target = overlay.querySelector(sel);
162
+ assert.ok(target, `no ${sel} in #${overlayId}`);
163
+ overlay.fire('click', { target });
164
+ await out.settle();
165
+ },
166
+ settle: async () => { for (let i = 0; i < 10; i++) await settle(); },
167
+ };
168
+ boot = out;
169
+ return out;
170
+ }
171
+ const ok = (data) => ({ status: 200, ok: true, data });
172
+ const HOME = {
173
+ 'GET /copy-desk/tally': ok(TALLY),
174
+ 'GET /copy-desk/next': ok(NEXT),
175
+ 'GET /copy-desk/pages': ok(PAGES),
176
+ 'GET /copy-desk/pages/landing:index/draft': ok(RESUME_READ),
177
+ };
178
+
179
+ try {
180
+ // ── the pure half, over the composers' answers ────────────────────────────
181
+ await test('the tally reads the composer: credits, pages tweaked, and this week with its sign', () => {
182
+ assert.deepEqual(lib.tallyParts(TALLY), { credits: '60', pages: '2', week: '+30' });
183
+ assert.deepEqual(lib.tallyParts({ credits: 0, pages_tweaked: 0, this_week: 0 }), { credits: '0', pages: '0', week: '0' }, 'a quiet week is 0, not +0');
184
+ assert.equal(lib.tallyParts(null), null);
185
+ });
186
+
187
+ await test('Resume and the sub-line name the held page as the artist reads it, in the artboard\'s words', () => {
188
+ assert.equal(NEXT.resume.page_id, 'landing:index', 'the composer reports the held round as resume');
189
+ assert.equal(lib.resumeDetail(NEXT.resume, RESUME_READ.counter), 'Home · landing · 12 of 38 lines');
190
+ assert.equal(lib.resumeDetail(NEXT.resume, null), 'Home · landing');
191
+ assert.deepEqual(lib.subParts(NEXT.resume, RESUME_READ.counter),
192
+ { title: 'Home', surface: 'landing', counted: true, changed: 'Twelve', total: 'thirty-eight' });
193
+ assert.equal(lib.subParts(null, null), null, 'nothing held: the page\'s own sentence stands');
194
+ assert.equal(lib.numberWords(0, { capital: true }), 'None');
195
+ assert.equal(lib.numberWords(75), 'seventy-five');
196
+ assert.equal(lib.numberWords(120), 'one hundred and twenty');
197
+ assert.equal(lib.numberWords(1200), '1200');
198
+ });
199
+
200
+ await test('the three details: next up, the approvals count, and the whole count', () => {
201
+ assert.equal(lib.flowDetail(NEXT), 'next up: Projects — 2 people asked for it');
202
+ assert.equal(lib.flowDetail({ recommended: null }), 'every page is taken for now');
203
+ assert.equal(lib.statusDetail(PAGES), '2 of 5 pages tweaked');
204
+ });
205
+
206
+ await test('Artist Review Status: landing, builders hall, status, each N of M, the bar never empty', () => {
207
+ const rows = lib.surfaceRows(PAGES, 'cloudbongos.com');
208
+ assert.deepEqual(rows.map((r) => [r.name, r.where, r.count, r.pct]), [
209
+ ['Landing', 'cloudbongos.com', '1 of 2 tweaked', 50],
210
+ ['Builders hall', 'where the work happens', '0 of 2 tweaked', 3],
211
+ ['Status', 'the public dashboard', '1 of 1 tweaked', 100],
212
+ ]);
213
+ assert.equal(lib.landingHost('https://cloudbongos.com/', 'builders.x.test'), 'cloudbongos.com', 'the stamped origin wins');
214
+ assert.equal(lib.landingHost('{{publicOrigin}}', 'builders.x.test'), 'x.test', 'an unstamped token is no address');
215
+ assert.equal(lib.askedLine(NEXT.asked_pages), '1 page has been asked for. It comes up first in the Tweak/CopyWrite Flow.');
216
+ assert.equal(lib.askedLine(4), '4 pages have been asked for. They come up first in the Tweak/CopyWrite Flow.');
217
+ assert.equal(lib.askedLine(0), '');
218
+ });
219
+
220
+ await test('a word on the page finds its own line: the most specific wins, holes alone never match, shared never', () => {
221
+ const sentence = 'Home, on the landing page, is where you left it. Twelve of its thirty-eight lines are yours so far.';
222
+ assert.equal(lib.matchLine(sentence, STUDIO_READING.lines).key, 'L0003', 'not the generic "{…} of {…}"');
223
+ assert.equal(lib.matchLine('Good evening.', STUDIO_READING.lines).key, 'L0002');
224
+ assert.equal(lib.matchLine('Nine of ten', STUDIO_READING.lines), null, 'a line of holes and one word is no match for a sentence');
225
+ assert.equal(lib.matchLine('Skip to content', STUDIO_READING.lines), null, 'shell text is not the studio\'s own');
226
+ assert.equal(lib.matchLine('Something new', STUDIO_READING.lines), null);
227
+ });
228
+
229
+ await test('a refused claim, in words', () => {
230
+ assert.equal(lib.claimWords('page_held', { holder: { login: 'bob' } }), 'bob is tweaking this page right now.');
231
+ assert.match(lib.claimWords('not_an_artist', {}), /Only an artist can take a page/);
232
+ assert.equal(lib.claimWords('page_in_flight', { what_this_means: 'The server says so.' }), 'The server says so.');
233
+ });
234
+
235
+ // ── the real page ─────────────────────────────────────────────────────────
236
+ await test('the home paints from the four reads: tally, Resume into the editor, the sub-line, the details', async () => {
237
+ const s = bootStudio({ answers: HOME });
238
+ await s.settle();
239
+ assert.equal(s.el('st-tally').hidden, false);
240
+ assert.deepEqual(['st-tally-credits', 'st-tally-pages', 'st-tally-week'].map((id) => s.el(id).textContent), ['60', '2', '+30']);
241
+ assert.equal(s.el('st-resume').hidden, false);
242
+ assert.equal(s.el('st-resume').getAttribute('href'), '/builders/tweak-editor?page=landing:index');
243
+ assert.equal(s.el('st-resume-detail').textContent, 'Home · landing · 12 of 38 lines');
244
+ assert.equal(s.el('studio-sub').textContent, 'Home, on the landing page, is where you left it. Twelve of its thirty-eight lines are yours so far.');
245
+ assert.equal(s.el('st-flow-detail').textContent, 'next up: Projects — 2 people asked for it');
246
+ assert.equal(s.el('st-status-detail').textContent, '2 of 5 pages tweaked');
247
+ assert.deepEqual(s.writes(), [], 'opening the studio writes nothing');
248
+ });
249
+
250
+ await test('nothing held: no Resume, and the page\'s own sub-line stands', async () => {
251
+ const s = bootStudio({ answers: { ...HOME, 'GET /copy-desk/next': ok({ ...NEXT, resume: null }) } });
252
+ await s.settle();
253
+ assert.equal(s.el('st-resume').hidden, true);
254
+ assert.match(s.el('studio-sub').textContent, /^Nothing is on your desk yet\./);
255
+ });
256
+
257
+ await test('signed out: no tally, no reads of the ledger', async () => {
258
+ const s = bootStudio({ answers: HOME, signedIn: false });
259
+ await s.settle();
260
+ assert.equal(s.el('st-tally').hidden, true);
261
+ assert.equal(s.requests.filter((r) => /copy-desk\/(tally|next|pages)/.test(r.url)).length, 0);
262
+ });
263
+
264
+ await test('the Tweak/CopyWrite Flow: the recommendation, pick another swaps it, take it claims THAT page and opens it', async () => {
265
+ const s = bootStudio({ answers: { ...HOME, 'POST /copy-desk/pages/builders:studio/claim': ok({ ok: true, outcome: 'created' }) } });
266
+ await s.settle();
267
+ await s.click('st-flow');
268
+ assert.equal(s.el('st-panel').hidden, false);
269
+ assert.ok(s.html.classList.contains('st-open'));
270
+ assert.equal(s.el('st-panel-title').textContent, 'Tweak/CopyWrite Flow');
271
+ const body = () => s.el('st-panel-body').innerHTML;
272
+ assert.match(body(), /st-next__page">Projects</);
273
+ assert.match(body(), /landing · 2 people asked for it/);
274
+ await s.clickIn('st-panel', '[data-pick]');
275
+ assert.doesNotMatch(body(), /id="st-picks" hidden/, 'the chips open');
276
+ assert.match(body(), /data-pick-id="builders:studio">Studio · builders hall</);
277
+ await s.clickIn('st-panel', '[data-pick-id="builders:studio"]');
278
+ assert.match(body(), /st-next__page">Studio</, 'the picked page is now the one to take');
279
+ await s.clickIn('st-panel', '[data-take]');
280
+ assert.deepEqual(s.writes().map((w) => `${w.method} ${w.url}`), ['POST /api/bongos/copy-desk/pages/builders:studio/claim']);
281
+ assert.equal(s.navigated, '/builders/tweak-editor?page=builders:studio');
282
+ });
283
+
284
+ await test('a refused take stays in the panel and says why', async () => {
285
+ const s = bootStudio({ answers: { ...HOME, 'POST /copy-desk/pages/landing:projects/claim': { ok: false, status: 409, data: { error: { code: 'page_held', message: 'held', details: { holder: { login: 'bob' } } } } } } });
286
+ await s.settle();
287
+ await s.click('st-flow');
288
+ await s.clickIn('st-panel', '[data-take]');
289
+ assert.equal(s.navigated, null);
290
+ const err = s.el('st-take-err');
291
+ assert.equal(err.hidden, false);
292
+ assert.match(err.textContent, /is tweaking this page right now/);
293
+ });
294
+
295
+ await test('Artist Review Status: three surface lines, the instance\'s address on landing, the asked line', async () => {
296
+ const s = bootStudio({ answers: HOME });
297
+ s.el('hall-main').setAttribute('data-apex', 'https://cloudbongos.com');
298
+ await s.settle();
299
+ await s.click('st-status');
300
+ const body = s.el('st-panel-body').innerHTML;
301
+ assert.equal(s.el('st-panel-title').textContent, 'Artist Review Status');
302
+ assert.deepEqual([...body.matchAll(/st-surface__name">([^<]*)</g)].map((m) => m[1]), ['Landing', 'Builders hall', 'Status']);
303
+ assert.match(body, /st-surface__where">cloudbongos\.com</);
304
+ assert.match(body, /1 of 2 tweaked/);
305
+ assert.match(body, /style="width: 50%"/);
306
+ assert.match(body, /1 page has been asked for/);
307
+ await s.clickIn('st-panel', '[data-close-panel]');
308
+ assert.equal(s.el('st-panel').hidden, true);
309
+ assert.ok(!s.html.classList.contains('st-open'));
310
+ });
311
+
312
+ await test('the studio\'s words: a first click offers the studio page, and nothing is taken until the artist says so', async () => {
313
+ const s = bootStudio({ answers: { ...HOME, 'GET /copy-desk/pages/builders:studio/draft': ok(studioRead({ held: false })) } });
314
+ await s.settle();
315
+ await s.click('studio-greet');
316
+ assert.equal(s.el('st-offer').hidden, false);
317
+ assert.equal(s.el('studio-greet').getAttribute('contenteditable'), null, 'not editable before the page is taken');
318
+ assert.deepEqual(s.writes(), []);
319
+ await s.click('st-offer-no');
320
+ assert.equal(s.el('st-offer').hidden, true);
321
+ assert.deepEqual(s.writes(), [], 'not now takes nothing');
322
+ });
323
+
324
+ await test('taken: the words the reading lists are typed over in place, and the WHOLE draft is saved to the studio page', async () => {
325
+ let held = false;
326
+ const saved = [];
327
+ const s = bootStudio({
328
+ answers: {
329
+ ...HOME,
330
+ 'GET /copy-desk/pages/builders:studio/draft': () => ok(studioRead({ held, draftLines: held ? [{ key: 'L0005', after: 'Waiting for you' }] : null })),
331
+ 'POST /copy-desk/pages/builders:studio/claim': () => { held = true; return ok({ ok: true, outcome: 'created' }); },
332
+ 'PUT /copy-desk/pages/builders:studio/draft': (b) => { saved.push(b); return ok({ ok: true, outcome: 'saved' }); },
333
+ },
334
+ });
335
+ await s.settle();
336
+ await s.click('studio-greet');
337
+ await s.click('st-offer-take');
338
+ const greet = s.el('studio-greet');
339
+ const sub = s.el('studio-sub');
340
+ assert.equal(greet.getAttribute('contenteditable'), 'true');
341
+ assert.equal(sub.getAttribute('contenteditable'), 'true', 'the sub-line found its own line, not the generic one');
342
+ assert.equal(sub.textContent, STUDIO_READING.lines[2].text, 'a line with holes is typed over as the reading holds it');
343
+ assert.equal(s.el('st-hint').hidden, true);
344
+ assert.equal(s.el('st-editing').hidden, false);
345
+ assert.match(s.el('st-editing').innerHTML, /The studio page is yours · 1 of 5 rewritten · saved/);
346
+ assert.match(s.el('st-editing').innerHTML, /href="\/builders\/tweak-editor\?page=builders:studio"/, 'submit is the editor\'s');
347
+
348
+ greet.textContent = 'Good night.';
349
+ greet.fire('input', { target: greet });
350
+ greet.fire('blur', { target: greet });
351
+ await s.settle();
352
+ assert.equal(saved.length, 1, 'one save');
353
+ assert.deepEqual(JSON.parse(JSON.stringify(saved[0])), { reading_hash: 'rs-1', lines: [{ key: 'L0002', after: 'Good night.' }, { key: 'L0005', after: 'Waiting for you' }] },
354
+ 'the whole draft: the word typed here AND the line the editor saved');
355
+ assert.match(s.el('st-editing').innerHTML, /2 of 5 rewritten · saved/);
356
+ greet.fire('blur', { target: greet });
357
+ await s.settle();
358
+ assert.equal(saved.length, 1, 'an unchanged draft is not saved again');
359
+ const writes = s.writes().map((w) => `${w.method} ${w.url}`);
360
+ assert.deepEqual(writes, ['POST /api/bongos/copy-desk/pages/builders:studio/claim', 'PUT /api/bongos/copy-desk/pages/builders:studio/draft'],
361
+ 'the claim and the draft, and never a live string');
362
+ });
363
+
364
+ await test('already held: a click goes straight to editing; a refused save says so in the server\'s words', async () => {
365
+ const s = bootStudio({
366
+ answers: {
367
+ ...HOME,
368
+ 'GET /copy-desk/pages/builders:studio/draft': ok(studioRead({ held: true })),
369
+ 'PUT /copy-desk/pages/builders:studio/draft': { ok: false, status: 409, data: { error: { code: 'reading_moved', message: 'moved', details: { what_this_means: 'The page changed since it was read.' } } } },
370
+ },
371
+ });
372
+ await s.settle();
373
+ await s.click('studio-greet');
374
+ assert.equal(s.el('st-offer').hidden, true, 'no offer: the page is already yours');
375
+ const greet = s.el('studio-greet');
376
+ assert.equal(greet.getAttribute('contenteditable'), 'true');
377
+ greet.textContent = 'Evening.';
378
+ greet.fire('input', { target: greet });
379
+ greet.fire('blur', { target: greet });
380
+ await s.settle();
381
+ assert.match(s.el('st-editing').innerHTML, /not saved · The page changed since it was read\./);
382
+ assert.ok(s.el('st-editing').classList.contains('st-editing--failed'));
383
+ });
384
+
385
+ await test('a refused claim on the studio page keeps the words as they are and says why', async () => {
386
+ const s = bootStudio({
387
+ answers: {
388
+ ...HOME,
389
+ 'GET /copy-desk/pages/builders:studio/draft': ok(studioRead({ held: false })),
390
+ 'POST /copy-desk/pages/builders:studio/claim': { ok: false, status: 403, data: { error: { code: 'not_an_artist', message: 'no' } } },
391
+ },
392
+ });
393
+ await s.settle();
394
+ await s.click('studio-greet');
395
+ await s.click('st-offer-take');
396
+ assert.equal(s.el('studio-greet').getAttribute('contenteditable'), null);
397
+ assert.equal(s.el('st-offer-err').hidden, false);
398
+ assert.match(s.el('st-offer-err').textContent, /Only an artist can take a page/);
399
+ });
400
+
401
+ await test('the older reviews stay one quiet door away while any still wait', async () => {
402
+ const REVIEW = { id: '1900', title: 'Artist review: the copy and visuals that shipped in task 1899', status: 'backlog', source: 'cascade', source_ref: 'cascade-artist-review-on-ship-1899', created_at: day(20) };
403
+ const s = bootStudio({ answers: { ...HOME, 'GET /tasks?discipline=artist&status=backlog&limit=50': ok({ tasks: [REVIEW] }) } });
404
+ await s.settle();
405
+ assert.equal(s.el('st-older').hidden, false);
406
+ assert.equal(s.el('st-older-label').textContent, '1 review from before Tweak Mode still waits');
407
+ await s.click('st-older');
408
+ assert.equal(s.el('st-reviews').hidden, false);
409
+ assert.match(s.el('studio-focus').innerHTML, /to tend now · 1 of 1/);
410
+ const none = bootStudio({ answers: HOME });
411
+ await none.settle();
412
+ assert.equal(none.el('st-older').hidden, true, 'no review waiting: no door');
413
+ });
414
+
415
+ await test('the markup: one h1 (the greeting), the tally and four doors, the libs before the page, the full-screen switch', () => {
416
+ const nc = HTML.replace(/<!--[\s\S]*?-->/g, '');
417
+ assert.equal((nc.match(/<h1\b/g) || []).length, 1);
418
+ assert.match(nc, /<h1[^>]*id="studio-greet"[^>]*data-words/);
419
+ assert.match(nc, /<p[^>]*id="studio-sub"[^>]*data-words/);
420
+ for (const id of ['st-tally', 'st-resume', 'st-flow', 'studio-approvals', 'st-status', 'st-older']) assert.match(nc, new RegExp(`id="${id}"`));
421
+ assert.match(nc, /<body[^>]*data-fullscreen-switch/);
422
+ const at = (f) => nc.indexOf(f);
423
+ assert.ok(at('tweak-editor-lib.js') < at('studio-lib.js') && at('studio-lib.js') < at('/studio.js'), 'the libs, then the page');
424
+ assert.ok(at('approval-queue.js') < at('/studio.js'), 'the Approval queue panel before the page that mounts it');
425
+ });
426
+ } finally {
427
+ await new Promise((r) => setTimeout(r, 100));
428
+ summary();
429
+ }
@@ -55,6 +55,12 @@ test('the Studio writes ONLY the artist\'s verdict on their own review queue', (
55
55
  /^PATCH \$\{API\}\/tasks\/\$\{reviewId\}$/, // note it → the words, on the review
56
56
  /^POST \$\{API\}\/tasks$/, // needs work→ the follow-up task
57
57
  /^POST \$\{API\}\/tasks\/\$\{reviewId\}\/merge$/, // …and the review closed against it
58
+ // The home (task 1003971 / BV2.TW16, ADR 0341 D5 and D11): take a page's
59
+ // tweak round, and save the studio page's draft while the artist holds it.
60
+ // Neither writes a live string; the words reach a page only as a shipped,
61
+ // approved tweak.
62
+ /^POST \$\{API\}\/copy-desk\/pages\/\$\{pageId\}\/claim$/,
63
+ /^PUT \$\{API\}\/copy-desk\/pages\/\$\{pageId\}\/draft$/,
58
64
  ];
59
65
  // Every write site, as "<VERB> <route expression>" — the verb from the
60
66
  // request call, the route from the WRITE_ROUTES entry it names.
@@ -69,7 +75,7 @@ test('the Studio writes ONLY the artist\'s verdict on their own review queue', (
69
75
  const named = /^WRITE_ROUTES\.(\w+)\(/.exec(arg.trim());
70
76
  return { verb, arg: arg.trim(), route: named ? routes.get(named[1]) : null };
71
77
  });
72
- assert.equal(sites.length, 4, `exactly the four verdict writes; found ${sites.length}`);
78
+ assert.equal(sites.length, 6, `the four verdict writes, one page claim and one draft save; found ${sites.length}`);
73
79
  for (const s of sites) {
74
80
  assert.ok(s.route, `every write names a WRITE_ROUTES entry, never an inline URL: ${s.verb} ${s.arg}`);
75
81
  const site = `${s.verb} ${s.route}`;
@@ -62,8 +62,11 @@ await test('the prefix does NOT match the other cascade rule', () => {
62
62
  // separating a removal question from an artist review. If it ever widened, a
63
63
  // Confirm button would appear on a copy review and answering it would record a
64
64
  // removal verdict about work nobody proposed removing.
65
- const artist = cascade.CASCADES.find((r) => r.key === 'artist-review-on-ship');
66
- const artistRef = cascade.cascadeSourceRef(artist.key, SHIP);
65
+ //
66
+ // The artist rule itself was removed by Tweak Mode (TW14, ADR 0341 D9), but the
67
+ // reviews it filed are still rows on the board under its key, so the key is
68
+ // spelled here rather than read from CASCADES.
69
+ const artistRef = cascade.cascadeSourceRef('artist-review-on-ship', SHIP);
67
70
  assert.ok(!artistRef.startsWith(proposals.PROPOSAL_SOURCE_REF_PREFIX));
68
71
  assert.equal(proposals.shipTaskIdFromSourceRef(artistRef), null);
69
72
  });
@@ -0,0 +1,170 @@
1
+ // tests/tweak_replaces_artist_review.mjs — Tweak Mode replaces the per-ship
2
+ // artist review, the WHOLE path (task 1004325 / BV2.TW14, ADR 0341 D8 and D9).
3
+ //
4
+ // The done-when: "a ship changing a page's text files no artist review and the
5
+ // page reads changed-since". Two halves, and they meet at one ship:
6
+ //
7
+ // 1. THE SHIP FILES NOTHING. The real cascade runner, with the real
8
+ // production table, the ship's real changed files and the artist gate at
9
+ // 'strict' (the level that used to file AND hold), creates no task.
10
+ // 2. THE PAGE READS CHANGED-SINCE ANYWAY, with no write. The real copy-desk
11
+ // route over the real lifecycle port's SQL (a fake pool), reading the page
12
+ // artifacts from disk: before the ship the page matches its last round;
13
+ // after it, the page's reading has moved, drift names the changed line, and
14
+ // the ship itself is listed as what changed it, found by its touches.
15
+ //
16
+ // "No write" is checked twice: the cascade's createTask is never called, and
17
+ // the fake pool refuses any SQL that is not one of the two reads, so an INSERT
18
+ // or UPDATE anywhere on the read path reds here.
19
+
20
+ import { strict as assert } from 'node:assert';
21
+ import { createRequire } from 'node:module';
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { makeRunner } from './helpers.mjs';
26
+
27
+ process.env.NODE_ENV = 'test';
28
+ const require = createRequire(import.meta.url);
29
+
30
+ const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
31
+ const cascade = require('../modules/lifecycle/cascade.js');
32
+ const gate = require('../src/bongos/artist-gate.js');
33
+ const api = require('../src/module-api.js');
34
+
35
+ // THE SHIP. An engineer rewords the studio greeting ("Welcome home" becomes
36
+ // "Welcome back") after the page's first round shipped. Its touches meet the
37
+ // studio page's files, and it is copy/visual work by every measure the old rule
38
+ // used: a hall-ui module ship that changed a page file.
39
+ const SHIP = {
40
+ id: '555', title: 'Reword the studio greeting', status: 'shipped',
41
+ discipline: 'engineer', kind: 'feature', source: 'manual', module_key: 'hall-ui',
42
+ version_id: 'BONGOS-V2', goal_id: '1000095',
43
+ touches: ['modules/hall-ui/public/studio.html'],
44
+ shipped_at: new Date('2026-09-22T09:00:00Z'),
45
+ };
46
+
47
+ // Every SQL the page read sends, so the test can say none of it wrote.
48
+ const seen = [];
49
+ const flat = (sql) => String(sql).replace(/\s+/g, ' ').trim();
50
+ api.pool = {
51
+ async query(sql, params = []) {
52
+ const q = flat(sql);
53
+ seen.push({ q, params });
54
+ if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) return { rows: F.TASKS };
55
+ if (/t\.touches && \$1::text\[\]/.test(q)) {
56
+ // The real query's predicate, applied to the one ship: shipped after the
57
+ // page's last round, touching one of the page's files.
58
+ const files = new Set(params[0]);
59
+ const hit = SHIP.shipped_at > new Date(params[1]) && SHIP.touches.some((t) => files.has(t));
60
+ return { rows: hit ? [{ id: SHIP.id, title: SHIP.title, shipped_at: SHIP.shipped_at }] : [] };
61
+ }
62
+ throw new Error(`unexpected SQL on the page read: ${q.slice(0, 120)}`);
63
+ },
64
+ };
65
+ api.requireBuilder = (req, res, next) => { req.builder = { id: F.ALICE.id, rank: 'thetes' }; next(); };
66
+
67
+ const seams = require('../src/module-seams.js');
68
+ if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', require('../modules/lifecycle/lifecycle.js'));
69
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
70
+
71
+ const pageData = require('../modules/copy-desk/page-data.js');
72
+ const express = require('express');
73
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
74
+ const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
75
+
76
+ const app = express();
77
+ app.use(attachFail);
78
+ app.use(buildRouter());
79
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
80
+ const base = `http://127.0.0.1:${server.address().port}`;
81
+ async function get(p) {
82
+ const r = await fetch(base + p);
83
+ return { status: r.status, body: await r.json() };
84
+ }
85
+
86
+ // The studio's reading BEFORE the ship says exactly what its first round left
87
+ // ("Welcome home", "Resume"); AFTER it is the fixture's (the greeting moved).
88
+ // The shared shell line differs on both sides, and drift must ignore it.
89
+ const studioAfter = F.READINGS.pages.find((p) => p.id === 'builders:studio');
90
+ const studioBefore = {
91
+ ...studioAfter,
92
+ reading_hash: 'studio-before',
93
+ lines: studioAfter.lines.map((l) => (l.key === 'L0002' ? { ...l, text: 'Welcome home' } : l)),
94
+ };
95
+ const readingsWith = (studio) => ({ ...F.READINGS, pages: F.READINGS.pages.map((p) => (p.id === studio.id ? studio : p)) });
96
+
97
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tweak-replaces-review-'));
98
+ const root = (name, readings) => {
99
+ const dir = path.join(tmp, name);
100
+ fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
101
+ fs.writeFileSync(path.join(dir, 'docs', 'page-inventory.json'), JSON.stringify(F.INVENTORY));
102
+ fs.writeFileSync(path.join(dir, 'docs', 'page-readings.json'), JSON.stringify(readings));
103
+ return dir;
104
+ };
105
+ const BEFORE = root('before', readingsWith(studioBefore));
106
+ const AFTER = root('after', readingsWith(studioAfter));
107
+
108
+ const { test, summary } = makeRunner();
109
+
110
+ try {
111
+ await test('before the ship, the studio matches its last round: tweaked, no drift', async () => {
112
+ pageData._setRoot(BEFORE);
113
+ const { status, body } = await get('/copy-desk/pages/builders:studio');
114
+ assert.equal(status, 200);
115
+ assert.equal(body.status_label, 'Text tweaked 1x');
116
+ assert.equal(body.drift.changed, false);
117
+ });
118
+
119
+ await test('the ship that rewords the page files NO artist review, at the strictest gate', async () => {
120
+ const created = [];
121
+ const report = await cascade.runCascadesForShip({ id: SHIP.id }, {
122
+ getTask: async () => SHIP,
123
+ findTaskBySourceRef: async () => null,
124
+ createTask: async (spec) => { created.push(spec); return { id: '9999' }; },
125
+ artistGateMode: () => 'strict',
126
+ changedFiles: async () => ({ paths: SHIP.touches, truncated: false }),
127
+ removalCandidates: async () => ({ removals: [] }),
128
+ });
129
+ assert.deepEqual(report.errors, []);
130
+ assert.deepEqual(created, [], 'nothing is filed: no review task, no marker, nothing');
131
+ assert.equal(created.some((s) => gate.isArtistReview({ source: s.source, source_ref: s.sourceRef })), false);
132
+ // It is still copy/visual work: the grader's reader lens still runs on it.
133
+ assert.equal(cascade.hasCopyOrVisualWork(SHIP, SHIP.touches), true);
134
+ });
135
+
136
+ await test('after the ship, the page reads changed-since, and names the ship, with no write', async () => {
137
+ pageData._setRoot(AFTER);
138
+ seen.length = 0;
139
+ const { status, body } = await get('/copy-desk/pages/builders:studio');
140
+ assert.equal(status, 200);
141
+ assert.equal(body.drift.known, true);
142
+ assert.equal(body.drift.changed, true, 'the page reads "changed since last tweak"');
143
+ assert.deepEqual(body.drift.changed_lines, [{ key: 'L0002', section: 'greeting', text: 'Welcome back' }]);
144
+ assert.deepEqual(body.drift.removed_lines, ['Welcome home']);
145
+ assert.deepEqual(body.drift.ships.map((s) => s.task_id), [SHIP.id], 'the ship is what changed it');
146
+ assert.equal(body.status_label, 'Text tweaked 1x', 'drift never lowers the count');
147
+ // Derived, never stored: only the two reads ran.
148
+ assert.ok(seen.length > 0);
149
+ for (const s of seen) assert.match(s.q, /^(WITH .*)?SELECT /, `a read, never a write: ${s.q.slice(0, 80)}`);
150
+ });
151
+
152
+ await test('the page list carries the same drift, which is what the recommendation ranks on', async () => {
153
+ pageData._setRoot(AFTER);
154
+ const list = await get('/copy-desk/pages');
155
+ const studio = list.body.pages.find((p) => p.id === 'builders:studio');
156
+ assert.equal(studio.drift.changed, true);
157
+ assert.equal(studio.drift.changed_count, 1);
158
+ });
159
+ } finally {
160
+ pageData._setRoot(null);
161
+ server.closeAllConnections();
162
+ await new Promise((resolve) => server.close(resolve));
163
+ fs.rmSync(tmp, { recursive: true, force: true });
164
+ }
165
+
166
+ // Let the closed sockets finish closing before summary() calls process.exit: on
167
+ // Windows an exit while a handle is mid-close trips a libuv assertion (exit 127)
168
+ // after every case has passed.
169
+ await new Promise((resolve) => setTimeout(resolve, 100));
170
+ summary();