@bongos/core 1.19.1065 → 1.19.1067

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.
@@ -0,0 +1,272 @@
1
+ // tests/artist_page_credit.mjs
2
+ //
3
+ // Task 1004324 / BV2.TW13, ADR 0341 D10: an approved page pays its ARTIST at
4
+ // the land, as the artist reviews it replaced paid.
5
+ //
6
+ // The contract this pins, through the REAL db.shipTask and the REAL economy
7
+ // writer over a small stateful ledger (tasks, claims, credit_log):
8
+ // * a shipped page tweak books ONE credit_log row, reason
9
+ // tweak.page_approved:<task id>, to the builder of the round's latest WEB
10
+ // claim, worth credits_reward x KIND_MULTIPLIERS[kind] (30 for a feature
11
+ // round at 30);
12
+ // * the shipper's per-task estimate (task.shipped) is NOT paid on a page
13
+ // tweak, so the per-task stream's total is the same as an ordinary ship's;
14
+ // * a self-applied round (the artist also ran /tweak) is one artist row;
15
+ // * rounding is the one multiplier rule, once;
16
+ // * a re-ship or a reconciler re-sweep books nothing a second time;
17
+ // * a sent-back round is never paid, and a re-applied round pays its artist,
18
+ // never either applier;
19
+ // * the lane is not withheld by cost-plus-only, and only an explicit
20
+ // artistCredit:false turns it off;
21
+ // * the reason prefix is the one the studio tally sums, and the tally reads
22
+ // the booked row as 30 credits.
23
+ //
24
+ // Run: node tests/artist_page_credit.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import { createRequire } from 'node:module';
28
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
29
+
30
+ process.env.NODE_ENV = 'test';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const achievements = require('../modules/economy/achievements.js');
34
+ achievements.evaluateAfterShip = async () => [];
35
+ achievements.evaluateAfterCreditAward = async () => [];
36
+ achievements.reconcileBuilderAchievements = async () => [];
37
+ const seams = require('../src/module-seams.js');
38
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
39
+ const credits = require('../modules/economy/credits.js');
40
+ const db = require('../modules/lifecycle/db.js');
41
+ const pageStatus = require('../modules/copy-desk/page-status.js');
42
+
43
+ const { test, summary } = makeRunner();
44
+
45
+ const ARTIST = '41';
46
+ const APPLIER = '42';
47
+ const APPLIER_2 = '43';
48
+ const OTHER_ARTIST = '44';
49
+ const RECONCILER = '999';
50
+ const LAND_PROOF = { kind: 'proof', reason: 'test fixture: land proven' };
51
+
52
+ // The claims a round collects, in order. A web claim has no worktree and no
53
+ // session; the applier's CLI claim has a worktree.
54
+ const web = (builder, at) => ({ builder_id: builder, claimed_at: at, worktree_name: null, creator_session_id: null });
55
+ const cli = (builder, at) => ({ builder_id: builder, claimed_at: at, worktree_name: `tw-${builder}-${at}`, creator_session_id: `s-${builder}` });
56
+
57
+ // A stateful ledger for one task. It answers the statements shipTask and the
58
+ // artist writer send; credit_log is a real list, so the NOT EXISTS guards are
59
+ // evaluated against what was actually booked.
60
+ function ledger({ source = 'page-tweak', status = 'confirmed', creditsReward = 30, kind = 'feature', claims = [] } = {}) {
61
+ const state = {
62
+ task: { id: 7001, title: 'Tweak: Home (landing:index)', source, status, credits_reward: creditsReward, kind, shipped_by: null },
63
+ claims: claims.map((c, i) => ({ id: 9000 + i, released_at: 'earlier', ...c })),
64
+ credit_log: [],
65
+ };
66
+ const latest = (list) => list.slice().sort((a, b) => (a.claimed_at - b.claimed_at) || (a.id - b.id)).slice(-1)[0] || null;
67
+ const pool = makeSqlAwareClient((s, p) => {
68
+ const t = state.task;
69
+ if (/^UPDATE tasks SET status = 'shipped'/.test(s)) {
70
+ if (t.status !== 'confirmed') return { rows: [] };
71
+ t.status = 'shipped';
72
+ t.shipped_by = t.shipped_by ?? p[1];
73
+ return { rows: [{ id: t.id, title: t.title, status: 'shipped', shipped_at: new Date().toISOString(), shipped_by: t.shipped_by,
74
+ security_sensitive: false, value_summary: null, credits_reward: t.credits_reward, kind: t.kind, source: t.source }] };
75
+ }
76
+ if (/^UPDATE claims/.test(s)) return { rows: [] };
77
+ if (/^SELECT builder_id FROM claims WHERE task_id = \$1/.test(s)) {
78
+ const c = latest(state.claims);
79
+ return { rows: c ? [{ builder_id: c.builder_id }] : [] };
80
+ }
81
+ if (/^SELECT 1 FROM credit_log WHERE task_id = \$1 AND reason = 'task\.shipped'/.test(s)) {
82
+ return { rows: state.credit_log.filter((r) => r.task_id === p[0] && r.reason === 'task.shipped').map(() => ({ one: 1 })) };
83
+ }
84
+ if (/^SELECT t\.source, t\.status, t\.credits_reward, t\.kind/.test(s)) {
85
+ if (p[0] !== t.id) return { rows: [] };
86
+ assert.match(s, /c\.worktree_name IS NULL AND c\.creator_session_id IS NULL/, 'the payee is read from WEB claims only');
87
+ const w = latest(state.claims.filter((c) => c.worktree_name == null && c.creator_session_id == null));
88
+ return { rows: [{ source: t.source, status: t.status, credits_reward: t.credits_reward, kind: t.kind, artist_id: w ? w.builder_id : null }] };
89
+ }
90
+ if (/^INSERT INTO credit_log/.test(s)) {
91
+ const [builder_id, task_id, delta, reason] = p;
92
+ if (/WHERE NOT EXISTS \(SELECT 1 FROM credit_log WHERE task_id = \$2 AND reason = \$4\)/.test(s)
93
+ && state.credit_log.some((r) => r.task_id === task_id && r.reason === reason)) return { rows: [], rowCount: 0 };
94
+ if (/WHERE NOT EXISTS \(SELECT 1 FROM credit_log WHERE builder_id = \$1 AND reason = \$4\)/.test(s)
95
+ && state.credit_log.some((r) => r.builder_id === builder_id && r.reason === reason)) return { rows: [], rowCount: 0 };
96
+ state.credit_log.push({ builder_id, task_id, delta, reason, recorded_at: new Date().toISOString() });
97
+ return { rows: [], rowCount: 1 };
98
+ }
99
+ return { rows: [] };
100
+ });
101
+ return { pool, state };
102
+ }
103
+
104
+ const ship = (pool) => db.shipTask({ taskId: 7001, builderId: RECONCILER, landAssurance: LAND_PROOF }, { pool });
105
+ const sent = (pool) => pool._client.queries.map((q) => String(q.sql).replace(/\s+/g, ' ').trim());
106
+ const perTaskRows = (state) => state.credit_log.filter((r) => r.reason === 'task.shipped' || r.reason.startsWith('tweak.page_approved:'));
107
+
108
+ // =========================================================================
109
+ await test('a shipped page tweak pays its artist 30, on tweak.page_approved:<id>, and the shipper no estimate', async () => {
110
+ const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
111
+ const out = await ship(pool);
112
+ assert.equal(out.status, 'shipped');
113
+ assert.equal(out.creditsAwarded, 0, 'the shipper is paid no per-task estimate on a page tweak');
114
+ assert.equal(String(out.creditedBuilderId), APPLIER, 'the applier is still the ship-credited builder (achievements, session reward)');
115
+ assert.deepEqual(state.credit_log.map((r) => [String(r.builder_id), r.task_id, r.delta, r.reason]),
116
+ [[ARTIST, 7001, 30, 'tweak.page_approved:7001']], 'exactly one row: the artist, the priced value, the tally reason');
117
+ assert.equal(out.artistCredit.reason, 'paid');
118
+ assert.equal(out.artistCredit.credited, 30);
119
+ assert.ok(!sent(pool).some((q) => /'task\.shipped'/.test(q)), 'the estimate path is never entered for a page tweak');
120
+ });
121
+
122
+ await test('self-applied: the artist also ran /tweak, and is paid one artist row, nothing doubled', async () => {
123
+ const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(ARTIST, 2)] });
124
+ const out = await ship(pool);
125
+ assert.equal(String(out.creditedBuilderId), ARTIST);
126
+ assert.equal(state.credit_log.length, 1, 'one row, not an artist row plus an estimate row');
127
+ assert.equal(String(state.credit_log[0].builder_id), ARTIST);
128
+ assert.equal(state.credit_log[0].delta, 30);
129
+ });
130
+
131
+ await test('rounding: the priced value is credits_reward x the kind multiplier, rounded once', async () => {
132
+ for (const [reward, kind, want] of [[30, 'feature', 30], [25, 'bug', 30], [7, 'refactor', 6], [5, 'spike', 6], [3, 'cleanup', 2], [11, 'mystery', 11]]) {
133
+ const { pool, state } = ledger({ creditsReward: reward, kind, claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
134
+ await ship(pool);
135
+ assert.equal(state.credit_log.length, 1, `${reward} x ${kind}: one row`);
136
+ assert.equal(state.credit_log[0].delta, want, `${reward} x ${kind} pays ${want}`);
137
+ assert.equal(state.credit_log[0].delta, credits.applyKindMultiplier(reward, kind).effective, 'the same rule as the estimate');
138
+ }
139
+ });
140
+
141
+ await test('a zero-reward round books nothing (no zero-value row that reads like a payment)', async () => {
142
+ const { pool, state } = ledger({ creditsReward: 0, claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
143
+ const out = await ship(pool);
144
+ assert.equal(state.credit_log.length, 0);
145
+ assert.equal(out.artistCredit.reason, 'zero_value');
146
+ });
147
+
148
+ await test('the per-task stream total is conserved: a page tweak pays the artist what the ordinary ship paid the shipper', async () => {
149
+ for (const [reward, kind] of [[30, 'feature'], [25, 'bug'], [7, 'refactor']]) {
150
+ const ordinary = ledger({ source: null, creditsReward: reward, kind, claims: [cli(APPLIER, 2)] });
151
+ await ship(ordinary.pool);
152
+ const tweak = ledger({ creditsReward: reward, kind, claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
153
+ await ship(tweak.pool);
154
+ const sum = (st) => perTaskRows(st).reduce((a, r) => a + r.delta, 0);
155
+ const ordinaryRows = perTaskRows(ordinary.state);
156
+ // An instance in cost-plus-only books no ordinary estimate at all; the
157
+ // equality then holds against what the estimate WOULD have been.
158
+ const estimate = ordinaryRows.length ? sum(ordinary.state) : credits.applyKindMultiplier(reward, kind).effective;
159
+ assert.equal(sum(tweak.state), estimate, `${reward} x ${kind}: the same total, moved to the artist`);
160
+ assert.ok(!perTaskRows(tweak.state).some((r) => String(r.builder_id) === APPLIER), 'the applier gets no per-task row');
161
+ assert.ok(!sent(ordinary.pool).some((q) => /^SELECT t\.source, t\.status/.test(q)), 'an ordinary ship never asks the artist lane');
162
+ }
163
+ });
164
+
165
+ await test('idempotent: a re-ship or a reconciler re-sweep books nothing a second time', async () => {
166
+ const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
167
+ await ship(pool);
168
+ // The shipped flip is terminal, so a second shipTask throws before paying.
169
+ await assert.rejects(() => ship(pool), (e) => e.code === 'TASK_NOT_CONFIRMED');
170
+ // And the writer itself, asked again inside some later transaction, refuses.
171
+ const again = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
172
+ assert.equal(again.reason, 'already_paid');
173
+ assert.equal(again.credited, 0);
174
+ assert.equal(state.credit_log.length, 1, 'still one row');
175
+ });
176
+
177
+ await test('one payment per ROUND: a payee that reads differently on a re-sweep is still refused', async () => {
178
+ const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
179
+ await ship(pool);
180
+ state.claims.push({ id: 9100, released_at: 'later', ...web(OTHER_ARTIST, 3) });
181
+ const again = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
182
+ assert.equal(again.reason, 'already_paid', 'keyed on (task, reason), not (builder, reason)');
183
+ assert.equal(state.credit_log.length, 1);
184
+ });
185
+
186
+ await test('a sent-back round is never paid: it sits at ready, its ship is refused, and the writer refuses too', async () => {
187
+ const { pool, state } = ledger({ status: 'ready', claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
188
+ await assert.rejects(() => ship(pool), (e) => e.code === 'TASK_NOT_CONFIRMED');
189
+ const direct = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
190
+ assert.equal(direct.reason, 'not_shipped', 'a round must read shipped in the transaction to be paid');
191
+ for (const s of ['completed', 'confirmed', 'active', 'abandoned']) {
192
+ state.task.status = s;
193
+ const r = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
194
+ assert.equal(r.reason, 'not_shipped', `${s} is not paid`);
195
+ }
196
+ assert.equal(state.credit_log.length, 0);
197
+ });
198
+
199
+ await test('sent back, re-applied by someone else, approved and landed: the artist is paid once, neither applier', async () => {
200
+ // web (artist writes) -> CLI (applier 1, sent back, released) -> CLI (applier 2)
201
+ const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2), cli(APPLIER_2, 3)] });
202
+ const out = await ship(pool);
203
+ assert.equal(String(out.creditedBuilderId), APPLIER_2, 'the landing applier is the ship-credited builder');
204
+ assert.deepEqual(state.credit_log.map((r) => [String(r.builder_id), r.delta]), [[ARTIST, 30]]);
205
+ });
206
+
207
+ await test('the payee is the LATEST web claim: an artist who resumed the draft after a send-back is the one paid', async () => {
208
+ const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2), web(OTHER_ARTIST, 3), cli(APPLIER, 4)] });
209
+ await ship(pool);
210
+ assert.deepEqual(state.credit_log.map((r) => String(r.builder_id)), [OTHER_ARTIST],
211
+ 'the TW05 reads name the same builder as the round\'s artist');
212
+ });
213
+
214
+ await test('a page tweak with no web claim pays nobody rather than guessing', async () => {
215
+ const { pool, state } = ledger({ claims: [cli(APPLIER, 2)] });
216
+ const out = await ship(pool);
217
+ assert.equal(out.artistCredit.reason, 'no_artist');
218
+ assert.equal(state.credit_log.length, 0, 'and the applier is not paid the estimate in its place');
219
+ });
220
+
221
+ await test('the writer reads the task itself: an ordinary task handed to it is refused', async () => {
222
+ const { pool, state } = ledger({ source: 'cascade', status: 'shipped', claims: [web(ARTIST, 1)] });
223
+ const r = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
224
+ assert.equal(r.reason, 'not_a_page_tweak');
225
+ const missing = await credits.awardArtistPageCredit(pool._client, { taskId: 1, config: credits.rewardPolicy({}) });
226
+ assert.equal(missing.reason, 'not_found');
227
+ assert.equal(state.credit_log.length, 0);
228
+ });
229
+
230
+ await test('the artist lane: cost-plus-only does not withhold it; only an explicit artistCredit:false does', async () => {
231
+ const p = credits.rewardPolicy({ mode: 'cost-plus-only' });
232
+ assert.equal(p.perTaskCredit, false);
233
+ assert.equal(p.artistCredit, true, 'a different craft\'s pay, not the builder estimate');
234
+ assert.equal(credits.rewardPolicy({}).artistCredit, true, 'absent -> on');
235
+ for (const v of [true, 1, 'yes', undefined, null]) assert.equal(credits.rewardPolicy({ artistCredit: v }).artistCredit, true);
236
+ assert.equal(credits.rewardPolicy({ artistCredit: false }).artistCredit, false, 'explicit opt-out');
237
+
238
+ const { pool, state } = ledger({ status: 'shipped', claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
239
+ const paid = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: p });
240
+ assert.equal(paid.credited, 30, 'paid under cost-plus-only');
241
+ const off = ledger({ status: 'shipped', claims: [web(ARTIST, 1)] });
242
+ const r = await credits.awardArtistPageCredit(off.pool._client, { taskId: 7001, config: credits.rewardPolicy({ artistCredit: false }) });
243
+ assert.equal(r.reason, 'artist_lane_off');
244
+ assert.equal(off.state.credit_log.length, 0);
245
+ assert.equal(state.credit_log.length, 1);
246
+ });
247
+
248
+ await test('the booked row is what the studio tally sums: same prefix, and it reads as 30 credits', async () => {
249
+ assert.equal(credits.ARTIST_PAGE_REASON_PREFIX, pageStatus.TALLY_REASON_PREFIX,
250
+ 'economy writes and copy-desk reads the same prefix (restated in each, since a module cannot require another)');
251
+ const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
252
+ await ship(pool);
253
+ const tally = pageStatus.composeTally({ builderId: ARTIST, tasks: [], creditRows: state.credit_log, now: new Date() });
254
+ assert.equal(tally.credits, 30);
255
+ assert.equal(tally.this_week, 30);
256
+ const applierTally = pageStatus.composeTally({ builderId: APPLIER, tasks: [], creditRows: state.credit_log.filter((r) => String(r.builder_id) === APPLIER), now: new Date() });
257
+ assert.equal(applierTally.credits, 0);
258
+ });
259
+
260
+ await test('the reward port exposes the writer, delegated so a stub bites at call time', async () => {
261
+ const port = require('../modules/economy/reward.js');
262
+ assert.equal(typeof port.awardArtistPageCredit, 'function');
263
+ const real = credits.awardArtistPageCredit;
264
+ credits.awardArtistPageCredit = async () => ({ credited: 0, reason: 'stubbed' });
265
+ try {
266
+ assert.equal((await port.awardArtistPageCredit({}, { taskId: 1 })).reason, 'stubbed');
267
+ } finally {
268
+ credits.awardArtistPageCredit = real;
269
+ }
270
+ });
271
+
272
+ summary();
@@ -216,36 +216,28 @@ test('docs/voice.md exists — the lens has a standard to judge against', () =>
216
216
  }
