@bongos/core 1.19.1059 → 1.19.1061

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.
@@ -103,6 +103,109 @@ const CLAIM_BODY_SCHEMA = Object.freeze({});
103
103
  const ASK_BODY_SCHEMA = Object.freeze({
104
104
  reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
105
105
  });
106
+ // The draft autosave (task 1004319 / TW08): the reading the editor was built
107
+ // on, and every changed line as { key, after }. Nothing else, and in
108
+ // particular no `before`, `section`, `placement` or file: every fact about a
109
+ // target is resolved from the page reading (pages.resolveDraft).
110
+ const DRAFT_BODY_SCHEMA = Object.freeze({
111
+ reading_hash: { required: true, type: 'string', maxLength: 64, minLength: 1 },
112
+ lines: { required: true, type: 'array', maxItems: pages.MAX_DRAFT_LINES },
113
+ });
114
+ // The submit takes nothing: it freezes the draft already on the task.
115
+ const SUBMIT_BODY_SCHEMA = Object.freeze({});
116
+
117
+ // The refusals of pages.resolveDraft / freezeBatch, as HTTP statuses.
118
+ const PAGE_REFUSAL_STATUS = Object.freeze({
119
+ reading_missing: 503,
120
+ reading_moved: 409,
121
+ lines_refused: 409,
122
+ nothing_to_submit: 409,
123
+ multiple_blocks: 409,
124
+ block_unreadable: 409,
125
+ block_schema_unknown: 409,
126
+ block_field_missing: 409,
127
+ wrong_page: 409,
128
+ });
129
+ const PAGE_REFUSAL_MEANS = Object.freeze({
130
+ reading_moved: 'The page changed since the editor loaded it, so a line key may now mean a different line. Reload the page to get the current reading; nothing was saved.',
131
+ unknown_line: 'The page reading has no line with that key. Reload the editor.',
132
+ line_empty: 'A line cannot be rewritten to nothing. Removing text from a page is a code change; leave the line as it is or give it new words.',
133
+ line_too_long: `A line holds at most ${pages.MAX_LINE_CHARS} characters.`,
134
+ lines_refused: 'Some lines cannot be frozen as written: the page no longer shows them (target_gone), shows the same words in more than one place (ambiguous_target), or the rewrite dropped or added a {…} hole (placeholder_mismatch). Fix or remove each named line and submit again; nothing was submitted.',
135
+ nothing_to_submit: 'The draft has no rewritten lines yet.',
136
+ });
137
+
138
+ function refusePage(res, r) {
139
+ const status = PAGE_REFUSAL_STATUS[r.code] || 400;
140
+ const means = PAGE_REFUSAL_MEANS[r.code];
141
+ return res.fail(r.code, status, { ...(r.detail || {}), ...(means ? { what_this_means: means } : {}) });
142
+ }
143
+
144
+ // The page's reading, which every draft and batch target comes from. Returns
145
+ // null when it has already responded.
146
+ function readingPageOrFail(res, pageId) {
147
+ const readings = readingsState();
148
+ if (!readings.data) {
149
+ res.fail('readings_missing', 503, {
150
+ run: readings.info.run || 'node scripts/gds/page-reader.js',
151
+ what_this_means: 'A draft is resolved against the committed page readings, and this instance has none. Nothing was saved.',
152
+ });
153
+ return null;
154
+ }
155
+ const readingPage = (readings.data.pages || []).find((p) => p && p.id === pageId);
156
+ if (!readingPage) {
157
+ res.fail('page_unread', 503, {
158
+ page_id: pageId,
159
+ what_this_means: 'The page readings do not include this page yet, so there are no lines to resolve a draft against.',
160
+ });
161
+ return null;
162
+ }
163
+ return readingPage;
164
+ }
165
+
166
+ // The page's OPEN round, from the same read the page routes use.
167
+ function openRoundOf(tasks, pageId) {
168
+ const open = (tasks || []).filter((t) => {
169
+ const ref = pages.parseRoundRef(t.source_ref);
170
+ return ref && ref.page_id === pageId && t.status !== 'shipped' && t.status !== 'abandoned';
171
+ });
172
+ return open.length ? open[open.length - 1] : null;
173
+ }
174
+
175
+ // The answer to a repeated submit: the round is in the queue (ready, a frozen
176
+ // batch, nobody holding it) and the caller is its artist, the builder of its
177
+ // latest web claim. Null when that is not the case.
178
+ function alreadySubmitted(round, builderId, pageId) {
179
+ if (round.holder_id != null || round.status !== 'ready' || String(round.artist_id) !== String(builderId)) return null;
180
+ const b = pages.parseBatchBlock(round.description);
181
+ if (!b.ok) return null;
182
+ return {
183
+ ok: true, outcome: 'already_submitted', page_id: pageId,
184
+ round: { task_id: String(round.id), state: 'submitted', status: 'ready' },
185
+ batch: { line_count: b.batch.lines.length, submitted_at: b.batch.submitted_at, reading_hash: b.batch.reading_hash },
186
+ unplaced_tasks: (b.batch.unplaced_tasks || []).map((id) => ({ task_id: String(id) })),
187
+ next: { path: '/copy-desk/next' },
188
+ };
189
+ }
190
+
191
+ // The port's not_held outcome, as the answer the editor shows.
192
+ function refuseNotHeld(res, pageId, r, round) {
193
+ // Nobody holds it and its batch is frozen: it is in the /tweak queue.
194
+ if (!r.holder && round && pages.blockPresence(round.description).batch) {
195
+ return res.fail('page_in_flight', 409, {
196
+ page_id: pageId,
197
+ what_this_means: 'This page has been submitted and is on its way to the site. Its draft is frozen until it ships or is sent back.',
198
+ });
199
+ }
200
+ const who = r.holder ? (r.holder.name || r.holder.login || 'Another builder') : null;
201
+ return res.fail('page_not_held', 409, {
202
+ page_id: pageId,
203
+ holder: r.holder ? { id: r.holder.id, login: r.holder.login, name: r.holder.name } : null,
204
+ what_this_means: who
205
+ ? `${who} holds this page now, so only they can change its draft.`
206
+ : 'You do not hold this page. Claim it again (POST /copy-desk/pages/:pageId/claim) and the draft is still there.',
207
+ });
208
+ }
106
209
 
