@bongos/core 1.19.1063 → 1.19.1065

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.
Files changed (65) hide show
  1. package/.bongos-core.json +136 -56
  2. package/.claude/skills/grade-recover/SKILL.md +1 -0
  3. package/.claude/skills/tweak/SKILL.md +70 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +12 -0
  6. package/clients/bongos-client/index.cjs +12 -0
  7. package/clients/bongos-client/index.d.ts +21 -1
  8. package/clients/bongos-client/index.mjs +12 -0
  9. package/docs/api/openapi.json +492 -4
  10. package/docs/api-reference.md +9 -3
  11. package/docs/copy-inventory.md +60 -41
  12. package/docs/copy-registry.json +228 -47
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +11 -2
  16. package/docs/page-readings.json +45 -22
  17. package/modules/copy-desk/page-decisions.js +71 -0
  18. package/modules/copy-desk/page-status.js +96 -0
  19. package/modules/copy-desk/pages.js +30 -0
  20. package/modules/copy-desk/routes/copy-desk.js +153 -0
  21. package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
  22. package/modules/hall-ui/public/approval-queue-lib.js +156 -0
  23. package/modules/hall-ui/public/approval-queue.css +286 -0
  24. package/modules/hall-ui/public/approval-queue.js +338 -0
  25. package/modules/hall-ui/public/studio.css +33 -0
  26. package/modules/hall-ui/public/studio.html +18 -1
  27. package/modules/hall-ui/public/studio.js +30 -0
  28. package/modules/hall-ui/public/studio.states.json +198 -0
  29. package/modules/hall-ui/records/approval-queue.md +32 -0
  30. package/modules/lifecycle/db-grade.js +96 -58
  31. package/modules/lifecycle/github-pr-close.js +41 -0
  32. package/modules/lifecycle/github-push.js +5 -2
  33. package/modules/lifecycle/lifecycle.js +12 -0
  34. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  35. package/modules/lifecycle/page-tweak-approve.js +242 -0
  36. package/modules/lifecycle/page-tweak-hold.js +74 -0
  37. package/modules/lifecycle/page-tweak-reads.js +33 -1
  38. package/modules/lifecycle/routes/gate-approvals.js +17 -2
  39. package/modules/lifecycle/routes/tasks.js +4 -2
  40. package/modules/lifecycle/routes/visuals.js +82 -0
  41. package/modules/lifecycle/task-visual-db.js +40 -1
  42. package/modules/lifecycle/task-visuals.js +24 -0
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +12 -0
  46. package/scripts/gds/copy-apply.js +277 -1
  47. package/scripts/gds/page-reader.js +2 -0
  48. package/scripts/gds/ship-finish.js +27 -2
  49. package/scripts/gds/ship-flow.js +17 -1
  50. package/scripts/gds/ship-land.js +30 -7
  51. package/scripts/gds/ship-merge.js +43 -15
  52. package/scripts/gds/strand-watch.js +41 -1
  53. package/scripts/gds/task.js +23 -5
  54. package/scripts/gds/tweak-renders.js +200 -0
  55. package/scripts/hall-preview/server.js +18 -0
  56. package/src/module-api.js +1 -1
  57. package/tests/copy_desk_page_approve.mjs +307 -0
  58. package/tests/fixtures/page-ledger.mjs +41 -1
  59. package/tests/gate_approvals.mjs +93 -0
  60. package/tests/hall_approval_queue.mjs +327 -0
  61. package/tests/hall_audit.mjs +4 -0
  62. package/tests/page_tweak_hold.mjs +265 -0
  63. package/tests/publish_status_branch.mjs +23 -0
  64. package/tests/task_visual_slots.mjs +161 -0
  65. package/tests/tweak_batch_apply.mjs +265 -0
