@bongos/core 1.19.1060 → 1.19.1062
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.
- package/.bongos-core.json +62 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +294 -4
- package/docs/api-reference.md +7 -3
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/docx.js +818 -0
- package/modules/copy-desk/pages.js +216 -0
- package/modules/copy-desk/routes/copy-desk.js +467 -0
- package/modules/copy-desk/tests/copy_docx.mjs +347 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +66 -2
- package/modules/copy-desk/tests/copy_page_draft.mjs +202 -0
- package/modules/copy-desk/tests/fixtures/word-docx.mjs +131 -0
- package/modules/lifecycle/lifecycle.js +8 -0
- package/modules/lifecycle/page-tweak-reads.js +11 -2
- package/modules/lifecycle/page-tweak-write.js +192 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_docx.mjs +279 -0
- package/tests/copy_desk_page_submit.mjs +289 -0
- package/tests/fitness.mjs +7 -2
- package/tests/fixtures/page-ledger.mjs +33 -2
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// modules/copy-desk/tests/copy_docx.mjs — the Word round trip, pure (task
|
|
2
|
+
// 1004320 / BV2.TW09, ADR 0341 D12).
|
|
3
|
+
//
|
|
4
|
+
// What docx.js must do before any route is involved: write a file Word can
|
|
5
|
+
// open with one tagged paragraph per line; read an upload the way Word stores
|
|
6
|
+
// an edit (runs split across <w:r> elements, proofing marks, smart quotes,
|
|
7
|
+
// tracked changes, a data-descriptor zip); map it back by tag or by position;
|
|
8
|
+
// merge it into a draft without losing a rewrite made after the download; and
|
|
9
|
+
// refuse by name every change of structure. And bound every read of an
|
|
10
|
+
// upload, which is someone else's input.
|
|
11
|
+
//
|
|
12
|
+
// The whole path (the routes, the holder check, the draft block on the task)
|
|
13
|
+
// is tests/copy_desk_page_docx.mjs (root).
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import zlib from 'node:zlib';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { unzipAll, wordZip, resave, typeInto, wordRuns, trackedInsert, trackedDelete, stripControls, lineParagraph } from './fixtures/word-docx.mjs';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const docx = require('../docx.js');
|
|
22
|
+
const pages = require('../pages.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
function test(name, fn) {
|
|
27
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
28
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
32
|
+
const READING = {
|
|
33
|
+
id: 'builders:studio',
|
|
34
|
+
title: 'Studio',
|
|
35
|
+
reading_hash: 'h-now',
|
|
36
|
+
lines: [
|
|
37
|
+
L('L0001', 'top bar', 'Studio', 'shared', { file: 'shell.js', line: 40, string_id: 'sh01' }),
|
|
38
|
+
L('L0002', 'greeting', 'Welcome back', 'placed', { file: 'studio.html', line: 12, string_id: 'st02' }),
|
|
39
|
+
L('L0003', 'greeting', '{…} pages tweaked', 'placed', { file: 'studio.js', line: 88, string_id: 'st03' }),
|
|
40
|
+
L('L0004', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
41
|
+
L('L0005', 'greeting', 'It\u2019s your turn', 'placed', { file: 'studio.js', line: 90, string_id: 'st05' }),
|
|
42
|
+
L('L0006', 'footer', 'Resume', 'placed', { file: 'studio.html', line: 99, string_id: 'st06' }),
|
|
43
|
+
// A section that recurs later on the page gets its heading again.
|
|
44
|
+
L('L0007', 'greeting', 'Take the next page', 'placed', { file: 'studio.html', line: 120, string_id: 'st07' }),
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
const download = (draft = null) => docx.composeDownload({ readingPage: READING, title: 'Studio', draft });
|
|
48
|
+
const upload = (buf) => {
|
|
49
|
+
const u = docx.readUpload(buf);
|
|
50
|
+
assert.equal(u.ok, true, JSON.stringify(u));
|
|
51
|
+
return u;
|
|
52
|
+
};
|
|
53
|
+
const roundTrip = (buf, draft = null) => {
|
|
54
|
+
const u = upload(buf);
|
|
55
|
+
const m = docx.mapUpload({ readingPage: READING, upload: u });
|
|
56
|
+
if (!m.ok) return { mapped: m };
|
|
57
|
+
return { mapped: m, merged: docx.mergeUpload({ readingPage: READING, texts: m.texts, baseline: u.baseline, draft }) };
|
|
58
|
+
};
|
|
59
|
+
const draftOf = (lines) => ({ page_id: READING.id, reading_hash: READING.reading_hash, saved_at: 't', lines });
|
|
60
|
+
|
|
61
|
+
console.log('copy-desk: the Word round trip');
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// The zip, both directions, and the bounds on an upload.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
test('the zip writer and reader agree, and the reader takes a Word-shaped zip (data descriptors)', () => {
|
|
68
|
+
const entries = [{ name: 'a.xml', data: '<a>é…</a>' }, { name: 'dir/b.xml', data: 'x'.repeat(5000) }];
|
|
69
|
+
const z = docx.readZip(docx.writeZip(entries));
|
|
70
|
+
assert.equal(z.ok, true);
|
|
71
|
+
assert.deepEqual([...z.parts].map(([k, v]) => [k, v.toString('utf8')]), entries.map((e) => [e.name, e.data]));
|
|
72
|
+
const w = docx.readZip(wordZip([['one.xml', '<one/>'], ['two.xml', 'two'.repeat(99)]]));
|
|
73
|
+
assert.equal(w.ok, true, JSON.stringify(w));
|
|
74
|
+
assert.equal(w.parts.get('two.xml').toString(), 'two'.repeat(99));
|
|
75
|
+
assert.equal(docx.crc32(Buffer.from('123456789')), 0xCBF43926, 'the standard CRC-32 check value');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the same page makes the same bytes', () => {
|
|
79
|
+
assert.equal(Buffer.compare(download(), download()), 0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('an upload that is not a zip, or a broken one, is not_a_docx', () => {
|
|
83
|
+
for (const bad of [Buffer.from('{"lines":[]}'), Buffer.from('PK\u0003\u0004 not really'), Buffer.alloc(0)]) {
|
|
84
|
+
assert.equal(docx.readUpload(bad).code, 'not_a_docx', bad.toString());
|
|
85
|
+
}
|
|
86
|
+
// A flipped byte inside the document part's deflated data fails its CRC (or
|
|
87
|
+
// its inflate). The first match is the local header's copy of the name.
|
|
88
|
+
const buf = Buffer.from(download());
|
|
89
|
+
const at = buf.indexOf('word/document.xml') + 'word/document.xml'.length + 40;
|
|
90
|
+
buf[at] ^= 0xFF;
|
|
91
|
+
assert.equal(docx.readUpload(buf).code, 'not_a_docx');
|
|
92
|
+
// A zip that is not a Word document.
|
|
93
|
+
assert.equal(docx.readUpload(docx.writeZip([{ name: 'hello.txt', data: 'hi' }])).detail.reason, 'the file has no Word document part');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('an encrypted entry is refused, never guessed at', () => {
|
|
97
|
+
const buf = Buffer.from(download());
|
|
98
|
+
// Set the encryption flag on every central directory entry.
|
|
99
|
+
for (let i = 0; i < buf.length - 4; i++) if (buf.readUInt32LE(i) === 0x02014b50) buf.writeUInt16LE(buf.readUInt16LE(i + 8) | 1, i + 8);
|
|
100
|
+
const r = docx.readUpload(buf);
|
|
101
|
+
assert.equal(r.code, 'not_a_docx');
|
|
102
|
+
assert.match(r.detail.reason, /encrypted/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a zip bomb stops at the part cap, and an oversized file is refused before it is read', () => {
|
|
106
|
+
// 9 MB of zeros deflates to a few kilobytes: the declared size is refused,
|
|
107
|
+
// and a lying declared size is stopped by the inflate cap itself.
|
|
108
|
+
const zeros = Buffer.alloc(docx.MAX_PART_BYTES + 1024 * 1024);
|
|
109
|
+
const honest = docx.writeZip([{ name: 'word/document.xml', data: zeros }]);
|
|
110
|
+
assert.ok(honest.length < 100 * 1024);
|
|
111
|
+
assert.equal(docx.readUpload(honest).code, 'upload_too_large');
|
|
112
|
+
const liar = Buffer.from(honest);
|
|
113
|
+
for (let i = 0; i < liar.length - 4; i++) if (liar.readUInt32LE(i) === 0x02014b50) liar.writeUInt32LE(10, i + 24);
|
|
114
|
+
assert.equal(docx.readZip(liar).code, 'upload_too_large');
|
|
115
|
+
assert.equal(docx.readUpload(Buffer.alloc(docx.MAX_UPLOAD_BYTES + 1, 1)).code, 'upload_too_large');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a DOCTYPE (an entity expansion) is refused, and entities are not expanded', () => {
|
|
119
|
+
const doctype = resave(download(), { document: (xml) => xml.replace('<w:document', '<!DOCTYPE d [<!ENTITY x "boom">]><w:document') });
|
|
120
|
+
assert.equal(docx.readUpload(doctype).code, 'not_a_docx');
|
|
121
|
+
assert.equal(docx.parseXml('<a>&lt; ’ &unknown;</a>').children[0], '< \u2019 &unknown;');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// The download.
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
test('the download: title, a Heading 2 per run of a section, one tagged plain-text control per line', () => {
|
|
129
|
+
const doc = unzipAll(download()).get('word/document.xml');
|
|
130
|
+
const paras = [...doc.matchAll(/<w:p>(.*?)<\/w:p>/gs)].map((m) => m[1]);
|
|
131
|
+
const style = (p) => (/<w:pStyle w:val="([^"]+)"/.exec(p) || [])[1] || null;
|
|
132
|
+
const text = (p) => [...p.matchAll(/<w:t[^>]*>([^<]*)<\/w:t>/g)].map((m) => m[1]).join('');
|
|
133
|
+
assert.deepEqual([style(paras[0]), text(paras[0])], ['Title', 'Studio']);
|
|
134
|
+
assert.equal(style(paras[1]), 'TweakNote');
|
|
135
|
+
const headings = paras.filter((p) => style(p) === 'Heading2').map(text);
|
|
136
|
+
assert.deepEqual(headings, ['top bar', 'greeting', 'footer', 'greeting']);
|
|
137
|
+
const lines = paras.filter((p) => /<w:tag /.test(p));
|
|
138
|
+
assert.deepEqual(lines.map((p) => /<w:tag w:val="([^"]+)"/.exec(p)[1]), READING.lines.map((l) => l.key));
|
|
139
|
+
for (const p of lines) assert.match(p, /<w:sdtPr><w:tag w:val="L\d{4}"\/><w:id w:val="\d+"\/><w:text\/><\/w:sdtPr>/, 'a plain-text control');
|
|
140
|
+
assert.equal(text(lines[4]), 'It\u2019s your turn');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('shared and unplaced lines carry their own style and a margin comment', () => {
|
|
144
|
+
const parts = unzipAll(download());
|
|
145
|
+
const doc = parts.get('word/document.xml');
|
|
146
|
+
assert.match(lineParagraph(doc, 'L0001'), /<w:pStyle w:val="TweakShared"\/>.*<w:commentReference w:id="0"\/>/s);
|
|
147
|
+
assert.match(lineParagraph(doc, 'L0004'), /<w:pStyle w:val="TweakUnplaced"\/>.*<w:commentReference w:id="1"\/>/s);
|
|
148
|
+
assert.doesNotMatch(lineParagraph(doc, 'L0002'), /pStyle|comment/);
|
|
149
|
+
const comments = [...parts.get('word/comments.xml').matchAll(/<w:comment w:id="(\d)".*?<w:t[^>]*>([^<]*)<\/w:t>/gs)].map((m) => [m[1], m[2]]);
|
|
150
|
+
assert.deepEqual(comments, [['0', 'changes this on every page'], ['1', 'goes to an engineer']]);
|
|
151
|
+
assert.match(parts.get('word/styles.xml'), /w:styleId="TweakShared"/);
|
|
152
|
+
assert.match(parts.get('word/styles.xml'), /w:styleId="Heading2"><w:name w:val="heading 2"\/>/, 'the built-in Heading 2, so Word lists it as one');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('page_id and reading_hash are custom document properties', () => {
|
|
156
|
+
const u = upload(download());
|
|
157
|
+
assert.equal(u.page_id, 'builders:studio');
|
|
158
|
+
assert.equal(u.reading_hash, 'h-now');
|
|
159
|
+
assert.match(unzipAll(download()).get('docProps/custom.xml'), /name="page_id"><vt:lpwstr>builders:studio<\/vt:lpwstr>/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('the download shows the draft over the reading, and ignores a draft saved on another reading', () => {
|
|
163
|
+
const draft = draftOf([{ key: 'L0002', section: 'greeting', before: 'Welcome back', after: 'Welcome home', placement: 'placed' }]);
|
|
164
|
+
assert.equal(upload(download(draft)).baseline.get('L0002'), 'Welcome home');
|
|
165
|
+
assert.match(unzipAll(download(draft)).get('word/document.xml'), />Welcome home</);
|
|
166
|
+
const stale = { ...draft, reading_hash: 'h-old' };
|
|
167
|
+
assert.doesNotMatch(unzipAll(download(stale)).get('word/document.xml'), />Welcome home</);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('an untouched round trip changes nothing on every line', () => {
|
|
171
|
+
const { mapped, merged } = roundTrip(download());
|
|
172
|
+
assert.equal(mapped.mode, 'tag');
|
|
173
|
+
assert.deepEqual(merged.from_file, []);
|
|
174
|
+
const resolved = pages.resolveDraft({ readingPage: READING, body: { reading_hash: 'h-now', lines: merged.lines }, savedAt: 't' });
|
|
175
|
+
assert.deepEqual(resolved.draft.lines, []);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Reading an edit the way Word stores it.
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
test('Word splits one edited line across many runs: the upload reads the whole sentence', () => {
|
|
183
|
+
const edited = resave(download(), { document: (xml) => typeInto(xml, 'L0002', wordRuns(['Wel', 'come ', 'ho', 'me, ', 'friend'])) });
|
|
184
|
+
const { merged } = roundTrip(edited);
|
|
185
|
+
assert.deepEqual(merged.from_file, ['L0002']);
|
|
186
|
+
assert.equal(merged.lines.find((l) => l.key === 'L0002').after, 'Welcome home, friend');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('smart quotes: folded back on a line the page writes with straight quotes, kept on one written curly', () => {
|
|
190
|
+
const edited = resave(download(), {
|
|
191
|
+
document: (xml) => typeInto(typeInto(xml, 'L0002', wordRuns(['Welcome back, it\u2019s \u201Cyour\u201D desk'])),
|
|
192
|
+
'L0005', wordRuns(['It\u2019s ', 'your go'])),
|
|
193
|
+
});
|
|
194
|
+
const { merged } = roundTrip(edited);
|
|
195
|
+
const after = (k) => merged.lines.find((l) => l.key === k).after;
|
|
196
|
+
assert.equal(after('L0002'), 'Welcome back, it\'s "your" desk');
|
|
197
|
+
assert.equal(after('L0005'), 'It\u2019s your go');
|
|
198
|
+
// An autocorrect alone (a quote retyped, nothing else) is not a change.
|
|
199
|
+
const retyped = resave(download(), { document: (xml) => typeInto(xml, 'L0005', wordRuns(['It\'s your turn'])) });
|
|
200
|
+
assert.deepEqual(roundTrip(retyped).merged.from_file, []);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('tracked changes read as accepted: an insertion is text, a deletion is not', () => {
|
|
204
|
+
const edited = resave(download(), {
|
|
205
|
+
document: (xml) => typeInto(xml, 'L0006', `${wordRuns(['Resume '])}${trackedDelete('now')}${trackedInsert('where you left off')}`),
|
|
206
|
+
});
|
|
207
|
+
assert.equal(roundTrip(edited).merged.lines.find((l) => l.key === 'L0006').after, 'Resume where you left off');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a tab, a line break and a different namespace prefix still read as the words', () => {
|
|
211
|
+
const edited = resave(download(), {
|
|
212
|
+
document: (xml) => typeInto(xml, 'L0002', '<w:r><w:t>Welcome</w:t><w:tab/><w:t>in</w:t><w:br/><w:t>here</w:t></w:r>')
|
|
213
|
+
.replace(/xmlns:w=/, 'xmlns:ww=').replace(/<(\/?)w:/g, '<$1ww:').replace(/ w:/g, ' ww:'),
|
|
214
|
+
});
|
|
215
|
+
assert.equal(roundTrip(edited).merged.lines.find((l) => l.key === 'L0002').after, 'Welcome in here');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// The merge.
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
test('the merge keeps a rewrite made in the studio after the download', () => {
|
|
223
|
+
const file = download(); // shows the page's own text everywhere
|
|
224
|
+
const edited = resave(file, { document: (xml) => typeInto(xml, 'L0006', wordRuns(['Pick up ', 'where you were'])) });
|
|
225
|
+
// Since the download, the artist rewrote L0002 in the studio.
|
|
226
|
+
const draft = draftOf([{ key: 'L0002', section: 'greeting', before: 'Welcome back', after: 'Hello again', placement: 'placed' }]);
|
|
227
|
+
const { merged } = roundTrip(edited, draft);
|
|
228
|
+
const after = (k) => merged.lines.find((l) => l.key === k).after;
|
|
229
|
+
assert.equal(after('L0002'), 'Hello again', 'the file did not touch L0002, so the draft keeps it');
|
|
230
|
+
assert.equal(after('L0006'), 'Pick up where you were');
|
|
231
|
+
assert.deepEqual(merged.from_file, ['L0006']);
|
|
232
|
+
assert.deepEqual(merged.kept_from_draft, ['L0002']);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('a line changed in Word wins over the draft; without the file\'s baseline the file is the whole state', () => {
|
|
236
|
+
const draft = draftOf([{ key: 'L0002', section: 'greeting', before: 'Welcome back', after: 'Hello again', placement: 'placed' }]);
|
|
237
|
+
const edited = resave(download(), { document: (xml) => typeInto(xml, 'L0002', wordRuns(['Hi there'])) });
|
|
238
|
+
assert.equal(roundTrip(edited, draft).merged.lines.find((l) => l.key === 'L0002').after, 'Hi there');
|
|
239
|
+
// An editor that drops the custom XML part: every line is what the file says,
|
|
240
|
+
// so the studio rewrite of L0002 is replaced by the page text the file shows.
|
|
241
|
+
const noBaseline = resave(download(), { drop: ['customXml/item1.xml'] });
|
|
242
|
+
const u = upload(noBaseline);
|
|
243
|
+
assert.equal(u.baseline, null);
|
|
244
|
+
const { merged } = roundTrip(noBaseline, draft);
|
|
245
|
+
assert.equal(merged.lines.find((l) => l.key === 'L0002').after, 'Welcome back');
|
|
246
|
+
assert.deepEqual(merged.from_file, ['L0002']);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Structure changes are refused, by name.
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
const refusal = (buf) => {
|
|
254
|
+
const { mapped } = roundTrip(buf);
|
|
255
|
+
assert.equal(mapped.ok, false);
|
|
256
|
+
assert.equal(mapped.code, 'structure_changed');
|
|
257
|
+
return mapped.detail.moved;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
test('an added paragraph is named by its section and first words', () => {
|
|
261
|
+
const buf = resave(download(), {
|
|
262
|
+
document: (xml) => { const p = lineParagraph(xml, 'L0002'); return xml.replace(p, () => `${p}<w:p><w:r><w:t>A brand new line nobody asked for today</w:t></w:r></w:p>`); },
|
|
263
|
+
});
|
|
264
|
+
assert.deepEqual(refusal(buf), [{ change: 'added', key: null, section: 'greeting', first_words: 'A brand new line nobody asked…' }]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('a dropped line is named by its key, section and first words', () => {
|
|
268
|
+
const buf = resave(download(), { document: (xml) => xml.replace(lineParagraph(xml, 'L0004'), '') });
|
|
269
|
+
assert.deepEqual(refusal(buf), [{ change: 'dropped', key: 'L0004', section: 'greeting', first_words: 'Your desk is quiet today' }]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('a moved line is named, and only the line that moved', () => {
|
|
273
|
+
const buf = resave(download(), {
|
|
274
|
+
document: (xml) => { const p = lineParagraph(xml, 'L0002'); return xml.replace(p, '').replace(lineParagraph(xml, 'L0004'), (q) => `${q}${p}`); },
|
|
275
|
+
});
|
|
276
|
+
assert.deepEqual(refusal(buf), [{ change: 'reordered', key: 'L0002', section: 'greeting', first_words: 'Welcome back' }]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('a copied control is an added line; two lines joined into one paragraph drop the second', () => {
|
|
280
|
+
const copied = resave(download(), { document: (xml) => { const p = lineParagraph(xml, 'L0006'); return xml.replace(p, () => `${p}${p}`); } });
|
|
281
|
+
assert.deepEqual(refusal(copied), [{ change: 'added', key: 'L0006', section: 'footer', first_words: 'Resume' }]);
|
|
282
|
+
const joined = resave(download(), {
|
|
283
|
+
document: (xml) => {
|
|
284
|
+
const a = lineParagraph(xml, 'L0002');
|
|
285
|
+
const b = lineParagraph(xml, 'L0003');
|
|
286
|
+
const sdtB = /<w:sdt>.*<\/w:sdt>/s.exec(b)[0];
|
|
287
|
+
return xml.replace(b, '').replace(a, () => a.replace('</w:p>', `${sdtB}</w:p>`));
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
assert.deepEqual(refusal(joined), [{ change: 'dropped', key: 'L0003', section: 'greeting', first_words: '{…} pages tweaked', joined_to: 'L0002' }]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('a changed section heading is refused', () => {
|
|
294
|
+
const buf = resave(download(), { document: (xml) => xml.replace('<w:t xml:space="preserve">footer</w:t>', '<w:t>Footer bits</w:t>') });
|
|
295
|
+
assert.deepEqual(refusal(buf), [{ change: 'heading_changed', expected: 'footer', found: 'Footer bits' }]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('empty paragraphs (spacing) and the title and note are not lines', () => {
|
|
299
|
+
const buf = resave(download(), {
|
|
300
|
+
document: (xml) => { const p = lineParagraph(xml, 'L0002'); return xml.replace(p, () => `<w:p/>${p}<w:p><w:r><w:t> </w:t></w:r></w:p>`).replace('>Studio</w:t></w:r></w:p>', '>My studio notes</w:t></w:r></w:p>'); },
|
|
301
|
+
});
|
|
302
|
+
const { mapped } = roundTrip(buf);
|
|
303
|
+
assert.equal(mapped.ok, true, JSON.stringify(mapped.detail));
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// When every tag is gone: by position.
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
test('with the controls stripped, lines map by position under the same headings', () => {
|
|
311
|
+
const buf = resave(download(), { document: (xml) => stripControls(typeInto(xml, 'L0006', wordRuns(['Carry ', 'on']))) });
|
|
312
|
+
assert.doesNotMatch(unzipAll(buf).get('word/document.xml'), /<w:tag /);
|
|
313
|
+
const { mapped, merged } = roundTrip(buf);
|
|
314
|
+
assert.equal(mapped.mode, 'position');
|
|
315
|
+
assert.deepEqual(merged.from_file, ['L0006']);
|
|
316
|
+
assert.equal(merged.lines.find((l) => l.key === 'L0006').after, 'Carry on');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('by position, a section with a line more or a line fewer is refused and named', () => {
|
|
320
|
+
const extra = resave(download(), {
|
|
321
|
+
document: (xml) => { const p = lineParagraph(xml, 'L0006'); return stripControls(xml.replace(p, () => `${p}<w:p><w:r><w:t>Another line</w:t></w:r></w:p>`)); },
|
|
322
|
+
});
|
|
323
|
+
assert.deepEqual(refusal(extra), [{ change: 'added', key: null, section: 'footer', first_words: 'Another line' }]);
|
|
324
|
+
const fewer = resave(download(), { document: (xml) => stripControls(xml.replace(lineParagraph(xml, 'L0006'), '')) });
|
|
325
|
+
assert.deepEqual(refusal(fewer), [{ change: 'dropped', key: 'L0006', section: 'footer', first_words: 'Resume' }]);
|
|
326
|
+
const heading = resave(download(), { document: (xml) => stripControls(xml.replace('<w:t xml:space="preserve">top bar</w:t>', '<w:t>Top</w:t>')) });
|
|
327
|
+
assert.equal(refusal(heading)[0].change, 'heading_changed');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Every page the inventory reads round-trips untouched.
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
test('every committed page reading round-trips with no change', () => {
|
|
335
|
+
const readings = require('../../../docs/page-readings.json');
|
|
336
|
+
for (const p of readings.pages) {
|
|
337
|
+
const u = docx.readUpload(docx.composeDownload({ readingPage: p, title: p.title, draft: null }));
|
|
338
|
+
assert.equal(u.ok, true, p.id);
|
|
339
|
+
const m = docx.mapUpload({ readingPage: p, upload: u });
|
|
340
|
+
assert.equal(m.ok, true, `${p.id}: ${JSON.stringify(m.detail)}`);
|
|
341
|
+
assert.deepEqual(docx.mergeUpload({ readingPage: p, texts: m.texts, baseline: u.baseline, draft: null }).from_file, [], p.id);
|
|
342
|
+
}
|
|
343
|
+
assert.ok(zlib, 'node:zlib is the only dependency');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
console.log(`\ncopy_docx: ${passed} passed, ${failed} failed`);
|
|
347
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -21,7 +21,9 @@
|
|
|
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
|
|
24
|
+
// * the write surface is EXACTLY the verbs the module defines (eight since
|
|
25
|
+
// TW09: the page draft, its Word upload and the submit write the round
|
|
26
|
+
// TASK, never a row),
|
|
25
27
|
// * no column on copy_desk_flags could hold replacement text,
|
|
26
28
|
// * nothing in this module writes a file, and
|
|
27
29
|
// * the ONLY thing that writes a surface file is the applier, it lives outside
|
|
@@ -63,7 +65,8 @@ const stripComments = (src) => src
|
|
|
63
65
|
// reads: pages.js (the page-tweak block format), page-status.js (the derived
|
|
64
66
|
// status, drift and tally) and page-data.js (the read side of the page
|
|
65
67
|
// inventory and readings). They are held to the same two rules as the rest.
|
|
66
|
-
|
|
68
|
+
// TW09 (task 1004320) added docx.js, the Word round trip over Buffers.
|
|
69
|
+
const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'routes/copy-desk.js'];
|
|
67
70
|
|
|
68
71
|
console.log('copy-desk: NO LIVE CMS');
|
|
69
72
|
|
|
@@ -80,7 +83,17 @@ await test('the write surface is exactly the verbs the module defines', () => {
|
|
|
80
83
|
// flag row whose target is a page id. Neither stores wording.
|
|
81
84
|
'POST /copy-desk/pages/:pageId/asks',
|
|
82
85
|
'POST /copy-desk/pages/:pageId/claim',
|
|
86
|
+
// TW09 (task 1004320, ADR 0341 D11, D12): the Word upload. It lands in the
|
|
87
|
+
// round's DRAFT through the autosave's own port call and resolver, never a
|
|
88
|
+
// row here, a file, or the site.
|
|
89
|
+
'POST /copy-desk/pages/:pageId/draft.docx',
|
|
90
|
+
// TW08 (task 1004319, ADR 0341 D4, D11): the draft autosave and the
|
|
91
|
+
// submit. Both write the page round's TASK DESCRIPTION through the
|
|
92
|
+
// lifecycle port, the place ADR 0233 lets wording rest; neither writes a
|
|
93
|
+
// row here, a file, or anything a renderer reads.
|
|
94
|
+
'POST /copy-desk/pages/:pageId/submit',
|
|
83
95
|
'POST /copy-desk/proposals',
|
|
96
|
+
'PUT /copy-desk/pages/:pageId/draft',
|
|
84
97
|
], 'a new write route on this module must be a deliberate, reviewed change to this list');
|
|
85
98
|
});
|
|
86
99
|
|
|
@@ -185,6 +198,57 @@ await test('the page claim creates a TASK through the port and writes no row her
|
|
|
185
198
|
assert.ok(/roundTaskBody\(page, n\)/.test(claim), 'the round body comes from pages.roundTaskBody over the inventory page');
|
|
186
199
|
});
|
|
187
200
|
|
|
201
|
+
await test('the draft and the submit write only the round task, through the port, with targets from the reading', () => {
|
|
202
|
+
const src = stripComments(read('routes/copy-desk.js'));
|
|
203
|
+
const handler = (verb, p) => {
|
|
204
|
+
const start = src.indexOf(`router.${verb}('${p}'`);
|
|
205
|
+
assert.ok(start >= 0, `${verb.toUpperCase()} ${p} is mounted`);
|
|
206
|
+
const next = src.indexOf('router.', start + 1);
|
|
207
|
+
return src.slice(start, next === -1 ? undefined : next);
|
|
208
|
+
};
|
|
209
|
+
const draft = handler('put', '/copy-desk/pages/:pageId/draft');
|
|
210
|
+
const submit = handler('post', '/copy-desk/pages/:pageId/submit');
|
|
211
|
+
assert.ok(/lifecycle\.updateHeldTaskDescription\(/.test(draft), 'the draft lands on the task via the lifecycle port');
|
|
212
|
+
assert.ok(/lifecycle\.submitPageTweak\(/.test(submit), 'the submit lands on the task via the lifecycle port');
|
|
213
|
+
for (const [name, h] of [['draft', draft], ['submit', submit]]) {
|
|
214
|
+
assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE /i.test(h), false, `the ${name} route must write no row in this module`);
|
|
215
|
+
// Every target fact is the reading's. A handler reading `before`, a
|
|
216
|
+
// section, a placement or a file from the body would let a caller describe
|
|
217
|
+
// an edit to a sentence the page does not show.
|
|
218
|
+
assert.equal(/req\.body\.(before|section|placement|file|line|string_id|text)\b/.test(h), false,
|
|
219
|
+
`the ${name} route must not take a target from the request body`);
|
|
220
|
+
assert.ok(/readingPageOrFail\(res, pageId\)/.test(h), `the ${name} route resolves against the page reading`);
|
|
221
|
+
}
|
|
222
|
+
assert.ok(/pages\.resolveDraft\(\{ readingPage,/.test(draft), 'the draft is resolved from the reading by pages.resolveDraft');
|
|
223
|
+
assert.ok(/pages\.freezeBatch\(\{ readingPage,/.test(submit), 'the batch is frozen from the reading by pages.freezeBatch');
|
|
224
|
+
// The body schemas carry no target field.
|
|
225
|
+
const schemas = src.slice(src.indexOf('const DRAFT_BODY_SCHEMA'), src.indexOf('const PAGE_REFUSAL_STATUS'));
|
|
226
|
+
assert.equal(/\b(before|section|placement|file|string_id)\s*:/.test(schemas), false, 'the draft and submit schemas name no target field');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await test('the Word round trip is pure over Buffers, and its upload writes only the draft, through the port', () => {
|
|
230
|
+
// docx.js handles an artist's wording like pages.js does, so it is held to
|
|
231
|
+
// the same shape: its one require is node:zlib (a zip is deflate), and it
|
|
232
|
+
// reaches no fs, pool or network.
|
|
233
|
+
const lib = stripComments(read('docx.js'));
|
|
234
|
+
assert.deepEqual([...lib.matchAll(/require\(\s*'([^']+)'\s*\)/g)].map((m) => m[1]), ['node:zlib'],
|
|
235
|
+
'docx.js may require node:zlib and nothing else');
|
|
236
|
+
for (const forbidden of ['fs.', 'pool', 'fetch(']) {
|
|
237
|
+
assert.equal(lib.includes(forbidden), false, `docx.js must stay pure — it must not reach for ${forbidden}`);
|
|
238
|
+
}
|
|
239
|
+
const src = stripComments(read('routes/copy-desk.js'));
|
|
240
|
+
const start = src.indexOf("router.post('/copy-desk/pages/:pageId/draft.docx'");
|
|
241
|
+
assert.ok(start >= 0, 'the upload is mounted');
|
|
242
|
+
const next = src.indexOf('router.', start + 1);
|
|
243
|
+
const h = src.slice(start, next === -1 ? undefined : next);
|
|
244
|
+
assert.ok(/lifecycle\.updateHeldTaskDescription\(/.test(h), 'the upload lands on the task via the autosave\'s port call (holder only)');
|
|
245
|
+
assert.ok(/pages\.resolveDraft\(\{ readingPage,/.test(h), 'the upload is resolved from the reading by the autosave\'s resolver');
|
|
246
|
+
assert.ok(/pages\.saveDraftInto\(/.test(h), 'the upload writes the draft block through the autosave\'s writer');
|
|
247
|
+
assert.ok(/readingPageOrFail\(res, pageId\)/.test(h), 'the upload resolves against the page reading');
|
|
248
|
+
assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE |composeBatchBlock|submitPageTweak/i.test(h), false,
|
|
249
|
+
'the upload writes the draft and nothing else: no row, and never the frozen batch');
|
|
250
|
+
});
|
|
251
|
+
|
|
188
252
|
await test('the module manifest declares no seam another module could write copy through', () => {
|
|
189
253
|
const manifest = JSON.parse(read('module.json'));
|
|
190
254
|
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);
|