107
210
  // Load the registry or answer the named "no registry" state. Returns null when
108
211
  // it has already responded, so every handler is `if (!reg) return;`.
@@ -663,6 +766,47 @@ module.exports = function buildCopyDeskRouter() {
663
766
  }
664
767
  });
665
768
 
769
+ // -------------------------------------------------------------------------
770
+ // GET /copy-desk/next — the Tweak/CopyWrite Flow's recommendation (task
771
+ // 1004318 / BV2.TW07, ADR 0341 D8): the page the caller should tweak next
772
+ // and why ("2 people asked for it"), plus the next few for "pick another".
773
+ //
774
+ // Order: asked for (most distinct askers, then the oldest ask), then changed
775
+ // since the last tweak (most changed lines), then untweaked (landing,
776
+ // builders, status, then inventory order); tweaked pages with no drift last.
777
+ // A page another builder holds, or whose round is in flight, is never
778
+ // recommended (it is listed in `skipped`). The caller's own held page is
779
+ // `resume`, not a recommendation.
780
+ //
781
+ // "Take next" is POST /copy-desk/pages/:pageId/claim on `recommended.page_id`
782
+ // (each candidate carries its `take`), one call. The availability rule here
783
+ // is the claim's own, so that call is not refused for a reason this read knew.
784
+ //
785
+ // rank: any signed-in builder. The read is the caller's own and needs no
786
+ // craft; the claim it points at is where the artist gate lives.
787
+ // -------------------------------------------------------------------------
788
+ router.get('/copy-desk/next', api.requireBuilder, async (req, res) => {
789
+ try {
790
+ const inv = inventoryOrFail(res);
791
+ if (!inv) return;
792
+ const lifecycle = pageReadsOrFail(res);
793
+ if (!lifecycle) return;
794
+ const readings = readingsState();
795
+ const [tasks, asks] = await Promise.all([
796
+ lifecycle.listPageTweakTasks(),
797
+ flags.pageAskSummary(),
798
+ ]);
799
+ const out = pageStatus.composeNext({ inventory: inv.data, readings: readings.data, tasks, asks, builderId: req.builder.id });
800
+ out.inventory = { at: inv.at };
801
+ out.readings = readings.info;
802
+ res.set('Cache-Control', 'no-store');
803
+ res.json(out);
804
+ } catch (err) {
805
+ log.error({ err }, 'GET /copy-desk/next failed');
806
+ if (!res.headersSent) res.fail('copy_next_failed', 500);
807
+ }
808
+ });
809
+
666
810
  // -------------------------------------------------------------------------
667
811
  // POST /copy-desk/pages/:pageId/claim — claim a page's tweak (task 1004317 /
668
812
  // BV2.TW06, ADR 0341 D2 and D5). Also Resume and "Take next".
@@ -800,6 +944,211 @@ module.exports = function buildCopyDeskRouter() {
800
944
  }
801
945
  });
802
946
 