217
217
  });
218
218
 
219
- // ---- 7. THE DONE-WHEN CLAUSE THAT LIVES IN ANOTHER FILE ---------------------
219
+ // ---- 7. THE ARTIST REVIEW THIS LENS WAS PAIRED WITH IS GONE -----------------
220
+ //
221
+ // Task 1003977 turned STEP 1 of the per-ship artist review into "coach the
222
+ // reader", because this lens was listening. Tweak Mode removed that review
223
+ // (task 1004325 / BV2.TW14, ADR 0341 D9): a ship now files no artist review at
224
+ // all. The lens stays, scoped by the same predicate, which cascade.js still
225
+ // exports for exactly this consumer.
220
226
 
221
- test('the artist review now coaches the reader, because there is a lens listening', () => {
222
- // The task's own DONE WHEN: "Then STEP 1 of the artist review reverts to true
223
- // coaching — see the comment in modules/lifecycle/cascade.js, which says so
224
- // and asks not to be reverted early." The interim wording was explicitly a
225
- // signal for which world we are in, so shipping the lens without turning it
226
- // leaves the instruction to artists describing a panel that no longer exists.
227
- const { artistReviewBody } = require('../modules/lifecycle/cascade.js');
228
- const body = artistReviewBody({ id: '1234' });
229
- assert.match(body, /COACH THE READER/, 'STEP 1 asks for coaching again');
230
- assert.match(body, /docs\/voice\.md/, 'and names the standard to coach against');
231
- assert.ok(!/none of them reads a sentence/.test(body),
232
- 'the interim claim that no lens reads a sentence is gone — it is no longer true');
233
- // Still a request, never a gate (ADR 0241) — unchanged by this task.
234
- assert.match(body, /REQUEST, not a gate/);
227
+ test('the per-ship artist review is gone, and the lens keeps its scope predicate', () => {
228
+ const cascade = require('../modules/lifecycle/cascade.js');
229
+ assert.equal(cascade.artistReviewBody, undefined, 'no review body is left to coach from');
230
+ assert.equal(cascade.CASCADES.some((r) => r.key === 'artist-review-on-ship'), false);
231
+ assert.equal(cascade.hasCopyOrVisualWork, hasCopyOrVisualWork,
232
+ 'the lens is scoped by cascade.js\'s own export, not a copy of it');
235
233
  });
236
234
 
237
- test('the cascade and the copy lens cover the SAME set by construction', () => {
238
- // Both key off hasCopyOrVisualWork, so an artist review exists for exactly
239
- // the ships a reader ran on. If these ever diverge, an artist is asked to
240
- // coach a lens that never looked.
241
- const cascadeSrc = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'cascade.js'), 'utf8');
235
+ test('the copy lens is scoped by the files the work changed', () => {
242
236
  const shipSrc = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-grade.js'), 'utf8');
243
- assert.match(cascadeSrc, /hasCopyOrVisualWork/);
244
237
  assert.match(shipSrc, /hasCopyOrVisualWork/);
245
- // Task 1004201: BOTH consumers hand the predicate the files the work changed,
246
- // so an unlabelled UI ship gets the lens at grade time AND the review on ship.
238
+ // Task 1004201: the grader hands the predicate the files the work changed, so
239
+ // an unlabelled UI ship gets the lens at grade time.
247
240
  assert.match(shipSrc, /hasCopyOrVisualWork\(task, changedFiles\)/, 'the grader passes its own diff');
248
- assert.match(cascadeSrc, /hasCopyOrVisualWork\(t, \(ctx \|\| \{\}\)\.changedFiles\)/, 'the cascade passes the merged PR');
249
241
  const unlabelled = { module_key: null, discipline: 'engineer' };
250
242
  assert.equal(hasCopyOrVisualWork(unlabelled, ['modules/hall-ui/public/goal-forest-lib.js']), true);
251
243
  assert.equal(hasCopyOrVisualWork(unlabelled, ['modules/hall-ui/module.json', 'scripts/gds/claim.js']), false);
@@ -62,8 +62,11 @@ await test('the prefix does NOT match the other cascade rule', () => {
62
62
  // separating a removal question from an artist review. If it ever widened, a
63
63
  // Confirm button would appear on a copy review and answering it would record a
64
64
  // removal verdict about work nobody proposed removing.
65
- const artist = cascade.CASCADES.find((r) => r.key === 'artist-review-on-ship');
66
- const artistRef = cascade.cascadeSourceRef(artist.key, SHIP);
65
+ //
66
+ // The artist rule itself was removed by Tweak Mode (TW14, ADR 0341 D9), but the
67
+ // reviews it filed are still rows on the board under its key, so the key is
68
+ // spelled here rather than read from CASCADES.
69
+ const artistRef = cascade.cascadeSourceRef('artist-review-on-ship', SHIP);
67
70
  assert.ok(!artistRef.startsWith(proposals.PROPOSAL_SOURCE_REF_PREFIX));
68
71
  assert.equal(proposals.shipTaskIdFromSourceRef(artistRef), null);
69
72
  });
@@ -0,0 +1,170 @@
1
+ // tests/tweak_replaces_artist_review.mjs — Tweak Mode replaces the per-ship
2
+ // artist review, the WHOLE path (task 1004325 / BV2.TW14, ADR 0341 D8 and D9).
3
+ //
4
+ // The done-when: "a ship changing a page's text files no artist review and the
5
+ // page reads changed-since". Two halves, and they meet at one ship:
6
+ //
7
+ // 1. THE SHIP FILES NOTHING. The real cascade runner, with the real
8
+ // production table, the ship's real changed files and the artist gate at
9
+ // 'strict' (the level that used to file AND hold), creates no task.
10
+ // 2. THE PAGE READS CHANGED-SINCE ANYWAY, with no write. The real copy-desk
11
+ // route over the real lifecycle port's SQL (a fake pool), reading the page
12
+ // artifacts from disk: before the ship the page matches its last round;
13
+ // after it, the page's reading has moved, drift names the changed line, and
14
+ // the ship itself is listed as what changed it, found by its touches.
15
+ //
16
+ // "No write" is checked twice: the cascade's createTask is never called, and
17
+ // the fake pool refuses any SQL that is not one of the two reads, so an INSERT
18
+ // or UPDATE anywhere on the read path reds here.
19
+
20
+ import { strict as assert } from 'node:assert';
21
+ import { createRequire } from 'node:module';
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { makeRunner } from './helpers.mjs';
26
+
27
+ process.env.NODE_ENV = 'test';
28
+ const require = createRequire(import.meta.url);
29
+
30
+ const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
31
+ const cascade = require('../modules/lifecycle/cascade.js');
32
+ const gate = require('../src/bongos/artist-gate.js');
33
+ const api = require('../src/module-api.js');
34
+
35
+ // THE SHIP. An engineer rewords the studio greeting ("Welcome home" becomes
36
+ // "Welcome back") after the page's first round shipped. Its touches meet the
37
+ // studio page's files, and it is copy/visual work by every measure the old rule
38
+ // used: a hall-ui module ship that changed a page file.
39
+ const SHIP = {
40
+ id: '555', title: 'Reword the studio greeting', status: 'shipped',
41
+ discipline: 'engineer', kind: 'feature', source: 'manual', module_key: 'hall-ui',
42
+ version_id: 'BONGOS-V2', goal_id: '1000095',
43
+ touches: ['modules/hall-ui/public/studio.html'],
44
+ shipped_at: new Date('2026-09-22T09:00:00Z'),
45
+ };
46
+
47
+ // Every SQL the page read sends, so the test can say none of it wrote.
48
+ const seen = [];
49
+ const flat = (sql) => String(sql).replace(/\s+/g, ' ').trim();
50
+ api.pool = {
51
+ async query(sql, params = []) {
52
+ const q = flat(sql);
53
+ seen.push({ q, params });
54
+ if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) return { rows: F.TASKS };
55
+ if (/t\.touches && \$1::text\[\]/.test(q)) {
56
+ // The real query's predicate, applied to the one ship: shipped after the
57
+ // page's last round, touching one of the page's files.
58
+ const files = new Set(params[0]);
59
+ const hit = SHIP.shipped_at > new Date(params[1]) && SHIP.touches.some((t) => files.has(t));
60
+ return { rows: hit ? [{ id: SHIP.id, title: SHIP.title, shipped_at: SHIP.shipped_at }] : [] };
61
+ }
62
+ throw new Error(`unexpected SQL on the page read: ${q.slice(0, 120)}`);
63
+ },
64
+ };
65
+ api.requireBuilder = (req, res, next) => { req.builder = { id: F.ALICE.id, rank: 'thetes' }; next(); };
66
+
67
+ const seams = require('../src/module-seams.js');
68
+ if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', require('../modules/lifecycle/lifecycle.js'));
69
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
70
+
71
+ const pageData = require('../modules/copy-desk/page-data.js');
72
+ const express = require('express');
73
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
74
+ const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
75
+
76
+ const app = express();
77
+ app.use(attachFail);
78
+ app.use(buildRouter());
79
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
80
+ const base = `http://127.0.0.1:${server.address().port}`;
81
+ async function get(p) {
82
+ const r = await fetch(base + p);
83
+ return { status: r.status, body: await r.json() };
84
+ }
85
+
86
+ // The studio's reading BEFORE the ship says exactly what its first round left
87
+ // ("Welcome home", "Resume"); AFTER it is the fixture's (the greeting moved).
88
+ // The shared shell line differs on both sides, and drift must ignore it.
89
+ const studioAfter = F.READINGS.pages.find((p) => p.id === 'builders:studio');
90
+ const studioBefore = {
91
+ ...studioAfter,
92
+ reading_hash: 'studio-before',
93
+ lines: studioAfter.lines.map((l) => (l.key === 'L0002' ? { ...l, text: 'Welcome home' } : l)),
94
+ };
95
+ const readingsWith = (studio) => ({ ...F.READINGS, pages: F.READINGS.pages.map((p) => (p.id === studio.id ? studio : p)) });
96
+
97
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tweak-replaces-review-'));
98
+ const root = (name, readings) => {
99
+ const dir = path.join(tmp, name);
100
+ fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
101
+ fs.writeFileSync(path.join(dir, 'docs', 'page-inventory.json'), JSON.stringify(F.INVENTORY));
102
+ fs.writeFileSync(path.join(dir, 'docs', 'page-readings.json'), JSON.stringify(readings));
103
+ return dir;
104
+ };
105
+ const BEFORE = root('before', readingsWith(studioBefore));
106
+ const AFTER = root('after', readingsWith(studioAfter));
107
+
108
+ const { test, summary } = makeRunner();
109
+
110
+ try {
111
+ await test('before the ship, the studio matches its last round: tweaked, no drift', async () => {
112
+ pageData._setRoot(BEFORE);
113
+ const { status, body } = await get('/copy-desk/pages/builders:studio');
114
+ assert.equal(status, 200);
115
+ assert.equal(body.status_label, 'Text tweaked 1x');
116
+ assert.equal(body.drift.changed, false);
117
+ });
118
+
119
+ await test('the ship that rewords the page files NO artist review, at the strictest gate', async () => {
120
+ const created = [];
121
+ const report = await cascade.runCascadesForShip({ id: SHIP.id }, {
122
+ getTask: async () => SHIP,
123
+ findTaskBySourceRef: async () => null,
124
+ createTask: async (spec) => { created.push(spec); return { id: '9999' }; },
125
+ artistGateMode: () => 'strict',
126
+ changedFiles: async () => ({ paths: SHIP.touches, truncated: false }),
127
+ removalCandidates: async () => ({ removals: [] }),
128
+ });
129
+ assert.deepEqual(report.errors, []);
130
+ assert.deepEqual(created, [], 'nothing is filed: no review task, no marker, nothing');
131
+ assert.equal(created.some((s) => gate.isArtistReview({ source: s.source, source_ref: s.sourceRef })), false);
132
+ // It is still copy/visual work: the grader's reader lens still runs on it.
133
+ assert.equal(cascade.hasCopyOrVisualWork(SHIP, SHIP.touches), true);
134
+ });
135
+
136
+ await test('after the ship, the page reads changed-since, and names the ship, with no write', async () => {
137
+ pageData._setRoot(AFTER);
138
+ seen.length = 0;
139
+ const { status, body } = await get('/copy-desk/pages/builders:studio');
140
+ assert.equal(status, 200);
141
+ assert.equal(body.drift.known, true);
142
+ assert.equal(body.drift.changed, true, 'the page reads "changed since last tweak"');
143
+ assert.deepEqual(body.drift.changed_lines, [{ key: 'L0002', section: 'greeting', text: 'Welcome back' }]);
144
+ assert.deepEqual(body.drift.removed_lines, ['Welcome home']);
145
+ assert.deepEqual(body.drift.ships.map((s) => s.task_id), [SHIP.id], 'the ship is what changed it');
146
+ assert.equal(body.status_label, 'Text tweaked 1x', 'drift never lowers the count');
147
+ // Derived, never stored: only the two reads ran.
148
+ assert.ok(seen.length > 0);
149
+ for (const s of seen) assert.match(s.q, /^(WITH .*)?SELECT /, `a read, never a write: ${s.q.slice(0, 80)}`);
150
+ });
151
+
152
+ await test('the page list carries the same drift, which is what the recommendation ranks on', async () => {
153
+ pageData._setRoot(AFTER);
154
+ const list = await get('/copy-desk/pages');
155
+ const studio = list.body.pages.find((p) => p.id === 'builders:studio');
156
+ assert.equal(studio.drift.changed, true);
157
+ assert.equal(studio.drift.changed_count, 1);
158
+ });
159
+ } finally {
160
+ pageData._setRoot(null);
161
+ server.closeAllConnections();
162
+ await new Promise((resolve) => server.close(resolve));
163
+ fs.rmSync(tmp, { recursive: true, force: true });
164
+ }
165
+
166
+ // Let the closed sockets finish closing before summary() calls process.exit: on
167
+ // Windows an exit while a handle is mid-close trips a libuv assertion (exit 127)
168
+ // after every case has passed.
169
+ await new Promise((resolve) => setTimeout(resolve, 100));
170
+ summary();