@@ -0,0 +1,161 @@
1
+ // tests/task_visual_slots.mjs — task visuals widened to NAMED slots, and the
2
+ // eight page-tweak renders attached to them (task 1004322 / BV2.TW11;
3
+ // lifecycle_013; ADR 0341 D7, builder pick 9).
4
+ //
5
+ // Drives the real routes/visuals.js router with the slot queries stubbed at the
6
+ // module boundary and a temp store, then the pure halves of tweak-renders.js and
7
+ // its attach step against a fake client. No DB, no network, no browser.
8
+ //
9
+ // Run: node tests/task_visual_slots.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+ import http from 'node:http';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import fs from 'node:fs';
17
+ import { makeRunner } from './helpers.mjs';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'tw11-slots-'));
21
+ process.env.TASK_VISUALS_DIR = path.join(TMP, 'store');
22
+
23
+ const express = require('express');
24
+ const api = require('../src/module-api.js');
25
+ const visualDb = require('../modules/lifecycle/task-visual-db.js');
26
+ const visuals = require('../modules/lifecycle/task-visuals.js');
27
+ const renders = require('../scripts/gds/tweak-renders.js');
28
+
29
+ const { test, summary } = makeRunner();
30
+
31
+ // A 1x1 PNG, real enough for the magic-byte sniff.
32
+ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
33
+
34
+ const slots = new Map(); // `${task}|${slot}` -> row
35
+ visualDb.getTaskVisualAccess = async (id) => ({ id, status: 'completed', security_sensitive: false, visual_url: null });
36
+ visualDb.setTaskVisualSlot = async ({ taskId, slot, visualUrl, visualAlt }) => {
37
+ const k = `${taskId}|${slot}`;
38
+ const prev = slots.get(k);
39
+ slots.set(k, { slot, visual_url: visualUrl, visual_alt: visualAlt, updated_at: 't' });
40
+ return { slot, visual_url: visualUrl, visual_alt: visualAlt, replaced_url: prev ? prev.visual_url : null };
41
+ };
42
+ visualDb.deleteTaskVisualSlot = async ({ taskId, slot }) => { const k = `${taskId}|${slot}`; const p = slots.get(k); slots.delete(k); return p ? p.visual_url : null; };
43
+ visualDb.listTaskVisualSlots = async (taskId) => [...slots.entries()].filter(([k]) => k.startsWith(`${taskId}|`)).map(([, v]) => v).sort((a, b) => (a.slot < b.slot ? -1 : 1));
44
+ let rank = 'archon';
45
+ api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank, github_login: 'x' }; next(); };
46
+
47
+ const app = express();
48
+ app.use((req, res, next) => { res.fail = (code, status, extra) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code, ...(extra || {}) }); next(); });
49
+ app.use(require('../modules/lifecycle/routes/visuals.js')());
50
+ const server = http.createServer(app);
51
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
52
+ const base = `http://127.0.0.1:${server.address().port}`;
53
+ const call = async (method, url, body) => {
54
+ const res = await fetch(base + url, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
55
+ return { status: res.status, body: await res.json() };
56
+ };
57
+ const png = { image_b64: PNG.toString('base64'), content_type: 'image/png' };
58
+ const stored = () => fs.existsSync(process.env.TASK_VISUALS_DIR) ? fs.readdirSync(process.env.TASK_VISUALS_DIR) : [];
59
+
60
+ await test('the eight render slots are declared once: before/after x desktop/phone x light/dark', () => {
61
+ assert.equal(visuals.TWEAK_RENDER_SLOTS.length, 8);
62
+ assert.equal(new Set(visuals.TWEAK_RENDER_SLOTS).size, 8);
63
+ for (const s of visuals.TWEAK_RENDER_SLOTS) assert.ok(visuals.isSafeSlot(s), s);
64
+ assert.deepEqual(visuals.TWEAK_RENDER_DEVICES, { desktop: 1440, phone: 390 }, 'the spec widths: 1440 and 390');
65
+ const sql = fs.readFileSync(new URL('../modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql', import.meta.url), 'utf8');
66
+ const re = new RegExp(/slot ~ '([^']+)'/.exec(sql)[1]);
67
+ for (const s of visuals.TWEAK_RENDER_SLOTS) assert.ok(re.test(s), `the column CHECK admits ${s}`);
68
+ for (const bad of ['', 'Before', '../x', 'a b', 'x'.repeat(49), 'a--b']) assert.equal(visuals.isSafeSlot(bad), false, JSON.stringify(bad));
69
+ });
70
+
71
+ await test('POST a named slot stores the image and lists it; the one ship-time visual is untouched', async () => {
72
+ const r = await call('POST', '/tasks/5001/visuals/after-phone-dark', png);
73
+ assert.equal(r.status, 201, JSON.stringify(r.body));
74
+ assert.equal(r.body.slot, 'after-phone-dark');
75
+ assert.match(r.body.visual_url, /^\/api\/bongos\/task-visuals\/task-5001-[0-9a-f]{16}\.png$/, 'the same store and serve path as the one visual');
76
+ const l = await call('GET', '/tasks/5001/visuals');
77
+ assert.deepEqual(l.body.slots.map((x) => x.slot), ['after-phone-dark']);
78
+ assert.equal(stored().length, 1);
79
+ });
80
+
81
+ await test('attaching a slot again REPLACES it and unlinks the superseded file', async () => {
82
+ const before = stored();
83
+ const r = await call('POST', '/tasks/5001/visuals/after-phone-dark', png);
84
+ assert.equal(r.status, 201);
85
+ const after = stored();
86
+ assert.equal(after.length, 1, 'one file per slot on disk');
87
+ assert.notDeepEqual(after, before);
88
+ });
89
+
90
+ await test('a bad slot name is refused before any byte is stored; a non-image is refused', async () => {
91
+ const n = stored().length;
92
+ assert.equal((await call('POST', '/tasks/5001/visuals/Not%20A%20Slot', png)).status, 400);
93
+ const r = await call('POST', '/tasks/5001/visuals/before-desktop-light', { image_b64: Buffer.from('<svg/>').toString('base64'), content_type: 'image/png' });
94
+ assert.equal(r.status, 400);
95
+ assert.equal(stored().length, n);
96
+ });
97
+
98
+ await test('only the claim holder or an Archon may attach (the one visual\'s own gate)', async () => {
99
+ rank = 'thetes';
100
+ const routes = require('../modules/lifecycle/routes/tasks.js');
101
+ const src = fs.readFileSync(require.resolve('../modules/lifecycle/routes/visuals.js'), 'utf8');
102
+ const post = src.slice(src.indexOf("router.post('/tasks/:id/visuals/:slot'"), src.indexOf("router.delete('/tasks/:id/visuals/:slot'"));
103
+ const del = src.slice(src.indexOf("router.delete('/tasks/:id/visuals/:slot'"), src.indexOf("router.get('/tasks/:id/visuals'"));
104
+ for (const body of [post, del]) assert.match(body, /gateTaskOwnership\(req, res, id\)/);
105
+ assert.ok(routes.gateTaskOwnership, 'the shared gate is the one used');
106
+ rank = 'archon';
107
+ });
108
+
109
+ await test('DELETE a slot removes its row and its file, and is idempotent', async () => {
110
+ const r = await call('DELETE', '/tasks/5001/visuals/after-phone-dark');
111
+ assert.equal(r.status, 200);
112
+ assert.equal(stored().length, 0);
113
+ assert.equal((await call('DELETE', '/tasks/5001/visuals/after-phone-dark')).status, 200);
114
+ assert.deepEqual((await call('GET', '/tasks/5001/visuals')).body.slots, []);
115
+ });
116
+ server.close();
117
+
118
+ // ---- tweak-renders.js -------------------------------------------------------
119
+
120
+ await test('shotsFor names one file per slot; a page pinning one mode is shot in it for both mode slots', () => {
121
+ const free = renders.shotsFor('before', { pinnedMode: null });
122
+ assert.deepEqual(free.map((s) => s.slot), visuals.TWEAK_RENDER_SLOTS.filter((s) => s.startsWith('before-')));
123
+ assert.deepEqual(free.map((s) => s.renderMode), ['light', 'dark', 'light', 'dark']);
124
+ const pinned = renders.shotsFor('after', { pinnedMode: 'dark' });
125
+ assert.deepEqual(pinned.map((s) => s.renderMode), ['dark', 'dark', 'dark', 'dark']);
126
+ assert.throws(() => renders.shotsFor('during', {}));
127
+ });
128
+
129
+ await test('planForPage reads the real inventory: an unknown page is refused, a known one has a url', () => {
130
+ const reader = require('../scripts/gds/page-reader.js');
131
+ const inventory = JSON.parse(fs.readFileSync(new URL('../docs/page-inventory.json', import.meta.url), 'utf8'));
132
+ assert.equal(renders.planForPage('nope:nope', { inventory, planRenders: reader.planRenders }).code, 'unknown_page');
133
+ const p = renders.planForPage(inventory.pages[0].id, { inventory, planRenders: reader.planRenders });
134
+ assert.equal(p.ok, true);
135
+ assert.ok(typeof p.plan.url === 'string' && p.plan.url.startsWith('/'));
136
+ });
137
+
138
+ await test('attach uploads every slot file in the directory to its NAMED slot, and names the missing ones', async () => {
139
+ const dir = path.join(TMP, 'shots');
140
+ fs.mkdirSync(dir, { recursive: true });
141
+ const present = visuals.TWEAK_RENDER_SLOTS.slice(0, 6);
142
+ for (const s of present) fs.writeFileSync(path.join(dir, `${s}.png`), PNG);
143
+ const posts = [];
144
+ const client = { tasks: { postTasksIdVisualsSlot: async (a) => { posts.push(a); return { ok: true, status: 201 }; } } };
145
+ const errs = [];
146
+ const origErr = console.error, origLog = console.log;
147
+ console.error = (m) => errs.push(String(m)); console.log = () => {};
148
+ let code;
149
+ try { code = await renders.attach({ taskId: 5001, dir }, { client }); } finally { console.error = origErr; console.log = origLog; }
150
+ assert.deepEqual(posts.map((p) => p.slot), present);
151
+ assert.ok(posts.every((p) => p.id === 5001 && p.body.content_type === 'image/png'));
152
+ assert.equal(code, 1, 'a missing slot is not a success');
153
+ assert.ok(errs.some((m) => m.includes('after-phone-light') && m.includes('after-phone-dark')), 'the missing slots are named');
154
+ for (const s of visuals.TWEAK_RENDER_SLOTS.slice(6)) fs.writeFileSync(path.join(dir, `${s}.png`), PNG);
155
+ console.log = () => {};
156
+ try { code = await renders.attach({ taskId: 5001, dir }, { client }); } finally { console.log = origLog; }
157
+ assert.equal(code, 0, 'all eight attached');
158
+ });
159
+
160
+ fs.rmSync(TMP, { recursive: true, force: true });
161
+ summary();
@@ -0,0 +1,265 @@
1
+ // tests/tweak_batch_apply.mjs — copy-apply.js --batch lands a whole page round
2
+ // (task 1004322 / BV2.TW11; ADR 0341 D4).
3
+ //
4
+ // The done-when: "/tweak applies a multi-line page batch ... a refused line is
5
+ // named, not skipped." Two layers:
6
+ // 1. the pure core (applyBatchToSources) over fixture sources and a registry
7
+ // built by the REAL extractors, so a line is matched exactly the way the
8
+ // inventory reads it;
9
+ // 2. the whole command (batchMain) on a FIXTURE ROUND: a page-tweak task read
10
+ // from a fake API, its files from an in-memory tree, the registry
11
+ // regenerated by the real extractors over the files it wrote, and the
12
+ // page-tweak-applied block PATCHed back and parsed by the one reader.
13
+ // No DB, no network, no browser (the page re-read is the one stubbed step; the
14
+ // real re-read is page-reader.js --stale, covered by tests/page_reader.mjs).
15
+ //
16
+ // Run: node tests/tweak_batch_apply.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { makeRunner } from './helpers.mjs';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ci = require('../scripts/gds/copy-inventory.js');
26
+ const pages = require('../modules/copy-desk/pages.js');
27
+ const apply = require('../scripts/gds/copy-apply.js');
28
+
29
+ const { test, summary } = makeRunner();
30
+ const REPO_ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
31
+
32
+ const HTML = 'modules/public-landing/public/fixture-tweak.html';
33
+ const JS = 'modules/hall-ui/public/fixture-tweak.js';
34
+ const OTHER = 'modules/public-landing/public/fixture-other.html';
35
+ const SURFACE_OF = { [HTML]: 'landing', [JS]: 'builders-hall', [OTHER]: 'landing' };
36
+
37
+ const TREE = () => ({
38
+ [HTML]: `<!doctype html>
39
+ <html><body>
40
+ <main>
41
+ <h1>Welcome to the studio</h1>
42
+ <p>Take a page and rewrite every word of it.</p>
43
+ <p>Every word counts, and every word is yours.</p>
44
+ <button>Take next</button>
45
+ </main>
46
+ </body></html>
47
+ `,
48
+ [JS]: "'use strict';\nfunction render(el, n) {\n el.innerHTML = `<p>You have ${n} pages waiting for review</p>`;\n}\n",
49
+ [OTHER]: '<!doctype html>\n<html><body>\n<p>Take next</p>\n</body></html>\n',
50
+ });
51
+
52
+ // The registry the inventory would write over a tree: the real extractors, the
53
+ // real stable id, the span dropped exactly as buildRegistry drops it.
54
+ function registryOver(tree) {
55
+ const entries = [];
56
+ for (const [rel, source] of Object.entries(tree)) {
57
+ const rows = rel.endsWith('.html') ? ci.fromHtml({ source, rel }) : ci.fromJs({ source, rel });
58
+ for (const { start, end, ...row } of rows) entries.push({ id: ci.stableId(SURFACE_OF[rel], row.text), surface: SURFACE_OF[rel], ...row });
59
+ }
60
+ return { entries, counts: { strings: entries.length } };
61
+ }
62
+
63
+ const REG = registryOver(TREE());
64
+ const row = (text, file) => REG.entries.find((e) => e.text === text && (!file || e.file === file));
65
+ const line = (key, before, after, file, extra = {}) => {
66
+ const r = row(extra.registryText || before, file);
67
+ return { key, section: 'main', before, after, placement: 'placed', file: r ? r.file : file, line: r ? r.line : 1, string_id: r ? r.id : 'ffffffffffff', ...extra.override };
68
+ };
69
+
70
+ const BATCH = () => ({
71
+ page_id: 'landing:fixture',
72
+ reading_hash: 'r-before',
73
+ submitted_at: '2026-09-28T00:00:00Z',
74
+ lines: [
75
+ line('L0001', 'Welcome to the studio', 'Welcome to your studio', HTML),
76
+ line('L0002', 'Take a page and rewrite every word of it.', 'Take a page and make every word of it yours.', HTML),
77
+ line('L0003', 'You have {…} pages waiting for review', 'There are {…} pages waiting for you', JS),
78
+ line('L0004', 'Take next', 'Take the next page', HTML),
79
+ ],
80
+ });
81
+
82
+ const readerOf = (tree) => (rel) => { if (!(rel in tree)) throw new Error(`ENOENT ${rel}`); return tree[rel]; };
83
+
84
+ await test('a multi-line batch applies every line, across files, holes put back', () => {
85
+ const tree = TREE();
86
+ const out = apply.applyBatchToSources({ batch: BATCH(), registry: REG, readSource: readerOf(tree) });
87
+ assert.deepEqual(out.refused, []);
88
+ assert.deepEqual(out.applied.map((a) => a.key), ['L0001', 'L0002', 'L0003', 'L0004'], 'reported in PAGE order');
89
+ const html = out.sources.get(HTML);
90
+ assert.match(html, /<h1>Welcome to your studio<\/h1>/);
91
+ assert.match(html, /<p>Take a page and make every word of it yours\.<\/p>/);
92
+ assert.match(html, /<button>Take the next page<\/button>/);
93
+ assert.match(html, /Every word counts, and every word is yours\./, 'an untouched line is untouched');
94
+ assert.equal(out.sources.get(JS), "'use strict';\nfunction render(el, n) {\n el.innerHTML = `<p>There are ${n} pages waiting for you</p>`;\n}\n", 'the ${n} expression is re-injected, not the {…} placeholder');
95
+ assert.ok(!out.sources.has(OTHER), 'the same sentence in ANOTHER file is left alone: the line names its own file');
96
+ });
97
+
98
+ await test('a refused line is NAMED, with its code, and the rest of the batch still lands', () => {
99
+ const b = BATCH();
100
+ b.lines.push(
101
+ // the page changed after submit: its string is gone from the registry
102
+ { key: 'L0005', section: 'main', before: 'A sentence no page shows', after: 'Still not shown', placement: 'placed', file: HTML, line: 9, string_id: '000000000000' },
103
+ // the rewrite drops the value the page drops in
104
+ line('L0006', 'You have {…} pages waiting for review', 'Lots of pages are waiting', JS),
105
+ // a fragment that appears twice inside its string
106
+ line('L0007', 'word', 'phrase', HTML, { registryText: 'Every word counts, and every word is yours.' }),
107
+ // a fragment that is not inside its string at all
108
+ line('L0008', 'nowhere in it', 'still nowhere', HTML, { registryText: 'Every word counts, and every word is yours.' }),
109
+ );
110
+ // and a registry row that points outside every surface
111
+ const reg = { entries: [...REG.entries, { id: 'aaaaaaaaaaaa', surface: 'landing', text: 'Secret', file: 'config/secret.html', line: 1, origin: 'html-text' }] };
112
+ b.lines.push({ key: 'L0009', section: 'main', before: 'Secret', after: 'Public', placement: 'placed', file: 'config/secret.html', line: 1, string_id: 'aaaaaaaaaaaa' });
113
+ const out = apply.applyBatchToSources({ batch: b, registry: reg, readSource: readerOf(TREE()) });
114
+ assert.deepEqual(out.refused.map((x) => [x.key, x.code]), [
115
+ ['L0005', 'target_gone'],
116
+ ['L0006', 'placeholder_mismatch'],
117
+ ['L0007', 'ambiguous_target'],
118
+ ['L0008', 'no_exact_span'],
119
+ ['L0009', 'path_outside_surface'],
120
+ ]);
121
+ for (const x of out.refused) assert.ok(apply.REFUSAL_CODES.includes(x.code), `${x.code} is a named code`);
122
+ assert.equal(out.applied.length, 4, 'the four good lines still applied');
123
+ assert.equal(out.applied.length + out.refused.length, b.lines.length, 'NO line is skipped silently: every line is applied or refused');
124
+ });
125
+
126
+ await test('two lines inside ONE string compose: the second is resolved against what the first left', () => {
127
+ const b = { page_id: 'landing:fixture', lines: [
128
+ line('L0001', 'Every word counts', 'Each word matters', HTML, { registryText: 'Every word counts, and every word is yours.' }),
129
+ line('L0002', 'is yours', 'belongs to you', HTML, { registryText: 'Every word counts, and every word is yours.' }),
130
+ ] };
131
+ const out = apply.applyBatchToSources({ batch: b, registry: REG, readSource: readerOf(TREE()) });
132
+ assert.deepEqual(out.refused, []);
133
+ assert.match(out.sources.get(HTML), /<p>Each word matters, and every word belongs to you\.<\/p>/);
134
+ });
135
+
136
+ await test('a string id shared by two files resolves to the row in the line\'s own file', () => {
137
+ const r = apply.registryRowFor({ string_id: row('Take next', OTHER).id, file: OTHER, line: 3 }, new Map([[row('Take next', OTHER).id, REG.entries.filter((e) => e.text === 'Take next')]]));
138
+ assert.equal(r.file, OTHER);
139
+ });
140
+
141
+ await test('the applied block leaves shared lines out of lines_after and carries each refusal', () => {
142
+ const block = apply.appliedBlockFor({
143
+ pageId: 'landing:fixture',
144
+ appliedAt: '2026-09-28T01:00:00Z',
145
+ readingPage: { reading_hash: 'r-after', lines: [
146
+ { key: 'L0001', text: 'Skip to content', placement: 'shared' },
147
+ { key: 'L0002', text: 'Welcome to your studio', placement: 'placed' },
148
+ { key: 'L0003', text: 'Something unplaced', placement: 'unplaced' },
149
+ ] },
150
+ refused: [{ key: 'L0005', code: 'target_gone', detail: { x: 1 } }],
151
+ });
152
+ assert.deepEqual(block.lines_after, ['Welcome to your studio', 'Something unplaced']);
153
+ assert.deepEqual(block.refused, [{ key: 'L0005', code: 'target_gone' }], 'key and code only');
154
+ const parsed = pages.parseAppliedBlock(pages.composeAppliedBlock(block));
155
+ assert.equal(parsed.ok, true);
156
+ assert.equal(parsed.applied.reading_hash_after, 'r-after');
157
+ });
158
+
159
+ // ---- the whole command on a fixture round ---------------------------------
160
+
161
+ function fixtureRound(batch) {
162
+ const page = { id: batch.page_id, title: 'Fixture' };
163
+ return [pages.roundTaskBody(page, 1), '', pages.composeBatchBlock({ ...batch, unplaced_tasks: [] })].join('\n');
164
+ }
165
+
166
+ function harness({ batch, source = 'page-tweak', registryOverride = null, rereadOk = true } = {}) {
167
+ const tree = TREE();
168
+ const abs = (rel) => path.join(REPO_ROOT, rel);
169
+ const files = new Map(Object.entries(tree).map(([rel, s]) => [abs(rel), s]));
170
+ const writes = [];
171
+ const calls = [];
172
+ const task = { id: '5001', source, description: fixtureRound(batch) };
173
+ const deps = {
174
+ log: () => {},
175
+ readFile: (p) => { if (!files.has(p)) throw new Error(`ENOENT ${p}`); return files.get(p); },
176
+ writeFile: (p, s) => { writes.push(p); files.set(p, s); },
177
+ registry: REG,
178
+ regenerateRegistry: () => ({ reg: registryOverride || registryOver(Object.fromEntries(Object.keys(tree).map((rel) => [rel, files.get(abs(rel))]))) }),
179
+ reread: () => (rereadOk ? { ok: true } : { ok: false, code: 'no_browser' }),
180
+ readingFor: () => ({ reading_hash: 'r-after', lines: [{ key: 'L0001', text: 'Skip to content', placement: 'shared' }, { key: 'L0002', text: 'Welcome to your studio', placement: 'placed' }] }),
181
+ api: async (method, url, body) => {
182
+ calls.push({ method, url, body });
183
+ if (method === 'GET') return { ok: true, status: 200, data: { task } };
184
+ if (method === 'PATCH') { task.description = body.description; return { ok: true, status: 200, data: { task } }; }
185
+ return { ok: false, status: 405 };
186
+ },
187
+ };
188
+ return { deps, files, writes, calls, task, abs };
189
+ }
190
+
191
+ await test('END TO END: a fixture round is applied, its files written, and the applied block recorded on the task', async () => {
192
+ const b = BATCH();
193
+ b.lines.push({ key: 'L0005', section: 'main', before: 'A sentence no page shows', after: 'Still not shown', placement: 'placed', file: HTML, line: 9, string_id: '000000000000' });
194
+ const h = harness({ batch: b });
195
+ const code = await apply.batchMain({ taskId: '5001', now: () => '2026-09-28T02:00:00Z' }, h.deps);
196
+ assert.equal(code, 0);
197
+ assert.match(h.files.get(h.abs(HTML)), /Welcome to your studio/);
198
+ assert.match(h.files.get(h.abs(JS)), /There are \$\{n\} pages waiting for you/);
199
+ assert.deepEqual(h.writes.sort(), [h.abs(HTML), h.abs(JS)].sort(), 'only the files the batch changed are written');
200
+ const patch = h.calls.find((c) => c.method === 'PATCH');
201
+ assert.ok(patch, 'the applied block is written back to the round');
202
+ assert.equal(patch.url, '/api/bongos/tasks/5001');
203
+ const applied = pages.parseAppliedBlock(patch.body.description);
204
+ assert.equal(applied.ok, true, 'one applied block, readable by the one reader');
205
+ assert.deepEqual(applied.applied.refused, [{ key: 'L0005', code: 'target_gone' }], 'the refused line is named on the task, not skipped');
206
+ assert.equal(applied.applied.reading_hash_after, 'r-after');
207
+ assert.deepEqual(applied.applied.lines_after, ['Welcome to your studio']);
208
+ assert.equal(pages.parseBatchBlock(patch.body.description).ok, true, 'the frozen batch is kept beside it');
209
+ const presence = pages.blockPresence(patch.body.description);
210
+ assert.equal(presence.applied, true);
211
+ assert.equal(presence.applied_at, '2026-09-28T02:00:00Z');
212
+ });
213
+
214
+ await test('a re-apply REPLACES the applied block rather than adding a second', async () => {
215
+ const h = harness({ batch: BATCH() });
216
+ await apply.batchMain({ taskId: '5001', now: () => 't1' }, h.deps);
217
+ const h2 = harness({ batch: BATCH() });
218
+ h2.task.description = h.task.description;
219
+ await apply.batchMain({ taskId: '5001', now: () => 't2' }, h2.deps);
220
+ assert.equal(pages.parseAppliedBlock(h2.task.description).ok, true, 'still exactly one applied block');
221
+ assert.equal(pages.parseAppliedBlock(h2.task.description).applied.applied_at, 't2');
222
+ });
223
+
224
+ await test('--dry-run writes no file and no task', async () => {
225
+ const h = harness({ batch: BATCH() });
226
+ const code = await apply.batchMain({ taskId: '5001', dryRun: true }, h.deps);
227
+ assert.equal(code, 0);
228
+ assert.deepEqual(h.writes, []);
229
+ assert.ok(!h.calls.some((c) => c.method === 'PATCH'));
230
+ });
231
+
232
+ await test('every line refused: exit 2, and the refusals are still recorded on the task', async () => {
233
+ const h = harness({ batch: { page_id: 'landing:fixture', reading_hash: 'r', submitted_at: 't', lines: [
234
+ { key: 'L0005', section: 'main', before: 'Gone', after: 'Still gone', placement: 'placed', file: HTML, line: 9, string_id: '000000000000' },
235
+ ] } });
236
+ const code = await apply.batchMain({ taskId: '5001' }, h.deps);
237
+ assert.equal(code, 2);
238
+ const patch = h.calls.find((c) => c.method === 'PATCH');
239
+ assert.deepEqual(pages.parseAppliedBlock(patch.body.description).applied.refused, [{ key: 'L0005', code: 'target_gone' }]);
240
+ });
241
+
242
+ await test('the regenerated registry not showing the new wording is a hard stop (exit 3), before any task write', async () => {
243
+ const h = harness({ batch: BATCH(), registryOverride: REG });
244
+ const code = await apply.batchMain({ taskId: '5001' }, h.deps);
245
+ assert.equal(code, 3);
246
+ assert.ok(!h.calls.some((c) => c.method === 'PATCH'));
247
+ });
248
+
249
+ await test('a page that cannot be re-read stops before the applied block (exit 4): no block from a stale reading', async () => {
250
+ const h = harness({ batch: BATCH(), rereadOk: false });
251
+ assert.equal(await apply.batchMain({ taskId: '5001' }, h.deps), 4);
252
+ assert.ok(!h.calls.some((c) => c.method === 'PATCH'));
253
+ });
254
+
255
+ await test('--batch refuses a task that is not a page tweak', async () => {
256
+ const h = harness({ batch: BATCH(), source: 'copy-proposal' });
257
+ const logged = [];
258
+ const origErr = console.error;
259
+ console.error = (m) => logged.push(String(m));
260
+ try { assert.equal(await apply.batchMain({ taskId: '5001' }, h.deps), 2); } finally { console.error = origErr; }
261
+ assert.ok(logged.some((m) => m.includes('not_a_page_tweak')));
262
+ assert.deepEqual(h.writes, []);
263
+ });
264
+
265
+ summary();