947
+ // -------------------------------------------------------------------------
948
+ // PUT /copy-desk/pages/:pageId/draft — autosave the artist's draft (task
949
+ // 1004319 / BV2.TW08, ADR 0341 D4).
950
+ //
951
+ // Body: { reading_hash, lines: [{ key, after }] }, the WHOLE draft: every line
952
+ // the artist has changed. The saved draft becomes exactly that (a line sent
953
+ // back at its current text is dropped, so only changed lines are stored).
954
+ // rank: any signed-in builder; only the HOLDER of the page's writing claim
955
+ // (the web claim the page claim took) may save, checked in the
956
+ // lifecycle transaction.
957
+ //
958
+ // Every target is resolved from the page reading by key: section, current
959
+ // text (`before`) and placement are the reading's, never the body's. A body
960
+ // built on an older reading is refused `reading_moved`, whole, since its keys
961
+ // may now mean other lines.
962
+ //
963
+ // Cheap and idempotent: a save that resolves to the draft already stored
964
+ // writes nothing (200 `unchanged`; `updated_at` does not move). The editor
965
+ // debounces; the server only has to make a repeated save free.
966
+ //
967
+ // 200 saved | unchanged with the draft and "N of M rewritten"
968
+ // 409 page_not_held the caller does not hold the page (holder named)
969
+ // 409 no_open_round the page has no round: claim it first
970
+ // 409 reading_moved · 400 unknown_line / line_empty / line_too_long
971
+ // -------------------------------------------------------------------------
972
+ router.put('/copy-desk/pages/:pageId/draft', api.requireBuilder, async (req, res) => {
973
+ try {
974
+ if (validateOrRespond(req, res, DRAFT_BODY_SCHEMA)) return;
975
+ const found = pageOrFail(req, res);
976
+ if (!found) return;
977
+ const { pageId } = found;
978
+ const lifecycle = pageReadsOrFail(res);
979
+ if (!lifecycle) return;
980
+ if (typeof lifecycle.updateHeldTaskDescription !== 'function') {
981
+ return res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot save a page draft. Nothing was saved.' });
982
+ }
983
+ const readingPage = readingPageOrFail(res, pageId);
984
+ if (!readingPage) return;
985
+
986
+ const resolved = pages.resolveDraft({ readingPage, body: req.body, savedAt: new Date().toISOString() });
987
+ if (!resolved.ok) return refusePage(res, resolved);
988
+
989
+ const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
990
+ if (!round) {
991
+ return res.fail('no_open_round', 409, {
992
+ page_id: pageId,
993
+ what_this_means: 'Nobody is writing this page. Claim it first (POST /copy-desk/pages/:pageId/claim).',
994
+ });
995
+ }
996
+
997
+ let draft = resolved.draft;
998
+ const r = await lifecycle.updateHeldTaskDescription({
999
+ taskId: round.id,
1000
+ builderId: req.builder.id,
1001
+ source: pages.SOURCE,
1002
+ compose: (task) => {
1003
+ const prev = pages.parseDraftBlock(task.description);
1004
+ // The same draft again: keep the stored block, saved_at included.
1005
+ if (prev.ok && pages.sameDraft(prev.draft, resolved.draft)) {
1006
+ draft = prev.draft;
1007
+ return { ok: true, description: task.description };
1008
+ }
1009
+ // No draft yet and nothing changed: there is nothing to store.
1010
+ if (prev.code === 'no_block' && !resolved.draft.lines.length) return { ok: true, description: task.description };
1011
+ return pages.replaceBlock(task.description, pages.FENCES.draft, pages.composeDraftBlock(resolved.draft));
1012
+ },
1013
+ });
1014
+ switch (r.outcome) {
1015
+ case 'saved':
1016
+ case 'unchanged':
1017
+ return res.json({
1018
+ ok: true,
1019
+ outcome: r.outcome,
1020
+ page_id: pageId,
1021
+ round: { task_id: String(round.id), state: 'writing' },
1022
+ draft: { reading_hash: draft.reading_hash, saved_at: draft.saved_at, lines: draft.lines },
1023
+ // The editor's counter, "N of M rewritten · saved" (Editor.dc.html).
1024
+ counter: { changed: draft.lines.length, total: readingPage.lines.length },
1025
+ });
1026
+ case 'not_held':
1027
+ return refuseNotHeld(res, pageId, r, round);
1028
+ case 'refused':
1029
+ return refusePage(res, r.refusal);
1030
+ default:
1031
+ throw new Error(`updateHeldTaskDescription: unexpected outcome ${r.outcome}`);
1032
+ }
1033
+ } catch (err) {
1034
+ log.error({ err }, 'PUT /copy-desk/pages/:pageId/draft failed');
1035
+ if (!res.headersSent) res.fail('copy_page_draft_failed', 500);
1036
+ }
1037
+ });
1038
+
1039
+ // -------------------------------------------------------------------------
1040
+ // POST /copy-desk/pages/:pageId/submit — freeze the draft and queue the page
1041
+ // for /tweak (task 1004319 / BV2.TW08, ADR 0341 D4, D7).
1042
+ //
1043
+ // Body: {} (it freezes the draft already on the task; nothing is taken from
1044
+ // the request). rank: any signed-in builder; the holder only.
1045
+ //
1046
+ // One lifecycle transaction:
1047
+ // * every draft line is re-resolved on the CURRENT page reading; a line the
1048
+ // page no longer shows is refused by name (target_gone), as are an
1049
+ // ambiguous one and a {…} hole added or dropped. Any refusal refuses the
1050
+ // submit, listing the lines, and nothing is frozen;
1051
+ // * a rewrite of an UNPLACED line (and of shared shell text with no
1052
+ // registry row) leaves the batch and files its own engineer task
1053
+ // (source page-tweak-unplaced, backlog), carrying the page, the line and
1054
+ // the wording. The page does not wait for it;
1055
+ // * the rest is frozen into the `page-tweak` block with each line's file,
1056
+ // line and string id from the reading, naming the engineer tasks;
1057
+ // * the artist's web claim is released and the task goes back to `ready`:
1058
+ // the round reads `submitted`, the /tweak queue.
1059
+ //
1060
+ // 200 submitted with the batch, the engineer tasks, and where
1061
+ // "next up" reads (GET /copy-desk/next)
1062
+ // 200 already_submitted a repeated submit by the round's artist
1063
+ // 409 lines_refused · nothing_to_submit · page_not_held · no_open_round
1064
+ // -------------------------------------------------------------------------
1065
+ router.post('/copy-desk/pages/:pageId/submit', api.requireBuilder, async (req, res) => {
1066
+ try {
1067
+ if (validateOrRespond(req, res, SUBMIT_BODY_SCHEMA)) return;
1068
+ const found = pageOrFail(req, res);
1069
+ if (!found) return;
1070
+ const { page, pageId } = found;
1071
+ const lifecycle = pageReadsOrFail(res);
1072
+ if (!lifecycle) return;
1073
+ if (typeof lifecycle.submitPageTweak !== 'function') {
1074
+ return res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot submit a page. Nothing was submitted.' });
1075
+ }
1076
+ const readingPage = readingPageOrFail(res, pageId);
1077
+ if (!readingPage) return;
1078
+
1079
+ const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
1080
+ if (!round) {
1081
+ return res.fail('no_open_round', 409, {
1082
+ page_id: pageId,
1083
+ what_this_means: 'Nobody is writing this page. Claim it first (POST /copy-desk/pages/:pageId/claim).',
1084
+ });
1085
+ }
1086
+ // A double-click: the round's own artist submitting what is already in
1087
+ // the queue gets the same answer, not a refusal.
1088
+ const again = alreadySubmitted(round, req.builder.id, pageId);
1089
+ if (again) return res.json(again);
1090
+
1091
+ const building = proposals.resolveTargetVersion(await lifecycle.listVersions());
1092
+ const submittedAt = new Date().toISOString();
1093
+ let frozen = null;
1094
+ const r = await lifecycle.submitPageTweak({
1095
+ taskId: round.id,
1096
+ builderId: req.builder.id,
1097
+ versionId: building ? building.id : null,
1098
+ freeze: (task) => {
1099
+ const d = pages.parseDraftBlock(task.description);
1100
+ if (!d.ok) return d.code === 'no_block' ? { ok: false, code: 'nothing_to_submit', detail: {} } : d;
1101
+ if (d.draft.page_id !== pageId) return { ok: false, code: 'wrong_page', detail: { page_id: pageId, draft_page_id: d.draft.page_id } };
1102
+ const f = pages.freezeBatch({ readingPage, draft: d.draft, submittedAt });
1103
+ if (!f.ok) return f;
1104
+ const probe = pages.replaceBlock(task.description, pages.FENCES.batch, '');
1105
+ if (!probe.ok) return probe;
1106
+ frozen = f;
1107
+ return {
1108
+ ok: true,
1109
+ unplaced: f.unplaced.map((l) => ({
1110
+ key: l.key,
1111
+ source: pages.UNPLACED_SOURCE,
1112
+ sourceRef: pages.unplacedSourceRef(task.id, l.key),
1113
+ title: pages.unplacedTaskTitle(page, l),
1114
+ description: pages.unplacedTaskBody(page, l, task.id),
1115
+ touches: Array.isArray(page.files) ? page.files : [],
1116
+ })),
1117
+ description: (ids) => pages.replaceBlock(task.description, pages.FENCES.batch,
1118
+ pages.composeBatchBlock({ ...f.batch, unplaced_tasks: ids })).description,
1119
+ };
1120
+ },
1121
+ });
1122
+ switch (r.outcome) {
1123
+ case 'submitted':
1124
+ return res.json({
1125
+ ok: true, outcome: 'submitted', page_id: pageId,
1126
+ round: { task_id: String(r.task.id), state: 'submitted', status: r.task.status },
1127
+ batch: { line_count: frozen.batch.lines.length, submitted_at: submittedAt, reading_hash: frozen.batch.reading_hash },
1128
+ unplaced_tasks: r.unplaced_tasks.map((u) => ({ task_id: u.id, key: u.key, created: u.created })),
1129
+ // "Next up" (the editor's closing card) reads the recommendation.
1130
+ next: { path: '/copy-desk/next' },
1131
+ });
1132
+ case 'not_held': {
1133
+ // A second submit that raced the first waits on the task's row
1134
+ // lock and then finds the claim released: read the round again,
1135
+ // and if the first froze it, this is the same double-click.
1136
+ const now = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
1137
+ const same = now && alreadySubmitted(now, req.builder.id, pageId);
1138
+ if (same) return res.json(same);
1139
+ return refuseNotHeld(res, pageId, r, now || round);
1140
+ }
1141
+ case 'refused':
1142
+ return refusePage(res, r.refusal);
1143
+ default:
1144
+ throw new Error(`submitPageTweak: unexpected outcome ${r.outcome}`);
1145
+ }
1146
+ } catch (err) {
1147
+ log.error({ err }, 'POST /copy-desk/pages/:pageId/submit failed');
1148
+ if (!res.headersSent) res.fail('copy_page_submit_failed', 500);
1149
+ }
1150
+ });
1151
+
803
1152
  return router;
