@bongos/core 1.19.1057 → 1.19.1058
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 +70 -25
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +108 -1
- package/docs/api-reference.md +5 -2
- package/docs/architecture.md +2 -1
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +1178 -0
- package/docs/page-readings.json +4108 -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 +204 -0
- package/modules/copy-desk/routes/copy-desk.js +155 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +16 -6
- 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 +7 -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 +6 -0
- package/scripts/gds/publish-manifest.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_reads.mjs +194 -0
|
@@ -45,6 +45,9 @@ const api = require('../../../src/module-api');
|
|
|
45
45
|
const flags = require('../flags');
|
|
46
46
|
const registry = require('../registry');
|
|
47
47
|
const proposals = require('../proposals');
|
|
48
|
+
const pageData = require('../page-data');
|
|
49
|
+
const pageStatus = require('../page-status');
|
|
50
|
+
const pages = require('../pages');
|
|
48
51
|
|
|
49
52
|
const { validateOrRespond } = api;
|
|
50
53
|
|
|
@@ -106,6 +109,67 @@ function registryOrFail(res) {
|
|
|
106
109
|
return loaded;
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Tweak Mode's reads (task 1004316 / BV2.TW05, ADR 0341 D8 and D11).
|
|
114
|
+
//
|
|
115
|
+
// The page inventory is REQUIRED: without it there is no page to speak of, so
|
|
116
|
+
// its absence is a named 503 carrying the generator command, the registry's own
|
|
117
|
+
// rule. The page readings are OPTIONAL: status, count and changelog come from
|
|
118
|
+
// the ledger and read true without them, so a missing readings file only marks
|
|
119
|
+
// drift unknown (`readings_missing`). Both files ship in the publish manifest
|
|
120
|
+
// (ADR 0341 D1); these branches are for an instance on an older core or a
|
|
121
|
+
// checkout that has not run the generators.
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
function inventoryOrFail(res) {
|
|
125
|
+
const loaded = pageData.loadInventory();
|
|
126
|
+
if (!loaded.ok) {
|
|
127
|
+
res.fail(loaded.code, 503, {
|
|
128
|
+
...loaded.detail,
|
|
129
|
+
what_this_means: 'Tweak Mode keys every record on a page from the committed page inventory. Without it there is no list of pages to report on.',
|
|
130
|
+
});
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return loaded;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readingsState() {
|
|
137
|
+
const loaded = pageData.loadReadings();
|
|
138
|
+
return loaded.ok
|
|
139
|
+
? { data: loaded.data, info: { ok: true, at: loaded.at } }
|
|
140
|
+
: { data: null, info: { ok: false, code: loaded.code, run: loaded.detail.run } };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The ledger reads come through the lifecycle port. copy-desk consumes
|
|
144
|
+
// lifecycle, so the port is always there on a booted instance; the check is for
|
|
145
|
+
// a core whose lifecycle predates these reads.
|
|
146
|
+
function pageReadsOrFail(res) {
|
|
147
|
+
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
148
|
+
if (!lifecycle || typeof lifecycle.listPageTweakTasks !== 'function') {
|
|
149
|
+
res.fail('lifecycle_unavailable', 503, {
|
|
150
|
+
what_this_means: 'Page status is derived from the task ledger, and this instance cannot read it.',
|
|
151
|
+
});
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
return lifecycle;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Every `tweak.page_approved:` credit row the caller has, through the reward
|
|
158
|
+
// port (credit_log is economy's table). No reward port, or no rows yet (TW13
|
|
159
|
+
// has not built the payout), reads as none: a tally of 0 is the truth then.
|
|
160
|
+
//
|
|
161
|
+
// ONE bounded query. An approved page books one row, so the cap is thousands of
|
|
162
|
+
// pages for one artist; past it the tally says `truncated` (creditLedger's
|
|
163
|
+
// `total` is the unpaginated count) rather than paging in a loop.
|
|
164
|
+
const TALLY_ROW_CAP = 5000;
|
|
165
|
+
async function tallyCreditRows(builderId) {
|
|
166
|
+
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
167
|
+
if (!reward || typeof reward.creditLedger !== 'function') return { rows: [], truncated: false };
|
|
168
|
+
const { rows, total } = await reward.creditLedger(builderId, { reasonPrefix: pageStatus.TALLY_REASON_PREFIX, limit: TALLY_ROW_CAP });
|
|
169
|
+
const list = rows || [];
|
|
170
|
+
return { rows: list, truncated: Number(total) > list.length };
|
|
171
|
+
}
|
|
172
|
+
|
|
109
173
|
module.exports = function buildCopyDeskRouter() {
|
|
110
174
|
const router = express.Router();
|
|
111
175
|
|
|
@@ -449,5 +513,96 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
449
513
|
}
|
|
450
514
|
});
|
|
451
515
|
|
|
516
|
+
// -------------------------------------------------------------------------
|
|
517
|
+
// GET /copy-desk/pages — every page's status, count and drift, plus the
|
|
518
|
+
// per-surface rollup, Artist Review Status ("N of M tweaked").
|
|
519
|
+
//
|
|
520
|
+
// rank: any signed-in builder. Page status is a work list, like the queue;
|
|
521
|
+
// an artist is a craft, not a rank.
|
|
522
|
+
// -------------------------------------------------------------------------
|
|
523
|
+
router.get('/copy-desk/pages', api.requireBuilder, async (req, res) => {
|
|
524
|
+
try {
|
|
525
|
+
const inv = inventoryOrFail(res);
|
|
526
|
+
if (!inv) return;
|
|
527
|
+
const lifecycle = pageReadsOrFail(res);
|
|
528
|
+
if (!lifecycle) return;
|
|
529
|
+
const readings = readingsState();
|
|
530
|
+
const tasks = await lifecycle.listPageTweakTasks();
|
|
531
|
+
const out = pageStatus.composePages({ inventory: inv.data, readings: readings.data, tasks });
|
|
532
|
+
out.inventory = { at: inv.at };
|
|
533
|
+
out.readings = readings.info;
|
|
534
|
+
res.set('Cache-Control', 'no-store');
|
|
535
|
+
res.json(out);
|
|
536
|
+
} catch (err) {
|
|
537
|
+
log.error({ err }, 'GET /copy-desk/pages failed');
|
|
538
|
+
if (!res.headersSent) res.fail('copy_pages_failed', 500);
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// -------------------------------------------------------------------------
|
|
543
|
+
// GET /copy-desk/pages/:pageId — one page: status, changelog, the lines
|
|
544
|
+
// changed since its last tweak and the ships that changed them, and the open
|
|
545
|
+
// round with its state (ADR 0341 D7).
|
|
546
|
+
//
|
|
547
|
+
// An id the inventory does not know is `unknown_page` (ADR 0341 D1).
|
|
548
|
+
// -------------------------------------------------------------------------
|
|
549
|
+
router.get('/copy-desk/pages/:pageId', api.requireBuilder, async (req, res) => {
|
|
550
|
+
try {
|
|
551
|
+
const pageId = String(req.params.pageId || '');
|
|
552
|
+
if (!pages.PAGE_ID_RE.test(pageId)) {
|
|
553
|
+
return res.fail('unknown_page', 404, { page_id: pageId, what_this_means: 'A page id is <surface>:<page>, for example landing:index.' });
|
|
554
|
+
}
|
|
555
|
+
const inv = inventoryOrFail(res);
|
|
556
|
+
if (!inv) return;
|
|
557
|
+
const page = (inv.data.pages || []).find((p) => p && p.id === pageId);
|
|
558
|
+
if (!page) {
|
|
559
|
+
return res.fail('unknown_page', 404, { page_id: pageId, what_this_means: 'The page inventory has no page with that id.' });
|
|
560
|
+
}
|
|
561
|
+
const lifecycle = pageReadsOrFail(res);
|
|
562
|
+
if (!lifecycle) return;
|
|
563
|
+
const readings = readingsState();
|
|
564
|
+
const readingPage = readings.data ? (readings.data.pages || []).find((p) => p && p.id === pageId) : null;
|
|
565
|
+
const tasks = await lifecycle.listPageTweakTasks();
|
|
566
|
+
// The ships since the page's last round are read first, so the page is
|
|
567
|
+
// composed once with everything it shows.
|
|
568
|
+
const since = pageStatus.lastRoundShippedAt(tasks, pageId);
|
|
569
|
+
const ships = since && typeof lifecycle.shippedTasksTouchingSince === 'function'
|
|
570
|
+
? await lifecycle.shippedTasksTouchingSince({ files: page.files, since })
|
|
571
|
+
: [];
|
|
572
|
+
const out = pageStatus.composePage({ page, tasks, readingPage, readingsOk: !!readings.data, ships });
|
|
573
|
+
out.readings = readings.info;
|
|
574
|
+
res.set('Cache-Control', 'no-store');
|
|
575
|
+
res.json(out);
|
|
576
|
+
} catch (err) {
|
|
577
|
+
log.error({ err }, 'GET /copy-desk/pages/:pageId failed');
|
|
578
|
+
if (!res.headersSent) res.fail('copy_page_failed', 500);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// -------------------------------------------------------------------------
|
|
583
|
+
// GET /copy-desk/tally — the caller's corner tally in the studio: credits
|
|
584
|
+
// from approved pages, pages tweaked, and this week's gain (trailing 7 days).
|
|
585
|
+
//
|
|
586
|
+
// Always the CALLER's own tally; there is no builder parameter to widen it.
|
|
587
|
+
// Needs no inventory: it reads the ledger only.
|
|
588
|
+
// -------------------------------------------------------------------------
|
|
589
|
+
router.get('/copy-desk/tally', api.requireBuilder, async (req, res) => {
|
|
590
|
+
try {
|
|
591
|
+
const lifecycle = pageReadsOrFail(res);
|
|
592
|
+
if (!lifecycle) return;
|
|
593
|
+
const [tasks, credit] = await Promise.all([
|
|
594
|
+
lifecycle.listPageTweakTasks(),
|
|
595
|
+
tallyCreditRows(req.builder.id),
|
|
596
|
+
]);
|
|
597
|
+
const out = pageStatus.composeTally({ builderId: req.builder.id, tasks, creditRows: credit.rows, now: new Date() });
|
|
598
|
+
out.truncated = credit.truncated;
|
|
599
|
+
res.set('Cache-Control', 'no-store');
|
|
600
|
+
res.json(out);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
log.error({ err }, 'GET /copy-desk/tally failed');
|
|
603
|
+
if (!res.headersSent) res.fail('copy_tally_failed', 500);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
452
607
|
return router;
|
|
453
608
|
};
|
|
@@ -59,6 +59,12 @@ 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
70
|
await test('the write surface is exactly the three verbs the module defines', () => {
|
|
@@ -110,7 +116,7 @@ await test('this module never writes to the surfaces it inventories', () => {
|
|
|
110
116
|
// The registry is generated by R01 and committed. If this module ever gained a
|
|
111
117
|
// write to docs/copy-registry.json — or to any surface file — the inventory
|
|
112
118
|
// would stop describing the tree and start describing itself.
|
|
113
|
-
for (const rel of
|
|
119
|
+
for (const rel of MODULE_FILES) {
|
|
114
120
|
const src = stripComments(read(rel));
|
|
115
121
|
assert.equal(/fs\.(writeFile|writeFileSync|appendFile|appendFileSync|rm|unlink|mkdir)/.test(src), false,
|
|
116
122
|
`${rel} performs a filesystem WRITE — this module reads the registry and never writes it`);
|
|
@@ -121,10 +127,14 @@ await test('the proposal domain holds no path to a renderer — it composes text
|
|
|
121
127
|
// proposals.js is the file that handles replacement wording, so it is the file
|
|
122
128
|
// most able to become a copy source by accident. It must be PURE: no fs, no
|
|
123
129
|
// DB, no network. Its output goes into a task description and nowhere else.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
// pages.js joins it (ADR 0341 D4): it holds a page batch's replacement
|
|
131
|
+
// wording, the same material at page scale.
|
|
132
|
+
for (const rel of ['proposals.js', 'pages.js']) {
|
|
133
|
+
const src = stripComments(read(rel));
|
|
134
|
+
for (const forbidden of ['require(', 'fs.', 'pool', 'fetch(']) {
|
|
135
|
+
assert.equal(src.includes(forbidden), false,
|
|
136
|
+
`${rel} must stay pure — it must not reach for ${forbidden}`);
|
|
137
|
+
}
|
|
128
138
|
}
|
|
129
139
|
});
|
|
130
140
|
|
|
@@ -160,7 +170,7 @@ await test('the applier is the only writer, it lives outside the request path, a
|
|
|
160
170
|
// exact line to run, and that instruction is the whole handoff. What must
|
|
161
171
|
// not exist is a require/import edge, which would put a file write one
|
|
162
172
|
// function call away from a request handler.
|
|
163
|
-
for (const rel of
|
|
173
|
+
for (const rel of MODULE_FILES) {
|
|
164
174
|
const src = stripComments(read(rel));
|
|
165
175
|
assert.equal(/(?:require|import)\s*\(?\s*['"][^'"]*copy-apply/.test(src), false,
|
|
166
176
|
`${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,7 @@ 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');
|
|
27
28
|
|
|
28
29
|
module.exports = {
|
|
29
30
|
// --- retired doorway leaks (ADR 0093 §3) ---------------------------------
|
|
@@ -104,6 +105,12 @@ module.exports = {
|
|
|
104
105
|
// core POST /tasks vector does, so a double-submitted proposal resolves to the
|
|
105
106
|
// task it already made instead of littering the board with twins.
|
|
106
107
|
findTaskBySourceRef: (...a) => db.findTaskBySourceRef(...a),
|
|
108
|
+
// Tweak Mode's ledger reads (task 1004316 / BV2.TW05, ADR 0341 D8): every
|
|
109
|
+
// page-tweak task with its grade, active claim and artist, and the ships that
|
|
110
|
+
// touched a page since its last round. copy-desk derives status, changelog,
|
|
111
|
+
// drift and the tally from these; there is no status table.
|
|
112
|
+
listPageTweakTasks: (...a) => pageTweakReads.listPageTweakTasks(...a),
|
|
113
|
+
shippedTasksTouchingSince: (...a) => pageTweakReads.shippedTasksTouchingSince(...a),
|
|
107
114
|
listVersions: (...a) => db.listVersions(...a),
|
|
108
115
|
versionProgress: (...a) => db.versionProgress(...a),
|
|
109
116
|
recentShippedTasks: (...a) => db.recentShippedTasks(...a),
|