@bongos/core 1.19.1057 → 1.19.1059
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 +88 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/api/openapi.json +235 -2
- package/docs/api-reference.md +7 -2
- package/docs/architecture.md +2 -1
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +1178 -0
- package/docs/page-readings.json +4108 -0
- package/modules/copy-desk/flags.js +84 -9
- package/modules/copy-desk/migrations/copy_desk_002_page_asks.sql +62 -0
- package/modules/copy-desk/page-data.js +79 -0
- package/modules/copy-desk/page-status.js +357 -0
- package/modules/copy-desk/pages.js +238 -0
- package/modules/copy-desk/routes/copy-desk.js +357 -4
- package/modules/copy-desk/tests/copy_flags.mjs +52 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +63 -17
- package/modules/copy-desk/tests/copy_page_status.mjs +240 -0
- package/modules/copy-desk/tests/fixtures/page-tweak.cjs +154 -0
- package/modules/lifecycle/lifecycle.js +14 -0
- package/modules/lifecycle/page-tweak-claim.js +184 -0
- package/modules/lifecycle/page-tweak-reads.js +87 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/publish-manifest.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_flags_db.mjs +44 -0
- package/tests/copy_desk_page_claim.mjs +334 -0
- package/tests/copy_desk_page_reads.mjs +194 -0
|
@@ -188,15 +188,65 @@ await test('the JS enums match the migration CHECK constraints exactly', async (
|
|
|
188
188
|
const url = await import('node:url');
|
|
189
189
|
const path = await import('node:path');
|
|
190
190
|
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
191
|
-
|
|
191
|
+
// Every migration, oldest first: a later one may widen a CHECK (TW06's
|
|
192
|
+
// copy_desk_002 widens scope), and the LAST definition is the one in force.
|
|
193
|
+
const dir = path.join(here, '..', 'migrations');
|
|
194
|
+
const sql = fs.readdirSync(dir).filter((x) => x.endsWith('.sql')).sort()
|
|
195
|
+
.map((x) => fs.readFileSync(path.join(dir, x), 'utf8')).join('\n');
|
|
192
196
|
for (const [name, list] of [['kind', flags.KINDS], ['scope', flags.SCOPES], ['status', flags.STATUSES]]) {
|
|
193
|
-
const
|
|
197
|
+
const all = [...sql.matchAll(new RegExp(`CHECK \\(${name} IN \\(([^)]*)\\)\\)`, 'g'))];
|
|
198
|
+
const m = all[all.length - 1];
|
|
194
199
|
assert.ok(m, `migration has no CHECK for ${name}`);
|
|
195
200
|
const inSql = m[1].split(',').map((s) => s.trim().replace(/'/g, ''));
|
|
196
201
|
assert.deepEqual(inSql.sort(), [...list].sort(), `${name} enum drifted between flags.js and the migration`);
|
|
197
202
|
}
|
|
198
203
|
});
|
|
199
204
|
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Page asks (task 1004317 / TW06, ADR 0341 D6).
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
console.log('copy-desk: page asks');
|
|
210
|
+
|
|
211
|
+
await test('the flag route refuses scope page and points at the ask route', () => {
|
|
212
|
+
const r = ok({ scope: 'page', surface: 'landing', reason: GOOD_REASON });
|
|
213
|
+
assert.equal(r.ok, false);
|
|
214
|
+
assert.equal(r.code, 'bad_scope');
|
|
215
|
+
assert.deepEqual([...r.detail.allowed], ['string', 'surface']);
|
|
216
|
+
assert.ok(/asks/.test(r.detail.why));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await test('a page ask carries the flag reason floor', () => {
|
|
220
|
+
const thin = flags.normalizePageAsk({ reason: ' do it ' }, 'landing:index');
|
|
221
|
+
assert.equal(thin.ok, false);
|
|
222
|
+
assert.equal(thin.code, 'reason_too_thin');
|
|
223
|
+
const long = flags.normalizePageAsk({ reason: 'x'.repeat(flags.MAX_REASON_CHARS + 1) }, 'landing:index');
|
|
224
|
+
assert.equal(long.code, 'reason_too_long');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('a page ask is scope page, points at the page twice, and holds no text', () => {
|
|
228
|
+
const r = flags.normalizePageAsk({ reason: GOOD_REASON }, 'builders:studio');
|
|
229
|
+
assert.equal(r.ok, true);
|
|
230
|
+
assert.equal(r.value.scope, 'page');
|
|
231
|
+
assert.equal(r.value.page_id, 'builders:studio');
|
|
232
|
+
// surface = page id: what makes 001's one-open-per-target index mean one
|
|
233
|
+
// open ask per (builder, page), and what copy_desk_flags_page_surface_chk pins.
|
|
234
|
+
assert.equal(r.value.surface, 'builders:studio');
|
|
235
|
+
assert.equal(r.value.string_id, null);
|
|
236
|
+
assert.equal(r.value.text_at_flag, null);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await test('the migration pins the page target both ways and ties surface to the page', async () => {
|
|
240
|
+
const fs = await import('node:fs');
|
|
241
|
+
const url = await import('node:url');
|
|
242
|
+
const path = await import('node:path');
|
|
243
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
244
|
+
const sql = fs.readFileSync(path.join(here, '..', 'migrations', 'copy_desk_002_page_asks.sql'), 'utf8');
|
|
245
|
+
assert.ok(/\(scope = 'page'\) = \(page_id IS NOT NULL\)/.test(sql), 'page_id present exactly for scope page');
|
|
246
|
+
assert.ok(/\(scope = 'string'\) = \(string_id IS NOT NULL\)/.test(sql), 'the string half of the target rule survives the widening');
|
|
247
|
+
assert.ok(/scope <> 'page' OR surface = page_id/.test(sql), 'a page ask keeps surface = page_id');
|
|
248
|
+
});
|
|
249
|
+
|
|
200
250
|
// ---------------------------------------------------------------------------
|
|
201
251
|
// Closing a flag.
|
|
202
252
|
// ---------------------------------------------------------------------------
|
|
@@ -59,9 +59,15 @@ const stripComments = (src) => src
|
|
|
59
59
|
.replace(/^\s*\/\/.*$/gm, ' ')
|
|
60
60
|
.replace(/--[^\n]*/g, ' ');
|
|
61
61
|
|
|
62
|
+
// Every file of the module's request path. TW05 (task 1004316) added the page
|
|
63
|
+
// reads: pages.js (the page-tweak block format), page-status.js (the derived
|
|
64
|
+
// status, drift and tally) and page-data.js (the read side of the page
|
|
65
|
+
// inventory and readings). They are held to the same two rules as the rest.
|
|
66
|
+
const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'routes/copy-desk.js'];
|
|
67
|
+
|
|
62
68
|
console.log('copy-desk: NO LIVE CMS');
|
|
63
69
|
|
|
64
|
-
await test('the write surface is exactly the
|
|
70
|
+
await test('the write surface is exactly the verbs the module defines', () => {
|
|
65
71
|
const src = stripComments(read('routes/copy-desk.js'));
|
|
66
72
|
const found = [...src.matchAll(/router\.(post|put|patch|delete)\(\s*'([^']+)'/g)]
|
|
67
73
|
.map((m) => `${m[1].toUpperCase()} ${m[2]}`)
|
|
@@ -69,12 +75,21 @@ await test('the write surface is exactly the three verbs the module defines', ()
|
|
|
69
75
|
assert.deepEqual(found, [
|
|
70
76
|
'POST /copy-desk/flags',
|
|
71
77
|
'POST /copy-desk/flags/:id/close',
|
|
78
|
+
// TW06 (task 1004317, ADR 0341 D5, D6, D11): the page claim opens or takes a
|
|
79
|
+
// TASK and a web claim through the lifecycle port; the page ask writes one
|
|
80
|
+
// flag row whose target is a page id. Neither stores wording.
|
|
81
|
+
'POST /copy-desk/pages/:pageId/asks',
|
|
82
|
+
'POST /copy-desk/pages/:pageId/claim',
|
|
72
83
|
'POST /copy-desk/proposals',
|
|
73
84
|
], 'a new write route on this module must be a deliberate, reviewed change to this list');
|
|
74
85
|
});
|
|
75
86
|
|
|
87
|
+
// Every migration this module ships, oldest first (the list is pinned below).
|
|
88
|
+
const MIGRATION_FILES = ['copy_desk_001_flags.sql', 'copy_desk_002_page_asks.sql'];
|
|
89
|
+
const allMigrationSql = () => MIGRATION_FILES.map((f) => stripComments(read(`migrations/${f}`))).join('\n');
|
|
90
|
+
|
|
76
91
|
await test('no column on copy_desk_flags could hold replacement text', () => {
|
|
77
|
-
const sql =
|
|
92
|
+
const sql = allMigrationSql();
|
|
78
93
|
// The names a "just store the suggested wording" column would plausibly take.
|
|
79
94
|
const forbidden = /\b(new_text|proposed_text|suggested_text|replacement|replacement_text|new_copy|proposed_copy|override_text|value_text)\b/i;
|
|
80
95
|
const hit = forbidden.exec(sql);
|
|
@@ -82,22 +97,26 @@ await test('no column on copy_desk_flags could hold replacement text', () => {
|
|
|
82
97
|
`copy_desk_flags must carry a judgement and a pointer, never replacement copy (found "${hit && hit[0]}")`);
|
|
83
98
|
});
|
|
84
99
|
|
|
85
|
-
await test('
|
|
100
|
+
await test('the module has exactly its two migrations — a proposal and a page round are tasks, not rows here', () => {
|
|
86
101
|
// The load-bearing one. R03's obvious-but-wrong implementation is a
|
|
87
102
|
// `copy_desk_proposals` table, which is the forbidden column with extra steps:
|
|
88
103
|
// once the wording lives in this module's schema, one plausible read of it
|
|
89
104
|
// turns the module into the copy source. The proposal lives on the TASK.
|
|
90
105
|
const migrations = fs.readdirSync(path.join(MODULE_DIR, 'migrations')).sort();
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
// TW06 (task 1004317) added the second, deliberately: copy_desk_002_page_asks
|
|
107
|
+
// widens the flag to a page scope and adds one POINTER column, page_id. It
|
|
108
|
+
// stores no wording; a page round, like a proposal, is a task.
|
|
109
|
+
assert.deepEqual(migrations, MIGRATION_FILES,
|
|
110
|
+
'another migration in this module needs a hard look at whether it stores copy');
|
|
93
111
|
});
|
|
94
112
|
|
|
95
113
|
await test('the only text column is the historical snapshot, and it is never the live source', () => {
|
|
96
|
-
const sql =
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
114
|
+
const sql = allMigrationSql();
|
|
115
|
+
const created = [...sql.matchAll(/^\s{2}(\w+)\s+text\b/gm)].map((m) => m[1]);
|
|
116
|
+
const added = [...sql.matchAll(/ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s+text\b/gi)].map((m) => m[1]);
|
|
117
|
+
assert.deepEqual([...created, ...added].sort(), [
|
|
118
|
+
'file_at_flag', 'kind', 'page_id', 'reason', 'resolution_note', 'scope', 'status', 'string_id', 'surface', 'text_at_flag',
|
|
119
|
+
], 'a new text column here needs a deliberate look at whether it is copy (page_id, TW06, is a page pointer)');
|
|
101
120
|
|
|
102
121
|
// And the live read path is the registry, not the row: annotateFlag fills
|
|
103
122
|
// `current` from the registry index every time.
|
|
@@ -110,7 +129,7 @@ await test('this module never writes to the surfaces it inventories', () => {
|
|
|
110
129
|
// The registry is generated by R01 and committed. If this module ever gained a
|
|
111
130
|
// write to docs/copy-registry.json — or to any surface file — the inventory
|
|
112
131
|
// would stop describing the tree and start describing itself.
|
|
113
|
-
for (const rel of
|
|
132
|
+
for (const rel of MODULE_FILES) {
|
|
114
133
|
const src = stripComments(read(rel));
|
|
115
134
|
assert.equal(/fs\.(writeFile|writeFileSync|appendFile|appendFileSync|rm|unlink|mkdir)/.test(src), false,
|
|
116
135
|
`${rel} performs a filesystem WRITE — this module reads the registry and never writes it`);
|
|
@@ -121,16 +140,24 @@ await test('the proposal domain holds no path to a renderer — it composes text
|
|
|
121
140
|
// proposals.js is the file that handles replacement wording, so it is the file
|
|
122
141
|
// most able to become a copy source by accident. It must be PURE: no fs, no
|
|
123
142
|
// DB, no network. Its output goes into a task description and nowhere else.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
143
|
+
// pages.js joins it (ADR 0341 D4): it holds a page batch's replacement
|
|
144
|
+
// wording, the same material at page scale.
|
|
145
|
+
for (const rel of ['proposals.js', 'pages.js']) {
|
|
146
|
+
const src = stripComments(read(rel));
|
|
147
|
+
for (const forbidden of ['require(', 'fs.', 'pool', 'fetch(']) {
|
|
148
|
+
assert.equal(src.includes(forbidden), false,
|
|
149
|
+
`${rel} must stay pure — it must not reach for ${forbidden}`);
|
|
150
|
+
}
|
|
128
151
|
}
|
|
129
152
|
});
|
|
130
153
|
|
|
131
154
|
await test('the proposal route creates a TASK and does not write a string anywhere', () => {
|
|
132
155
|
const src = stripComments(read('routes/copy-desk.js'));
|
|
133
|
-
|
|
156
|
+
// Bounded to THIS handler: the slice ends where the next route begins, so a
|
|
157
|
+
// later route (the page ask does call insertFlag) is not read as this one.
|
|
158
|
+
const start = src.indexOf("router.post('/copy-desk/proposals'");
|
|
159
|
+
const next = src.indexOf('router.', start + 1);
|
|
160
|
+
const route = src.slice(start, next === -1 ? undefined : next);
|
|
134
161
|
assert.ok(/lifecycle\.createTask\(/.test(route),
|
|
135
162
|
'the proposal path must land in the ledger via lifecycle.createTask');
|
|
136
163
|
// No INSERT/UPDATE of this module's own tables on the proposal path — the
|
|
@@ -139,6 +166,25 @@ await test('the proposal route creates a TASK and does not write a string anywhe
|
|
|
139
166
|
'the proposal path must not write a row in this module — the task IS the record');
|
|
140
167
|
});
|
|
141
168
|
|
|
169
|
+
await test('the page claim creates a TASK through the port and writes no row here; the ask writes a pointer', () => {
|
|
170
|
+
const src = stripComments(read('routes/copy-desk.js'));
|
|
171
|
+
const handler = (path) => {
|
|
172
|
+
const start = src.indexOf(`router.post('${path}'`);
|
|
173
|
+
assert.ok(start >= 0, `${path} is mounted`);
|
|
174
|
+
const next = src.indexOf('router.', start + 1);
|
|
175
|
+
return src.slice(start, next === -1 ? undefined : next);
|
|
176
|
+
};
|
|
177
|
+
const claim = handler('/copy-desk/pages/:pageId/claim');
|
|
178
|
+
assert.ok(/lifecycle\.webClaimPageTweak\(/.test(claim), 'the page claim must land in the ledger via the lifecycle port');
|
|
179
|
+
assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE |description:\s*req\.body/i.test(claim), false,
|
|
180
|
+
'the page claim must write no row in this module and take no wording from the request');
|
|
181
|
+
const ask = handler('/copy-desk/pages/:pageId/asks');
|
|
182
|
+
assert.ok(/normalizePageAsk\(/.test(ask) && /insertFlag\(/.test(ask), 'the ask files through the validated flag write');
|
|
183
|
+
// The round's description is composed by pages.js from the INVENTORY page,
|
|
184
|
+
// never from anything a caller sent.
|
|
185
|
+
assert.ok(/roundTaskBody\(page, n\)/.test(claim), 'the round body comes from pages.roundTaskBody over the inventory page');
|
|
186
|
+
});
|
|
187
|
+
|
|
142
188
|
await test('the module manifest declares no seam another module could write copy through', () => {
|
|
143
189
|
const manifest = JSON.parse(read('module.json'));
|
|
144
190
|
assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');
|
|
@@ -160,7 +206,7 @@ await test('the applier is the only writer, it lives outside the request path, a
|
|
|
160
206
|
// exact line to run, and that instruction is the whole handoff. What must
|
|
161
207
|
// not exist is a require/import edge, which would put a file write one
|
|
162
208
|
// function call away from a request handler.
|
|
163
|
-
for (const rel of
|
|
209
|
+
for (const rel of MODULE_FILES) {
|
|
164
210
|
const src = stripComments(read(rel));
|
|
165
211
|
assert.equal(/(?:require|import)\s*\(?\s*['"][^'"]*copy-apply/.test(src), false,
|
|
166
212
|
`${rel} must not IMPORT the applier — it runs under a claim, not under a request`);
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// modules/copy-desk/tests/copy_page_status.mjs — Tweak Mode's three reads, pure
|
|
2
|
+
// (task 1004316 / BV2.TW05, ADR 0341 D7 and D8).
|
|
3
|
+
//
|
|
4
|
+
// The done-when: all three reads are correct for a fixture with an UNTWEAKED
|
|
5
|
+
// page, a page TWEAKED TWICE, and a tweaked page whose TEXT CHANGED afterwards.
|
|
6
|
+
// The fixture (fixtures/page-tweak.cjs) holds exactly those, plus an open round
|
|
7
|
+
// and an abandoned one, which are the two ways a count could be wrong.
|
|
8
|
+
//
|
|
9
|
+
// The whole path (the lifecycle port's SQL rows through the real routes) is
|
|
10
|
+
// tests/copy_desk_page_reads.mjs.
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const pages = require('../pages.js');
|
|
17
|
+
const status = require('../page-status.js');
|
|
18
|
+
const F = require('./fixtures/page-tweak.cjs');
|
|
19
|
+
|
|
20
|
+
let passed = 0;
|
|
21
|
+
let failed = 0;
|
|
22
|
+
async function test(name, fn) {
|
|
23
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
24
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const list = () => status.composePages({ inventory: F.INVENTORY, readings: F.READINGS, tasks: F.TASKS });
|
|
28
|
+
const byId = (out, id) => out.pages.find((p) => p.id === id);
|
|
29
|
+
const detail = (id, over = {}) => status.composePage({
|
|
30
|
+
page: F.INVENTORY.pages.find((p) => p.id === id),
|
|
31
|
+
tasks: F.TASKS,
|
|
32
|
+
readingPage: F.READINGS.pages.find((p) => p.id === id),
|
|
33
|
+
readingsOk: true,
|
|
34
|
+
ships: [],
|
|
35
|
+
...over,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
console.log('copy-desk: page tweak status, Artist Review Status and the tally');
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// The block format: one writer, one reader, each tested against the other.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
await test('each block round-trips through its writer and its reader', () => {
|
|
45
|
+
const batch = { page_id: 'landing:index', reading_hash: 'h', submitted_at: 't', lines: [{ key: 'L0001', section: 's', before: 'a', after: 'b', placement: 'placed', file: 'f', line: 3, string_id: 'x' }], unplaced_tasks: [9] };
|
|
46
|
+
const b = pages.parseBatchBlock(pages.composeBatchBlock(batch));
|
|
47
|
+
assert.equal(b.ok, true);
|
|
48
|
+
assert.deepEqual(b.batch.lines, batch.lines);
|
|
49
|
+
assert.deepEqual(b.batch.unplaced_tasks, [9]);
|
|
50
|
+
assert.equal('kind' in b.batch, false, 'a text batch carries no kind');
|
|
51
|
+
|
|
52
|
+
const d = pages.parseDraftBlock(pages.composeDraftBlock({ page_id: 'landing:index', reading_hash: 'h', saved_at: 't', lines: [{ key: 'L0001', section: 's', before: 'a', after: 'b', placement: 'placed' }] }));
|
|
53
|
+
assert.equal(d.ok, true);
|
|
54
|
+
assert.equal(d.draft.lines[0].after, 'b');
|
|
55
|
+
|
|
56
|
+
const a = pages.parseAppliedBlock(pages.composeAppliedBlock({ page_id: 'landing:index', applied_at: 't', reading_hash_after: 'h2', lines_after: ['b'], refused: [{ key: 'L0002', code: 'target_gone' }] }));
|
|
57
|
+
assert.equal(a.ok, true);
|
|
58
|
+
assert.deepEqual(a.applied.lines_after, ['b']);
|
|
59
|
+
assert.deepEqual(a.applied.refused, [{ key: 'L0002', code: 'target_gone' }]);
|
|
60
|
+
|
|
61
|
+
const two = [pages.composeSentBackBlock({ at: '2026-09-01T00:00:00Z', by: '7', note: 'the heading is wrong' }), pages.composeSentBackBlock({ at: '2026-09-02T00:00:00Z', by: '8', note: 'still the heading' })].join('\n\n');
|
|
62
|
+
assert.equal(pages.parseSentBackBlocks(two).length, 2, 'send-backs append, and every one is read');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await test('a draft is never read as a batch: the fence name must end its line', () => {
|
|
66
|
+
const md = pages.composeDraftBlock({ page_id: 'landing:index', reading_hash: 'h', saved_at: 't', lines: [] });
|
|
67
|
+
assert.equal(pages.parseBatchBlock(md).code, 'no_block');
|
|
68
|
+
assert.equal(pages.blockPresence(md).batch, false);
|
|
69
|
+
assert.equal(pages.blockPresence(md).draft, true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await test('a second batch is a named refusal, not a coin flip', () => {
|
|
73
|
+
const one = pages.composeBatchBlock({ page_id: 'landing:index', reading_hash: 'h', submitted_at: 't', lines: [] });
|
|
74
|
+
assert.equal(pages.parseBatchBlock(`${one}\n\n${one}`).code, 'multiple_blocks');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await test('the round ref parses, and anything else belongs to no page', () => {
|
|
78
|
+
assert.deepEqual(pages.parseRoundRef('page-tweak/landing:index/r12'), { page_id: 'landing:index', round: 12 });
|
|
79
|
+
for (const bad of ['page-tweak/landing/r1', 'page-tweak/landing:index/r0', 'cascade-x', null, 'page-tweak-unplaced/5/L0001']) {
|
|
80
|
+
assert.equal(pages.parseRoundRef(bad), null, String(bad));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Read 1: per page.
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
await test('UNTWEAKED: an abandoned round counts for nothing, and no round at all reads the same', () => {
|
|
89
|
+
const out = list();
|
|
90
|
+
for (const id of ['landing:index', 'status:explore']) {
|
|
91
|
+
const p = byId(out, id);
|
|
92
|
+
assert.equal(p.count, 0, id);
|
|
93
|
+
assert.equal(p.status, 'Untweaked', id);
|
|
94
|
+
assert.equal(p.status_label, 'Untweaked', id);
|
|
95
|
+
assert.equal(p.open_round, null, `${id}: an abandoned round is closed`);
|
|
96
|
+
assert.equal(p.drift.changed, false, id);
|
|
97
|
+
}
|
|
98
|
+
const d = detail('landing:index');
|
|
99
|
+
assert.deepEqual(d.changelog, []);
|
|
100
|
+
assert.deepEqual(d.drift.ships, []);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('TWEAKED TWICE: the count is 2, the label says so, and the UI count is reserved at 0', () => {
|
|
104
|
+
const p = byId(list(), 'landing:projects');
|
|
105
|
+
assert.equal(p.count, 2);
|
|
106
|
+
assert.equal(p.text_count, 2);
|
|
107
|
+
assert.equal(p.ui_count, 0);
|
|
108
|
+
assert.equal(p.status, 'Text tweaked');
|
|
109
|
+
assert.equal(p.status_label, 'Text tweaked 2x');
|
|
110
|
+
assert.equal(p.drift.known, true);
|
|
111
|
+
assert.equal(p.drift.changed, false, 'the page says exactly what the last round left');
|
|
112
|
+
assert.equal(p.drift.since_task_id, '102', 'drift is measured from the LATEST shipped round');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('TWEAKED TWICE: the changelog is one entry per shipped round, newest first, with artist, date and lines', () => {
|
|
116
|
+
const d = detail('landing:projects');
|
|
117
|
+
assert.deepEqual(d.changelog.map((c) => [c.task_id, c.round, c.artist && c.artist.login, c.line_count]), [
|
|
118
|
+
['102', 2, 'bob', 2],
|
|
119
|
+
['101', 1, 'alice', 1],
|
|
120
|
+
]);
|
|
121
|
+
assert.equal(d.changelog[0].shipped_at, '2026-09-25T10:00:00.000Z');
|
|
122
|
+
assert.deepEqual(d.changelog[1].lines, [{ key: 'L0002', section: 'list', before: 'All projects', after: 'Every projekt' }]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await test('CHANGED AFTERWARDS: a tweaked page keeps its count and names the lines that moved', () => {
|
|
126
|
+
const p = byId(list(), 'builders:studio');
|
|
127
|
+
assert.equal(p.count, 1, 'drift never lowers the count');
|
|
128
|
+
assert.equal(p.status_label, 'Text tweaked 1x');
|
|
129
|
+
assert.equal(p.drift.known, true);
|
|
130
|
+
assert.equal(p.drift.changed, true);
|
|
131
|
+
assert.equal(p.drift.changed_count, 1, 'the shared shell title changed too, and must not count');
|
|
132
|
+
assert.equal(p.drift.removed_count, 1);
|
|
133
|
+
assert.equal('changed_lines' in p.drift, false, 'the list carries counts; the lines are the detail read');
|
|
134
|
+
|
|
135
|
+
const d = detail('builders:studio', { ships: F.STUDIO_SHIPS });
|
|
136
|
+
assert.deepEqual(d.drift.changed_lines, [{ key: 'L0002', section: 'greeting', text: 'Welcome back' }]);
|
|
137
|
+
assert.deepEqual(d.drift.removed_lines, ['Welcome home']);
|
|
138
|
+
assert.deepEqual(d.drift.ships, [{ task_id: '555', title: 'Reword the studio greeting', shipped_at: '2026-09-22T09:00:00.000Z' }]);
|
|
139
|
+
assert.equal(d.drift.since, '2026-09-20T10:00:00.000Z');
|
|
140
|
+
assert.equal(status.lastRoundShippedAt(F.TASKS, 'builders:studio'), d.drift.since, 'the route reads ships from the same instant drift is measured from');
|
|
141
|
+
assert.equal(status.lastRoundShippedAt(F.TASKS, 'landing:index'), null, 'an untweaked page has no since');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await test('drift is a MULTISET compare: a page that loses one of two identical lines has drifted', () => {
|
|
145
|
+
const rounds = [F.row({ id: '1', status: 'shipped', source_ref: 'page-tweak/landing:index/r1', shipped_at: '2026-09-01T00:00:00Z',
|
|
146
|
+
description: F.describe({ pageId: 'landing:index', submittedAt: 't', lines: [], appliedAt: 't', linesAfter: ['Save', 'Save'] }) })];
|
|
147
|
+
const indexed = status.indexRounds(rounds).get('landing:index');
|
|
148
|
+
const d = status.driftFor({ lines: [{ key: 'L0001', text: 'Save', placement: 'placed' }] }, indexed, true);
|
|
149
|
+
assert.equal(d.changed, true);
|
|
150
|
+
assert.deepEqual(d.removed_lines, ['Save']);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await test('drift says it does not know, rather than guessing, when a reading or an applied block is missing', () => {
|
|
154
|
+
const noReadings = status.composePages({ inventory: F.INVENTORY, readings: null, tasks: F.TASKS });
|
|
155
|
+
const studio = byId(noReadings, 'builders:studio');
|
|
156
|
+
assert.equal(studio.drift.known, false);
|
|
157
|
+
assert.equal(studio.drift.reason, 'readings_missing');
|
|
158
|
+
assert.equal(studio.count, 1, 'status and count come from the ledger and still read true');
|
|
159
|
+
assert.equal(byId(noReadings, 'landing:index').drift.known, true, 'an untweaked page has nothing to drift from');
|
|
160
|
+
|
|
161
|
+
const noPage = detail('builders:studio', { readingPage: null });
|
|
162
|
+
assert.equal(noPage.drift.reason, 'page_not_read');
|
|
163
|
+
|
|
164
|
+
const bare = F.TASKS.map((t) => (t.id === '201' ? { ...t, description: 'no blocks' } : t));
|
|
165
|
+
assert.equal(detail('builders:studio', { tasks: bare }).drift.reason, 'no_applied_block');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await test('a batch of kind ui counts as UI, and text plus ui reads Both', () => {
|
|
169
|
+
const ui = F.row({ id: '9', status: 'shipped', source_ref: 'page-tweak/landing:index/r2', shipped_at: '2026-09-02T00:00:00Z',
|
|
170
|
+
description: pages.composeBatchBlock({ page_id: 'landing:index', reading_hash: 'h', submitted_at: 't', kind: 'ui', lines: [] }) });
|
|
171
|
+
const text = F.row({ id: '8', status: 'shipped', source_ref: 'page-tweak/landing:index/r1', shipped_at: '2026-09-01T00:00:00Z', description: '' });
|
|
172
|
+
assert.equal(status.countsFor(status.indexRounds([ui]).get('landing:index')).status_label, 'UI tweaked 1x');
|
|
173
|
+
const both = status.countsFor(status.indexRounds([ui, text]).get('landing:index'));
|
|
174
|
+
assert.deepEqual([both.text_count, both.ui_count, both.status_label], [1, 1, 'Both 2x']);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// The open round's state (D7).
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
await test('the open round and its holder are named: a web claim is the artist writing', () => {
|
|
182
|
+
const d = detail('builders:studio');
|
|
183
|
+
assert.deepEqual(d.open_round, { task_id: '202', round: 2, status: 'active', state: 'writing', holder: { id: '8', login: 'bob' } });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await test('every row of the D7 state table', () => {
|
|
187
|
+
const batch = F.describe({ pageId: 'landing:index', submittedAt: 't', lines: [] });
|
|
188
|
+
const draft = F.describe({ pageId: 'landing:index', submittedAt: 't', lines: [], draft: true });
|
|
189
|
+
const sentBackAfterApply = F.describe({ pageId: 'landing:index', submittedAt: 't', lines: [], appliedAt: '2026-09-01T00:00:00Z', linesAfter: [],
|
|
190
|
+
sentBack: [{ at: '2026-09-02T00:00:00Z', by: '7', note: 'the heading is wrong' }] });
|
|
191
|
+
const applied = F.describe({ pageId: 'landing:index', submittedAt: 't', lines: [], appliedAt: '2026-09-03T00:00:00Z', linesAfter: [] });
|
|
192
|
+
const cases = [
|
|
193
|
+
[{ status: 'active', holder_id: '7', holder_worktree: false }, 'writing'],
|
|
194
|
+
[{ status: 'active', holder_id: '7', holder_worktree: true }, 'applying'],
|
|
195
|
+
[{ status: 'ready', description: draft }, 'draft'],
|
|
196
|
+
[{ status: 'ready', description: batch }, 'submitted'],
|
|
197
|
+
[{ status: 'ready', description: sentBackAfterApply }, 'submitted'],
|
|
198
|
+
[{ status: 'ready', description: applied }, 'unknown'],
|
|
199
|
+
[{ status: 'completed', grade_passed: true }, 'waiting_for_artist'],
|
|
200
|
+
[{ status: 'completed', grade_passed: false }, 'applying'],
|
|
201
|
+
[{ status: 'confirmed' }, 'landing'],
|
|
202
|
+
];
|
|
203
|
+
for (const [over, want] of cases) assert.equal(status.roundState(F.row(over)), want, JSON.stringify(over));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Read 2: per surface, Artist Review Status.
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
await test('Artist Review Status: N of M per surface, in the mock order, and drift does not lower N', () => {
|
|
211
|
+
const out = list();
|
|
212
|
+
assert.deepEqual(out.surfaces.map((s) => [s.id, s.label, s.line]), [
|
|
213
|
+
['landing', 'Landing', '1 of 2 tweaked'],
|
|
214
|
+
['builders', 'Builders hall', '1 of 1 tweaked'],
|
|
215
|
+
['status', 'Status', '0 of 1 tweaked'],
|
|
216
|
+
]);
|
|
217
|
+
assert.equal(out.totals.line, '2 of 4 pages tweaked');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Read 3: per artist, the tally.
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
await test('the tally sums only tweak.page_approved rows, and this week is the trailing seven days', () => {
|
|
225
|
+
const t = status.composeTally({ builderId: '7', tasks: F.TASKS, creditRows: F.CREDIT_ROWS, now: F.NOW });
|
|
226
|
+
assert.equal(t.credits, 60, 'the task.shipped row is another stream');
|
|
227
|
+
assert.equal(t.this_week, 30, 'the September 1 row is outside the week');
|
|
228
|
+
assert.equal(t.pages_tweaked, 2, 'alice authored r1 of Projects and r1 of Studio');
|
|
229
|
+
assert.equal(t.week_since, '2026-09-20T12:00:00.000Z');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await test('no payout rows yet (TW13 unbuilt) reads 0 credits, while pages tweaked still counts', () => {
|
|
233
|
+
const bob = status.composeTally({ builderId: '8', tasks: F.TASKS, creditRows: [], now: F.NOW });
|
|
234
|
+
assert.deepEqual([bob.credits, bob.this_week, bob.pages_tweaked], [0, 0, 1], 'an open round is not a page tweaked');
|
|
235
|
+
const carol = status.composeTally({ builderId: '9', tasks: F.TASKS, creditRows: [], now: F.NOW });
|
|
236
|
+
assert.deepEqual([carol.credits, carol.this_week, carol.pages_tweaked], [0, 0, 0], 'an abandoned round is not a page tweaked');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
240
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// modules/copy-desk/tests/fixtures/page-tweak.cjs — one small world for the
|
|
2
|
+
// Tweak Mode reads (task 1004316 / BV2.TW05). The done-when's three cases:
|
|
3
|
+
//
|
|
4
|
+
// UNTWEAKED landing:index. Its only round was abandoned, so it has
|
|
5
|
+
// no count, and status:explore has no round at all.
|
|
6
|
+
// TWEAKED TWICE landing:projects, r1 by alice then r2 by bob; the page
|
|
7
|
+
// still says exactly what r2 left, so no drift.
|
|
8
|
+
// TWEAKED, THEN MOVED builders:studio, r1 by alice; a later ship reworded one
|
|
9
|
+
// line ("Welcome home" -> "Welcome back") and the shared
|
|
10
|
+
// shell title changed too, which drift must ignore. A
|
|
11
|
+
// second round is open, held by bob on a web claim.
|
|
12
|
+
//
|
|
13
|
+
// The task rows are in the exact column shape of the lifecycle port's
|
|
14
|
+
// listPageTweakTasks() SELECT, so the same rows feed the pure tests and the
|
|
15
|
+
// whole-path route test's fake pool.
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const pages = require('../../pages.js');
|
|
20
|
+
|
|
21
|
+
const NOW = new Date('2026-09-27T12:00:00.000Z');
|
|
22
|
+
const ALICE = { id: '7', login: 'alice' };
|
|
23
|
+
const BOB = { id: '8', login: 'bob' };
|
|
24
|
+
const NOBODY = { id: '9', login: 'carol' };
|
|
25
|
+
|
|
26
|
+
const STUDIO_FILES = ['modules/hall-ui/public/studio.html', 'modules/hall-ui/public/shell.js'];
|
|
27
|
+
|
|
28
|
+
const INVENTORY = {
|
|
29
|
+
schema: 1,
|
|
30
|
+
surfaces: [
|
|
31
|
+
{ id: 'builders', pages: 1 },
|
|
32
|
+
{ id: 'landing', pages: 2 },
|
|
33
|
+
{ id: 'status', pages: 1 },
|
|
34
|
+
],
|
|
35
|
+
pages: [
|
|
36
|
+
{ id: 'builders:studio', surface: 'builders', title: 'Studio', path: '/studio', files: STUDIO_FILES },
|
|
37
|
+
{ id: 'landing:index', surface: 'landing', title: 'Home', path: '/', files: ['modules/public-landing/public/index.html'] },
|
|
38
|
+
{ id: 'landing:projects', surface: 'landing', title: 'Projects', path: '/projects', files: ['modules/public-landing/public/projects.html'] },
|
|
39
|
+
{ id: 'status:explore', surface: 'status', title: 'Explore', path: '/explore', files: ['modules/status-ui/public/explore.html'] },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const line = (key, section, text, placement = 'placed') => ({ key, section, text, placement });
|
|
44
|
+
|
|
45
|
+
const READINGS = {
|
|
46
|
+
schema: 1,
|
|
47
|
+
pages: [
|
|
48
|
+
{ id: 'builders:studio', reading_hash: 'studio-now', lines: [
|
|
49
|
+
line('L0001', 'top bar', 'Studio, renamed', 'shared'),
|
|
50
|
+
line('L0002', 'greeting', 'Welcome back'),
|
|
51
|
+
line('L0003', 'greeting', 'Resume'),
|
|
52
|
+
] },
|
|
53
|
+
{ id: 'landing:index', reading_hash: 'index-now', lines: [
|
|
54
|
+
line('L0001', 'skip link', 'Skip to content', 'shared'),
|
|
55
|
+
line('L0002', 'hero', 'Build here'),
|
|
56
|
+
] },
|
|
57
|
+
{ id: 'landing:projects', reading_hash: 'projects-now', lines: [
|
|
58
|
+
line('L0001', 'skip link', 'Skip to content', 'shared'),
|
|
59
|
+
line('L0002', 'list', 'Every project'),
|
|
60
|
+
line('L0003', 'list', 'Start one'),
|
|
61
|
+
] },
|
|
62
|
+
{ id: 'status:explore', reading_hash: 'explore-now', lines: [line('L0001', 'hero', 'Explore')] },
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function describe({ pageId, submittedAt, lines, appliedAt, linesAfter, draft, sentBack }) {
|
|
67
|
+
const parts = [`Tweak ${pageId}.`];
|
|
68
|
+
if (draft) parts.push(pages.composeDraftBlock({ page_id: pageId, reading_hash: 'h', saved_at: submittedAt, lines }));
|
|
69
|
+
if (!draft) {
|
|
70
|
+
parts.push(pages.composeBatchBlock({
|
|
71
|
+
page_id: pageId, reading_hash: 'h', submitted_at: submittedAt,
|
|
72
|
+
lines: lines.map((l) => ({ ...l, placement: 'placed', file: 'x.html', line: 1, string_id: 'abc' })),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
if (appliedAt) parts.push(pages.composeAppliedBlock({ page_id: pageId, applied_at: appliedAt, reading_hash_after: 'h2', lines_after: linesAfter, refused: [] }));
|
|
76
|
+
for (const s of sentBack || []) parts.push(pages.composeSentBackBlock(s));
|
|
77
|
+
return parts.join('\n\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function row(over) {
|
|
81
|
+
return {
|
|
82
|
+
id: null, status: 'ready', title: 'Tweak', description: '', source_ref: null, touches: [],
|
|
83
|
+
shipped_at: null, created_at: new Date('2026-08-01T00:00:00Z'), updated_at: new Date('2026-08-01T00:00:00Z'),
|
|
84
|
+
grade_passed: null, holder_id: null, holder_login: null, holder_worktree: false,
|
|
85
|
+
artist_id: null, artist_login: null,
|
|
86
|
+
...over,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const TASKS = [
|
|
91
|
+
row({
|
|
92
|
+
id: '101', status: 'shipped', source_ref: pages.roundSourceRef('landing:projects', 1),
|
|
93
|
+
shipped_at: new Date('2026-09-01T10:00:00Z'), grade_passed: true,
|
|
94
|
+
artist_id: ALICE.id, artist_login: ALICE.login,
|
|
95
|
+
description: describe({
|
|
96
|
+
pageId: 'landing:projects', submittedAt: '2026-08-30T10:00:00Z',
|
|
97
|
+
lines: [{ key: 'L0002', section: 'list', before: 'All projects', after: 'Every projekt' }],
|
|
98
|
+
appliedAt: '2026-08-31T10:00:00Z', linesAfter: ['Every projekt', 'Begin'],
|
|
99
|
+
}),
|
|
100
|
+
}),
|
|
101
|
+
row({
|
|
102
|
+
id: '102', status: 'shipped', source_ref: pages.roundSourceRef('landing:projects', 2),
|
|
103
|
+
shipped_at: new Date('2026-09-25T10:00:00Z'), grade_passed: true,
|
|
104
|
+
artist_id: BOB.id, artist_login: BOB.login,
|
|
105
|
+
description: describe({
|
|
106
|
+
pageId: 'landing:projects', submittedAt: '2026-09-24T10:00:00Z',
|
|
107
|
+
lines: [
|
|
108
|
+
{ key: 'L0002', section: 'list', before: 'Every projekt', after: 'Every project' },
|
|
109
|
+
{ key: 'L0003', section: 'list', before: 'Begin', after: 'Start one' },
|
|
110
|
+
],
|
|
111
|
+
appliedAt: '2026-09-24T18:00:00Z', linesAfter: ['Every project', 'Start one'],
|
|
112
|
+
}),
|
|
113
|
+
}),
|
|
114
|
+
row({
|
|
115
|
+
id: '201', status: 'shipped', source_ref: pages.roundSourceRef('builders:studio', 1),
|
|
116
|
+
shipped_at: new Date('2026-09-20T10:00:00Z'), grade_passed: true, touches: STUDIO_FILES,
|
|
117
|
+
artist_id: ALICE.id, artist_login: ALICE.login,
|
|
118
|
+
description: describe({
|
|
119
|
+
pageId: 'builders:studio', submittedAt: '2026-09-19T10:00:00Z',
|
|
120
|
+
lines: [{ key: 'L0002', section: 'greeting', before: 'Welcome', after: 'Welcome home' }],
|
|
121
|
+
appliedAt: '2026-09-19T18:00:00Z', linesAfter: ['Welcome home', 'Resume'],
|
|
122
|
+
}),
|
|
123
|
+
}),
|
|
124
|
+
row({
|
|
125
|
+
id: '202', status: 'active', source_ref: pages.roundSourceRef('builders:studio', 2),
|
|
126
|
+
holder_id: BOB.id, holder_login: BOB.login, holder_worktree: false,
|
|
127
|
+
artist_id: BOB.id, artist_login: BOB.login,
|
|
128
|
+
description: describe({
|
|
129
|
+
pageId: 'builders:studio', submittedAt: '2026-09-26T10:00:00Z', draft: true,
|
|
130
|
+
lines: [{ key: 'L0002', section: 'greeting', before: 'Welcome back', after: 'Good to see you' }],
|
|
131
|
+
}),
|
|
132
|
+
}),
|
|
133
|
+
row({
|
|
134
|
+
id: '301', status: 'abandoned', source_ref: pages.roundSourceRef('landing:index', 1),
|
|
135
|
+
artist_id: NOBODY.id, artist_login: NOBODY.login,
|
|
136
|
+
description: 'Tweak landing:index.',
|
|
137
|
+
}),
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
// The later ship that reworded the studio greeting, for drift attribution.
|
|
141
|
+
const STUDIO_SHIPS = [
|
|
142
|
+
{ id: '555', title: 'Reword the studio greeting', shipped_at: new Date('2026-09-22T09:00:00Z') },
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// credit_log rows (economy's creditLedger shape). One of alice's is outside the
|
|
146
|
+
// trailing week and one is inside; the task.shipped row is another stream and
|
|
147
|
+
// must never reach the tally.
|
|
148
|
+
const CREDIT_ROWS = [
|
|
149
|
+
{ id: 1, builder_id: 7, delta: 30, reason: 'tweak.page_approved:101', task_id: 101, recorded_at: new Date('2026-09-01T10:00:00Z') },
|
|
150
|
+
{ id: 2, builder_id: 7, delta: 30, reason: 'tweak.page_approved:201', task_id: 201, recorded_at: new Date('2026-09-24T10:00:00Z') },
|
|
151
|
+
{ id: 3, builder_id: 7, delta: 50, reason: 'task.shipped', task_id: 555, recorded_at: new Date('2026-09-26T10:00:00Z') },
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
module.exports = { NOW, ALICE, BOB, NOBODY, STUDIO_FILES, INVENTORY, READINGS, TASKS, STUDIO_SHIPS, CREDIT_ROWS, describe, row };
|
|
@@ -24,6 +24,8 @@ const helpRequests = require('./help-requests');
|
|
|
24
24
|
const taskRecommendations = require('./task-recommendations');
|
|
25
25
|
const activity = require('./activity');
|
|
26
26
|
const mingle = require('./mingle');
|
|
27
|
+
const pageTweakReads = require('./page-tweak-reads');
|
|
28
|
+
const pageTweakClaim = require('./page-tweak-claim');
|
|
27
29
|
|
|
28
30
|
module.exports = {
|
|
29
31
|
// --- retired doorway leaks (ADR 0093 §3) ---------------------------------
|
|
@@ -104,6 +106,18 @@ module.exports = {
|
|
|
104
106
|
// core POST /tasks vector does, so a double-submitted proposal resolves to the
|
|
105
107
|
// task it already made instead of littering the board with twins.
|
|
106
108
|
findTaskBySourceRef: (...a) => db.findTaskBySourceRef(...a),
|
|
109
|
+
// Tweak Mode's ledger reads (task 1004316 / BV2.TW05, ADR 0341 D8): every
|
|
110
|
+
// page-tweak task with its grade, active claim and artist, and the ships that
|
|
111
|
+
// touched a page since its last round. copy-desk derives status, changelog,
|
|
112
|
+
// drift and the tally from these; there is no status table.
|
|
113
|
+
listPageTweakTasks: (...a) => pageTweakReads.listPageTweakTasks(...a),
|
|
114
|
+
shippedTasksTouchingSince: (...a) => pageTweakReads.shippedTasksTouchingSince(...a),
|
|
115
|
+
// The page claim (task 1004317 / BV2.TW06, ADR 0341 D2, D5): find or create a
|
|
116
|
+
// page's open tweak round and web-claim it, in one transaction under an
|
|
117
|
+
// advisory lock on the page. ADR 0341 D11 names this port entry webClaimTask;
|
|
118
|
+
// it is page-shaped because the find-or-create has to share the claim's
|
|
119
|
+
// transaction. See modules/lifecycle/page-tweak-claim.js.
|
|
120
|
+
webClaimPageTweak: (...a) => pageTweakClaim.webClaimPageTweak(...a),
|
|
107
121
|
listVersions: (...a) => db.listVersions(...a),
|
|
108
122
|
versionProgress: (...a) => db.versionProgress(...a),
|
|
109
123
|
recentShippedTasks: (...a) => db.recentShippedTasks(...a),
|