804
1153
  };
805
1154
 
@@ -21,7 +21,8 @@
21
21
  // which no renderer reads — and the wording reaches a person only after somebody
22
22
  // claims that task and ships the diff. So the four things pinned here are:
23
23
  //
24
- // * the write surface is EXACTLY the three verbs the module defines,
24
+ // * the write surface is EXACTLY the verbs the module defines (seven since
25
+ // TW08: the page draft and submit write the round TASK, never a row),
25
26
  // * no column on copy_desk_flags could hold replacement text,
26
27
  // * nothing in this module writes a file, and
27
28
  // * the ONLY thing that writes a surface file is the applier, it lives outside
@@ -80,7 +81,13 @@ await test('the write surface is exactly the verbs the module defines', () => {
80
81
  // flag row whose target is a page id. Neither stores wording.
81
82
  'POST /copy-desk/pages/:pageId/asks',
82
83
  'POST /copy-desk/pages/:pageId/claim',
84
+ // TW08 (task 1004319, ADR 0341 D4, D11): the draft autosave and the
85
+ // submit. Both write the page round's TASK DESCRIPTION through the
86
+ // lifecycle port, the place ADR 0233 lets wording rest; neither writes a
87
+ // row here, a file, or anything a renderer reads.
88
+ 'POST /copy-desk/pages/:pageId/submit',
83
89
  'POST /copy-desk/proposals',
90
+ 'PUT /copy-desk/pages/:pageId/draft',
84
91
  ], 'a new write route on this module must be a deliberate, reviewed change to this list');
85
92
  });
86
93
 
@@ -185,6 +192,34 @@ await test('the page claim creates a TASK through the port and writes no row her
185
192
  assert.ok(/roundTaskBody\(page, n\)/.test(claim), 'the round body comes from pages.roundTaskBody over the inventory page');
186
193
  });
187
194
 
195
+ await test('the draft and the submit write only the round task, through the port, with targets from the reading', () => {
196
+ const src = stripComments(read('routes/copy-desk.js'));
197
+ const handler = (verb, p) => {
198
+ const start = src.indexOf(`router.${verb}('${p}'`);
199
+ assert.ok(start >= 0, `${verb.toUpperCase()} ${p} is mounted`);
200
+ const next = src.indexOf('router.', start + 1);
201
+ return src.slice(start, next === -1 ? undefined : next);
202
+ };
203
+ const draft = handler('put', '/copy-desk/pages/:pageId/draft');
204
+ const submit = handler('post', '/copy-desk/pages/:pageId/submit');
205
+ assert.ok(/lifecycle\.updateHeldTaskDescription\(/.test(draft), 'the draft lands on the task via the lifecycle port');
206
+ assert.ok(/lifecycle\.submitPageTweak\(/.test(submit), 'the submit lands on the task via the lifecycle port');
207
+ for (const [name, h] of [['draft', draft], ['submit', submit]]) {
208
+ assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE /i.test(h), false, `the ${name} route must write no row in this module`);
209
+ // Every target fact is the reading's. A handler reading `before`, a
210
+ // section, a placement or a file from the body would let a caller describe
211
+ // an edit to a sentence the page does not show.
212
+ assert.equal(/req\.body\.(before|section|placement|file|line|string_id|text)\b/.test(h), false,
213
+ `the ${name} route must not take a target from the request body`);
214
+ assert.ok(/readingPageOrFail\(res, pageId\)/.test(h), `the ${name} route resolves against the page reading`);
215
+ }
216
+ assert.ok(/pages\.resolveDraft\(\{ readingPage,/.test(draft), 'the draft is resolved from the reading by pages.resolveDraft');
217
+ assert.ok(/pages\.freezeBatch\(\{ readingPage,/.test(submit), 'the batch is frozen from the reading by pages.freezeBatch');
218
+ // The body schemas carry no target field.
219
+ const schemas = src.slice(src.indexOf('const DRAFT_BODY_SCHEMA'), src.indexOf('const PAGE_REFUSAL_STATUS'));
220
+ assert.equal(/\b(before|section|placement|file|string_id)\s*:/.test(schemas), false, 'the draft and submit schemas name no target field');
221
+ });
222
+
188
223
  await test('the module manifest declares no seam another module could write copy through', () => {
189
224
  const manifest = JSON.parse(read('module.json'));
190
225
  assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');
@@ -0,0 +1,202 @@
1
+ // modules/copy-desk/tests/copy_page_draft.mjs — the page draft and the frozen
2
+ // batch, pure (task 1004319 / BV2.TW08, ADR 0341 D4).
3
+ //
4
+ // What the resolvers in pages.js must do before any route or transaction is
5
+ // involved: a draft save resolves every target from the page reading and keeps
6
+ // only changed lines; the same draft again is recognised as the same; the
7
+ // submit re-resolves each line on the CURRENT reading, refuses by name the
8
+ // lines it cannot place, and splits the unplaced rewrites out for an engineer.
9
+ //
10
+ // The whole path (routes, the lifecycle transaction, the engineer tasks, the
11
+ // released claim) is tests/copy_desk_page_submit.mjs (root).
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const pages = require('../pages.js');
18
+
19
+ let passed = 0;
20
+ let failed = 0;
21
+ function test(name, fn) {
22
+ try { fn(); passed++; console.log(` ok ${name}`); }
23
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
24
+ }
25
+
26
+ // A page reading in the shape page-reader.js writes: placed lines carry their
27
+ // registry row, a shared line may or may not have one, an unplaced line none.
28
+ const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
29
+ const READING = {
30
+ id: 'landing:index',
31
+ reading_hash: 'h-now',
32
+ lines: [
33
+ L('L0001', 'skip link', 'Skip to content', 'shared', { file: 'shell.js', line: 7, string_id: 'aaa111' }),
34
+ L('L0002', 'top bar', 'Ctrl K', 'shared'),
35
+ L('L0003', 'hero', 'Build here', 'placed', { file: 'index.html', line: 12, string_id: 'bbb222' }),
36
+ L('L0004', 'hero', '{…} of {…} shipped', 'placed', { file: 'index.js', line: 40, string_id: 'ccc333' }),
37
+ L('L0005', 'hero', 'Cloud Bongos: Building Digitally', 'unplaced'),
38
+ L('L0006', 'footer', 'Resume', 'placed', { file: 'index.html', line: 90, string_id: 'ddd444' }),
39
+ ],
40
+ };
41
+ const SAVED_AT = '2026-09-28T10:00:00.000Z';
42
+ const body = (lines, hash = 'h-now') => ({ reading_hash: hash, lines });
43
+
44
+ console.log('copy-desk: the page draft and the frozen batch');
45
+
46
+ test('a draft resolves every target from the reading and keeps only changed lines, in page order', () => {
47
+ const r = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([
48
+ { key: 'L0006', after: 'Pick up where you left off' },
49
+ { key: 'L0003', after: ' Build it here ' },
50
+ { key: 'L0001', after: 'Skip to content' }, // unchanged: dropped
51
+ ]) });
52
+ assert.equal(r.ok, true, JSON.stringify(r));
53
+ assert.deepEqual(r.draft, {
54
+ page_id: 'landing:index', reading_hash: 'h-now', saved_at: SAVED_AT,
55
+ lines: [
56
+ { key: 'L0003', section: 'hero', before: 'Build here', after: 'Build it here', placement: 'placed' },
57
+ { key: 'L0006', section: 'footer', before: 'Resume', after: 'Pick up where you left off', placement: 'placed' },
58
+ ],
59
+ });
60
+ });
61
+
62
+ test('a draft body cannot describe its own target: before, section and placement are ignored', () => {
63
+ const r = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([
64
+ { key: 'L0003', after: 'New words', before: 'A sentence the page does not show', section: 'x', placement: 'unplaced' },
65
+ ]) });
66
+ assert.equal(r.ok, true);
67
+ assert.deepEqual([r.draft.lines[0].before, r.draft.lines[0].section, r.draft.lines[0].placement], ['Build here', 'hero', 'placed']);
68
+ });
69
+
70
+ test('a draft built on another reading is refused whole, reading_moved, naming the current hash', () => {
71
+ const r = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([{ key: 'L0003', after: 'x y' }], 'h-old') });
72
+ assert.equal(r.ok, false);
73
+ assert.equal(r.code, 'reading_moved');
74
+ assert.deepEqual(r.detail, { reading_hash: 'h-now', sent: 'h-old' });
75
+ });
76
+
77
+ test('the draft refusals are named: unknown line, empty line, too long, duplicate, malformed, no reading', () => {
78
+ const code = (lines) => pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body(lines) }).code;
79
+ assert.equal(code([{ key: 'L0099', after: 'x' }]), 'unknown_line');
80
+ assert.equal(code([{ key: 'L0003', after: ' ' }]), 'line_empty');
81
+ assert.equal(code([{ key: 'L0003', after: 'x'.repeat(pages.MAX_LINE_CHARS + 1) }]), 'line_too_long');
82
+ assert.equal(code([{ key: 'L0003', after: 'a' }, { key: 'L0003', after: 'b' }]), 'duplicate_line');
83
+ assert.equal(code([{ key: 'nope', after: 'a' }]), 'line_malformed');
84
+ assert.equal(code([{ key: 'L0003' }]), 'line_malformed');
85
+ assert.equal(pages.resolveDraft({ readingPage: null, body: body([]) }).code, 'reading_missing');
86
+ });
87
+
88
+ test('an autosave mid-sentence is accepted: the placeholder arithmetic waits for the submit', () => {
89
+ const r = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([{ key: 'L0004', after: 'shipped so far' }]) });
90
+ assert.equal(r.ok, true);
91
+ });
92
+
93
+ test('sameDraft ignores saved_at, so an unchanged autosave is recognised', () => {
94
+ const a = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([{ key: 'L0003', after: 'Build it here' }]) }).draft;
95
+ const b = pages.resolveDraft({ readingPage: READING, savedAt: '2026-09-28T11:00:00.000Z', body: body([{ key: 'L0003', after: 'Build it here' }]) }).draft;
96
+ const c = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([{ key: 'L0003', after: 'Build it there' }]) }).draft;
97
+ assert.equal(pages.sameDraft(a, b), true);
98
+ assert.equal(pages.sameDraft(a, c), false);
99
+ // Round-trips through the block: what is stored is what is compared.
100
+ const parsed = pages.parseDraftBlock(pages.composeDraftBlock(a));
101
+ assert.equal(parsed.ok, true);
102
+ assert.equal(pages.sameDraft(parsed.draft, b), true);
103
+ });
104
+
105
+ test('replaceBlock replaces the one draft in place, appends when there is none, refuses two', () => {
106
+ const intro = 'Page tweak round 1.';
107
+ const d1 = pages.composeDraftBlock({ page_id: 'landing:index', reading_hash: 'h', saved_at: 'a', lines: [] });
108
+ const d2 = pages.composeDraftBlock({ page_id: 'landing:index', reading_hash: 'h', saved_at: 'b', lines: [] });
109
+ const first = pages.replaceBlock(intro, pages.FENCES.draft, d1);
110
+ assert.equal(first.description, `${intro}\n\n${d1}`);
111
+ const second = pages.replaceBlock(first.description, pages.FENCES.draft, d2);
112
+ assert.equal(second.description, `${intro}\n\n${d2}`);
113
+ // The batch fence is a prefix of the draft fence: replacing the batch must
114
+ // not touch the draft.
115
+ const batch = pages.composeBatchBlock({ page_id: 'landing:index', reading_hash: 'h', submitted_at: 's', lines: [] });
116
+ const both = pages.replaceBlock(second.description, pages.FENCES.batch, batch).description;
117
+ assert.ok(both.includes(d2) && both.includes(batch));
118
+ assert.equal(pages.parseDraftBlock(both).ok, true);
119
+ assert.equal(pages.parseBatchBlock(both).ok, true);
120
+ assert.equal(pages.replaceBlock(`${d1}\n\n${d2}`, pages.FENCES.draft, d1).code, 'multiple_blocks');
121
+ });
122
+
123
+ const draftOf = (lines) => pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body(lines) }).draft;
124
+
125
+ test('the submit freezes placed and shared lines with their registry rows, and splits the unplaced out', () => {
126
+ const draft = draftOf([
127
+ { key: 'L0001', after: 'Jump to the page' },
128
+ { key: 'L0002', after: 'Control K' },
129
+ { key: 'L0003', after: 'Build it here' },
130
+ { key: 'L0005', after: 'Cloud Bongos: build it with us' },
131
+ ]);
132
+ const f = pages.freezeBatch({ readingPage: READING, draft, submittedAt: 'S' });
133
+ assert.equal(f.ok, true, JSON.stringify(f));
134
+ assert.deepEqual(f.batch.lines.map((l) => [l.key, l.placement, l.file, l.line, l.string_id, l.after]), [
135
+ ['L0001', 'shared', 'shell.js', 7, 'aaa111', 'Jump to the page'],
136
+ ['L0003', 'placed', 'index.html', 12, 'bbb222', 'Build it here'],
137
+ ]);
138
+ // Unplaced text, and shared shell text the registry has no row for, go to an engineer.
139
+ assert.deepEqual(f.unplaced.map((l) => [l.key, l.placement, l.before, l.after]), [
140
+ ['L0002', 'shared', 'Ctrl K', 'Control K'],
141
+ ['L0005', 'unplaced', 'Cloud Bongos: Building Digitally', 'Cloud Bongos: build it with us'],
142
+ ]);
143
+ assert.equal(f.batch.reading_hash, 'h-now');
144
+ assert.equal(f.batch.submitted_at, 'S');
145
+ });
146
+
147
+ test('a line the page no longer shows is refused target_gone, by name, and nothing is frozen', () => {
148
+ const draft = draftOf([{ key: 'L0003', after: 'Build it here' }, { key: 'L0006', after: 'Carry on' }]);
149
+ const moved = { ...READING, reading_hash: 'h-next', lines: READING.lines.filter((l) => l.key !== 'L0003') };
150
+ const f = pages.freezeBatch({ readingPage: moved, draft, submittedAt: 'S' });
151
+ assert.equal(f.ok, false);
152
+ assert.equal(f.code, 'lines_refused');
153
+ assert.deepEqual(f.detail.refused, [{ key: 'L0003', section: 'hero', before: 'Build here', code: 'target_gone' }]);
154
+ });
155
+
156
+ test('a line that moved to another key is found by its words, and frozen under its new key', () => {
157
+ const draft = draftOf([{ key: 'L0006', after: 'Carry on' }]);
158
+ const shifted = { ...READING, reading_hash: 'h-next', lines: [
159
+ L('L0001', 'skip link', 'Skip to content', 'shared', { file: 'shell.js', line: 7, string_id: 'aaa111' }),
160
+ L('L0002', 'hero', 'A new line above', 'placed', { file: 'index.html', line: 5, string_id: 'eee555' }),
161
+ L('L0007', 'footer', 'Resume', 'placed', { file: 'index.html', line: 91, string_id: 'ddd444' }),
162
+ ] };
163
+ const f = pages.freezeBatch({ readingPage: shifted, draft, submittedAt: 'S' });
164
+ assert.equal(f.ok, true, JSON.stringify(f));
165
+ assert.deepEqual([f.batch.lines[0].key, f.batch.lines[0].line, f.batch.reading_hash], ['L0007', 91, 'h-next']);
166
+ });
167
+
168
+ test('the same words in two places on a moved page are ambiguous_target, never a guess', () => {
169
+ const draft = draftOf([{ key: 'L0006', after: 'Carry on' }]);
170
+ const twice = { ...READING, lines: [
171
+ L('L0001', 'a', 'Resume', 'placed', { file: 'x', line: 1, string_id: 's1' }),
172
+ L('L0002', 'b', 'Resume', 'placed', { file: 'x', line: 2, string_id: 's1' }),
173
+ ] };
174
+ const f = pages.freezeBatch({ readingPage: twice, draft, submittedAt: 'S' });
175
+ assert.deepEqual(f.detail.refused.map((x) => x.code), ['ambiguous_target']);
176
+ });
177
+
178
+ test('the placeholder arithmetic is checked per line at submit', () => {
179
+ const bad = pages.freezeBatch({ readingPage: READING, draft: draftOf([{ key: 'L0004', after: 'shipped so far' }]), submittedAt: 'S' });
180
+ assert.deepEqual(bad.detail.refused, [{ key: 'L0004', section: 'hero', before: '{…} of {…} shipped', code: 'placeholder_mismatch', expected: 2, got: 0 }]);
181
+ const moved = pages.freezeBatch({ readingPage: READING, draft: draftOf([{ key: 'L0004', after: '{…} shipped, of {…}' }]), submittedAt: 'S' });
182
+ assert.equal(moved.ok, true, 'a hole may MOVE within the sentence');
183
+ });
184
+
185
+ test('an empty draft is nothing_to_submit', () => {
186
+ assert.equal(pages.freezeBatch({ readingPage: READING, draft: draftOf([]), submittedAt: 'S' }).code, 'nothing_to_submit');
187
+ assert.equal(pages.freezeBatch({ readingPage: READING, draft: null, submittedAt: 'S' }).code, 'nothing_to_submit');
188
+ });
189
+
190
+ test('the engineer task carries the page, the line and the wording, keyed per round and line', () => {
191
+ const page = { id: 'landing:index', title: 'Home', path: '/' };
192
+ const line = { key: 'L0005', section: 'hero', before: 'Cloud Bongos: Building Digitally', after: 'Cloud Bongos: build it with us', placement: 'unplaced' };
193
+ assert.equal(pages.unplacedSourceRef('5001', 'L0005'), 'page-tweak-unplaced/5001/L0005');
194
+ assert.equal(pages.unplacedTaskTitle(page, line), 'Place and reword "Cloud Bongos: Building Digitally" (landing:index)');
195
+ const b = pages.unplacedTaskBody(page, line, '5001');
196
+ for (const want of ['landing:index', 'Home', 'L0005', 'hero', '"Cloud Bongos: Building Digitally"', '"Cloud Bongos: build it with us"', 'task 5001', 'does not wait']) {
197
+ assert.ok(b.includes(want), `the body names ${want}`);
198
+ }
199
+ });
200
+
201
+ console.log(`\ncopy_page_draft: ${passed} passed, ${failed} failed`);
202
+ if (failed) process.exit(1);