@bongos/core 1.21.97 → 1.21.98
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 +39 -29
- package/docs/adr/0152-metic-task-abandonment.md +2 -0
- package/docs/api/openapi.json +2 -2
- package/docs/architecture.md +3 -0
- package/docs/copy-inventory.md +32 -30
- package/docs/copy-registry.json +51 -33
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +10 -10
- package/migrations/core_274_parked_backlog_stays_parked.sql +140 -0
- package/modules/hall-ui/public/studio-lib.js +22 -0
- package/modules/hall-ui/public/studio.css +138 -7
- package/modules/hall-ui/public/studio.html +10 -4
- package/modules/hall-ui/public/studio.js +39 -1
- package/modules/hall-ui/records/studio.md +16 -0
- package/modules/lifecycle/db-ship.js +7 -2
- package/modules/lifecycle/db-tasks.js +25 -0
- package/modules/lifecycle/routes/tasks.js +5 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/src/module-api.js +1 -1
- package/tests/abandon_task.mjs +1 -1
- package/tests/hall_studio_home.mjs +140 -5
- package/tests/parked_backlog_stays_parked.mjs +182 -0
- package/tests/promotion_triggers_skip_cascade.mjs +5 -3
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
// Then the REAL page: studio.html's own body in a mini-DOM, with the real
|
|
8
8
|
// dom-utils.js, tweak-editor-lib.js, studio-lib.js and studio.js in a vm over a
|
|
9
9
|
// stub client, driven the way an artist would: the tally, Resume, the three
|
|
10
|
-
// panels, "take it", and the studio's own words typed over in place.
|
|
10
|
+
// panels, "take it", and the studio's own words typed over in place. And the
|
|
11
|
+
// room's sky (task 1004654): the day room from 07:00 to 18:59 on the viewer's
|
|
12
|
+
// clock, re-checked each minute, and a greeting for the time of day that an
|
|
13
|
+
// artist's own words always beat.
|
|
11
14
|
|
|
12
15
|
import { strict as assert } from 'node:assert';
|
|
13
16
|
import { createRequire } from 'node:module';
|
|
@@ -112,9 +115,23 @@ function augment(el) {
|
|
|
112
115
|
return el;
|
|
113
116
|
}
|
|
114
117
|
|
|
115
|
-
|
|
118
|
+
// The viewer's clock, in LOCAL time (the rule reads getHours), held still so
|
|
119
|
+
// a test can name the hour. new Date() answers the held time; new Date(x) is
|
|
120
|
+
// the real constructor, so the composers' ISO dates still parse.
|
|
121
|
+
function heldClock(h, m) {
|
|
122
|
+
const Real = Date;
|
|
123
|
+
let t = new Real(2026, 9, 9, h, m, 0).getTime();
|
|
124
|
+
class Held extends Real {
|
|
125
|
+
constructor(...a) { if (a.length) super(...a); else super(t); }
|
|
126
|
+
static now() { return t; }
|
|
127
|
+
}
|
|
128
|
+
Held.set = (hh, mm) => { t = new Real(2026, 9, 9, hh, mm, 0).getTime(); };
|
|
129
|
+
return Held;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function bootStudio({ answers = {}, signedIn = true, at = null, body = BODY } = {}) {
|
|
116
133
|
const root = augment(makeEl('body', {}));
|
|
117
|
-
root.innerHTML =
|
|
134
|
+
root.innerHTML = body;
|
|
118
135
|
const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
|
|
119
136
|
const docListeners = {};
|
|
120
137
|
const requests = [];
|
|
@@ -154,12 +171,20 @@ function bootStudio({ answers = {}, signedIn = true } = {}) {
|
|
|
154
171
|
addEventListener() {},
|
|
155
172
|
scrollTo() {},
|
|
156
173
|
};
|
|
157
|
-
|
|
174
|
+
// setInterval is held, never real (a real one would keep the run alive): the
|
|
175
|
+
// test fires each registered tick by hand, as a minute passing.
|
|
176
|
+
const intervals = [];
|
|
177
|
+
const fakeSetInterval = (fn, ms) => { intervals.push({ fn, ms }); return intervals.length; };
|
|
178
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, setInterval: fakeSetInterval, encodeURIComponent };
|
|
179
|
+
const clock = at ? heldClock(at[0], at[1]) : null;
|
|
180
|
+
if (clock) sandbox.Date = clock;
|
|
158
181
|
sandbox.globalThis = sandbox;
|
|
159
182
|
vm.createContext(sandbox);
|
|
160
183
|
for (const [name, src] of SOURCES) vm.runInContext(src, sandbox, { filename: name });
|
|
161
184
|
const out = {
|
|
162
|
-
requests, navigated: null, focused: null, el: byId, html: htmlEl,
|
|
185
|
+
requests, navigated: null, focused: null, el: byId, html: htmlEl, body: root, intervals,
|
|
186
|
+
// Move the held clock and let a minute pass: every registered tick fires.
|
|
187
|
+
tickTo: (h, m) => { clock.set(h, m); for (const i of intervals) i.fn(); },
|
|
163
188
|
writes: () => requests.filter((r) => r.method !== 'GET'),
|
|
164
189
|
click: async (id) => { const el = byId(id); assert.ok(el, `no #${id}`); el.fire('click', { target: el }); await out.settle(); },
|
|
165
190
|
clickIn: async (overlayId, sel) => {
|
|
@@ -404,6 +429,116 @@ try {
|
|
|
404
429
|
assert.match(s.el('st-offer-err').textContent, /Only an artist can take a page/);
|
|
405
430
|
});
|
|
406
431
|
|
|
432
|
+
// ── the room's sky and the greeting (task 1004654) ────────────────────────
|
|
433
|
+
const at = (h, m) => new Date(2026, 9, 9, h, m, 0);
|
|
434
|
+
|
|
435
|
+
await test('the sky: day from 07:00 to 18:59 on the viewer\'s clock, night either side of it', () => {
|
|
436
|
+
assert.equal(lib.skyAt(at(6, 59)), 'night');
|
|
437
|
+
assert.equal(lib.skyAt(at(7, 0)), 'day');
|
|
438
|
+
assert.equal(lib.skyAt(at(12, 0)), 'day');
|
|
439
|
+
assert.equal(lib.skyAt(at(18, 59)), 'day');
|
|
440
|
+
assert.equal(lib.skyAt(at(19, 0)), 'night');
|
|
441
|
+
assert.equal(lib.skyAt(at(0, 0)), 'night');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
await test('the greeting\'s time of day: morning before noon by day, afternoon after, evening at night', () => {
|
|
445
|
+
assert.equal(lib.dayPart(at(6, 59)), 'evening', 'before seven the room is still at night');
|
|
446
|
+
assert.equal(lib.dayPart(at(7, 0)), 'morning');
|
|
447
|
+
assert.equal(lib.dayPart(at(11, 59)), 'morning');
|
|
448
|
+
assert.equal(lib.dayPart(at(12, 0)), 'afternoon');
|
|
449
|
+
assert.equal(lib.dayPart(at(18, 59)), 'afternoon');
|
|
450
|
+
assert.equal(lib.dayPart(at(19, 0)), 'evening');
|
|
451
|
+
assert.equal(lib.clockGreets('Good evening.'), true);
|
|
452
|
+
assert.equal(lib.clockGreets(' Good evening. '), true, 'the served markup\'s whitespace is not a tweak');
|
|
453
|
+
assert.equal(lib.clockGreets('Hello, you.'), false, 'an artist\'s words are not the stock greeting');
|
|
454
|
+
assert.match(HTML, new RegExp('id="studio-greet"[^>]*>' + lib.STOCK_GREETING.replace('.', '\\.') + '<'), 'the page is served with the stock greeting');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
await test('the page: the body wears the sky and the greeting follows the clock at each boundary hour', async () => {
|
|
458
|
+
for (const [h, m, sky, words] of [
|
|
459
|
+
[6, 59, 'night', 'Good evening.'],
|
|
460
|
+
[7, 0, 'day', 'Good morning.'],
|
|
461
|
+
[11, 59, 'day', 'Good morning.'],
|
|
462
|
+
[12, 0, 'day', 'Good afternoon.'],
|
|
463
|
+
[18, 59, 'day', 'Good afternoon.'],
|
|
464
|
+
[19, 0, 'night', 'Good evening.'],
|
|
465
|
+
]) {
|
|
466
|
+
const s = bootStudio({ answers: HOME, at: [h, m] });
|
|
467
|
+
await s.settle();
|
|
468
|
+
const hhmm = `${h}:${String(m).padStart(2, '0')}`;
|
|
469
|
+
assert.equal(s.body.getAttribute('data-sky'), sky, `the sky at ${hhmm}`);
|
|
470
|
+
assert.equal(s.el('studio-greet').textContent, words, `the greeting at ${hhmm}`);
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
await test('re-checked each minute: the room turns to day at 07:00 and back to night at 19:00', async () => {
|
|
475
|
+
const s = bootStudio({ answers: HOME, at: [6, 59] });
|
|
476
|
+
await s.settle();
|
|
477
|
+
assert.deepEqual(s.intervals.map((i) => i.ms), [60000], 'one tick, once a minute');
|
|
478
|
+
assert.equal(s.body.getAttribute('data-sky'), 'night');
|
|
479
|
+
s.tickTo(7, 0);
|
|
480
|
+
assert.equal(s.body.getAttribute('data-sky'), 'day');
|
|
481
|
+
assert.equal(s.el('studio-greet').textContent, 'Good morning.');
|
|
482
|
+
s.tickTo(12, 0);
|
|
483
|
+
assert.equal(s.el('studio-greet').textContent, 'Good afternoon.');
|
|
484
|
+
s.tickTo(19, 0);
|
|
485
|
+
assert.equal(s.body.getAttribute('data-sky'), 'night');
|
|
486
|
+
assert.equal(s.el('studio-greet').textContent, 'Good evening.', 'at night the page\'s own greeting is back');
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await test('an artist\'s rewritten greeting wins at every hour; the sky still follows the clock', async () => {
|
|
490
|
+
const tweaked = BODY.replace(/(id="studio-greet"[^>]*>)Good evening\.</, '$1Back at the desk.<');
|
|
491
|
+
assert.notEqual(tweaked, BODY, 'the fixture rewrote the greeting');
|
|
492
|
+
const s = bootStudio({ answers: HOME, body: tweaked, at: [8, 0] });
|
|
493
|
+
await s.settle();
|
|
494
|
+
assert.equal(s.body.getAttribute('data-sky'), 'day');
|
|
495
|
+
assert.equal(s.el('studio-greet').textContent, 'Back at the desk.');
|
|
496
|
+
for (const [h, m] of [[12, 0], [19, 0], [7, 0]]) {
|
|
497
|
+
s.tickTo(h, m);
|
|
498
|
+
assert.equal(s.el('studio-greet').textContent, 'Back at the desk.', `their words at ${h}:${m}`);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
await test('typing over the greeting in the morning: it opens as the page\'s own line, and the clock never types over it', async () => {
|
|
503
|
+
const s = bootStudio({ answers: { ...HOME, 'GET /copy-desk/pages/builders:studio/draft': ok(studioRead({ held: true })) }, at: [9, 0] });
|
|
504
|
+
await s.settle();
|
|
505
|
+
const greet = s.el('studio-greet');
|
|
506
|
+
assert.equal(greet.textContent, 'Good morning.');
|
|
507
|
+
await s.click('studio-greet');
|
|
508
|
+
assert.equal(greet.getAttribute('contenteditable'), 'true', 'matched to the greeting line, not left unread');
|
|
509
|
+
assert.equal(greet.getAttribute('data-unread'), null);
|
|
510
|
+
greet.textContent = 'Morning, you.';
|
|
511
|
+
s.tickTo(13, 0);
|
|
512
|
+
assert.equal(greet.textContent, 'Morning, you.', 'a minute passing never overwrites the words being typed');
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
await test('the sheet: the day tokens sit beside the night ones, the day pieces, and motion only without a reduced-motion preference', () => {
|
|
516
|
+
const CSS = read('studio.css');
|
|
517
|
+
const dayAt = CSS.indexOf('body[data-page="studio"][data-sky="day"] {');
|
|
518
|
+
const nightAt = CSS.indexOf('body[data-page="studio"] {');
|
|
519
|
+
assert.ok(nightAt >= 0 && dayAt > nightAt, 'a day block after the night tokens');
|
|
520
|
+
assert.ok(CSS.slice(nightAt, dayAt).split('\n}').length === 2, 'directly after them: nothing between the two blocks but the day block\'s note');
|
|
521
|
+
const dayBlock = CSS.slice(dayAt, CSS.indexOf('}', dayAt));
|
|
522
|
+
// the mockup's [data-sky="day"] values (docs/design/mocks/day-night-rooms/index.html)
|
|
523
|
+
for (const [tok, v] of [['--st-room', '#efe7dc'], ['--st-frame', '#d6c9b7'], ['--st-night', '#bfdaf0'], ['--st-moon', '#ffd46e'],
|
|
524
|
+
['--st-tower-1', '#9db2c7'], ['--st-tower-2', '#8ea4bb'], ['--st-tower-3', '#a8bccf'], ['--st-pane', '#e9f1f8'],
|
|
525
|
+
['--st-floor', '#e2d5c3'], ['--st-ink', '#1b1a1d'], ['--st-glass', 'rgba(255, 255, 255, .52)']]) {
|
|
526
|
+
assert.ok(dayBlock.includes(`${tok}: ${v};`), `day ${tok} is the mockup's ${v}`);
|
|
527
|
+
}
|
|
528
|
+
assert.match(CSS, /\.st-light \{[^}]*background: var\(--st-pane\)/, 'the panes are lit by night and unlit by day through one token');
|
|
529
|
+
assert.match(CSS, /body\[data-sky="day"\] \.st-cloud \{ opacity: \.9; \}/);
|
|
530
|
+
assert.match(CSS, /body\[data-sky="day"\] \.st-sunpatch \{ opacity: \.7; \}/);
|
|
531
|
+
assert.match(CSS, /body\[data-sky="day"\] \.st-star,\s*body\[data-sky="day"\] \.st-glow \{ opacity: 0; \}/, 'no stars, and the lamp is off');
|
|
532
|
+
assert.match(HTML, /class="st-cloud st-cloud--1"/);
|
|
533
|
+
assert.match(HTML, /class="st-sunpatch"/);
|
|
534
|
+
const anims = [...CSS.matchAll(/animation:\s*[^;]+;|transition:\s*[^;]+;/g)].map((m) => m.index);
|
|
535
|
+
const calm = CSS.indexOf('@media (prefers-reduced-motion: no-preference) {');
|
|
536
|
+
const calmEnd = CSS.indexOf('\n}', calm);
|
|
537
|
+
assert.ok(anims.length > 0 && anims.every((i) => i > calm && i < calmEnd), 'every animation and transition sits inside the no-preference block');
|
|
538
|
+
assert.match(CSS, /body\[data-page="studio"\]\[data-sky="day"\] \.st-overlay \{\s*--st-scrim:/, 'the panels follow the sky');
|
|
539
|
+
assert.match(CSS, /body\[data-page="studio"\]\[data-sky="day"\] \.aq-overlay \{/, 'the Approval queue follows the sky');
|
|
540
|
+
});
|
|
541
|
+
|
|
407
542
|
await test('the older reviews stay one quiet door away while any still wait', async () => {
|
|
408
543
|
const REVIEW = { id: '1900', title: 'Artist review: the copy and visuals that shipped in task 1899', status: 'backlog', source: 'cascade', source_ref: 'cascade-artist-review-on-ship-1899', created_at: day(20) };
|
|
409
544
|
const s = bootStudio({ answers: { ...HOME, 'GET /tasks?discipline=artist&status=backlog&limit=50': ok({ tasks: [REVIEW] }) } });
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// tests/parked_backlog_stays_parked.mjs
|
|
2
|
+
//
|
|
3
|
+
// A task a PERSON parked in backlog stays there until a person promotes it
|
|
4
|
+
// (task 1004592, migration core_274).
|
|
5
|
+
//
|
|
6
|
+
// What happened: restoring abandoned task 1003397 put it in backlog as ADR 0152
|
|
7
|
+
// says ("comes back un-workable and needs a second, explicit promote"), and the
|
|
8
|
+
// promotion sweep promoted it on the next reconciler tick. Demoting it again
|
|
9
|
+
// was undone one tick later the same way. The sweep could not tell "waiting on
|
|
10
|
+
// a dependency" from "parked on purpose", because nothing in the row said so.
|
|
11
|
+
//
|
|
12
|
+
// HOW THIS PROVES IT. The DB-free unit gate has no Postgres, so the fake below
|
|
13
|
+
// holds task rows in memory and answers each statement the way the database
|
|
14
|
+
// would GIVEN WHAT THE STATEMENT ACTUALLY SAYS: a restore or demote parks a row
|
|
15
|
+
// only if its UPDATE writes parked_at, and the sweep withholds a parked row
|
|
16
|
+
// only if its SQL carries `parked_at IS NULL`. Drop either half and the
|
|
17
|
+
// "still backlog" cases go red. The real restoreTask, parkTask and
|
|
18
|
+
// promoteUnblockedBacklogTasks run against it; nothing is re-typed here.
|
|
19
|
+
//
|
|
20
|
+
// The trigger half (the three promotion functions and the un-park trigger) is
|
|
21
|
+
// checked on the migration's SQL, comments stripped, as
|
|
22
|
+
// tests/promotion_triggers_skip_cascade.mjs does for core_250. That it applied
|
|
23
|
+
// is not proved here; it needs Postgres.
|
|
24
|
+
//
|
|
25
|
+
// Run: node tests/parked_backlog_stays_parked.mjs
|
|
26
|
+
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
30
|
+
import { makeRunner } from './helpers.mjs';
|
|
31
|
+
|
|
32
|
+
process.env.NODE_ENV = 'test';
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const dbShip = require('../modules/lifecycle/db-ship.js');
|
|
35
|
+
const dbTasks = require('../modules/lifecycle/db-tasks.js');
|
|
36
|
+
const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n');
|
|
37
|
+
|
|
38
|
+
const { test, summary } = makeRunner();
|
|
39
|
+
|
|
40
|
+
const PERSON = 42;
|
|
41
|
+
|
|
42
|
+
// One in-memory tasks table. Every row has no dependencies and no blocker, so
|
|
43
|
+
// the sweep's other guards pass it: the parked clause is the only thing that
|
|
44
|
+
// can hold a row back.
|
|
45
|
+
function fakeDb(rows) {
|
|
46
|
+
const tasks = new Map(rows.map((r) => [r.id, { parked_at: null, parked_by: null, ...r }]));
|
|
47
|
+
const flat = (sql) => String(sql).replace(/\s+/g, ' ');
|
|
48
|
+
// core_274's BEFORE UPDATE trigger: leaving backlog un-parks.
|
|
49
|
+
const setStatus = (row, status) => {
|
|
50
|
+
if (status !== 'backlog') { row.parked_at = null; row.parked_by = null; }
|
|
51
|
+
row.status = status;
|
|
52
|
+
};
|
|
53
|
+
const sweepAdmits = (sql, row) => row.status === 'backlog'
|
|
54
|
+
&& !(/parked_at IS NULL/.test(sql) && row.parked_at);
|
|
55
|
+
async function query(sqlIn, params = []) {
|
|
56
|
+
const sql = flat(sqlIn);
|
|
57
|
+
if (/^(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return { rows: [] };
|
|
58
|
+
if (/^SELECT id, title, status FROM tasks WHERE id = \$1 FOR UPDATE/.test(sql)) {
|
|
59
|
+
const row = tasks.get(params[0]);
|
|
60
|
+
return { rows: row ? [{ ...row }] : [] };
|
|
61
|
+
}
|
|
62
|
+
if (/^SELECT t\.id, t\.title, t\.goal_id FROM tasks t/.test(sql)) {
|
|
63
|
+
return { rows: [...tasks.values()].filter((r) => sweepAdmits(sql, r)).map(({ id, title, goal_id }) => ({ id, title, goal_id })) };
|
|
64
|
+
}
|
|
65
|
+
if (/^UPDATE tasks t SET status = 'ready'/.test(sql)) {
|
|
66
|
+
const ids = params[0];
|
|
67
|
+
const hit = [...tasks.values()].filter((r) => ids.includes(r.id) && sweepAdmits(sql, r));
|
|
68
|
+
hit.forEach((r) => setStatus(r, 'ready'));
|
|
69
|
+
return { rows: hit.map(({ id, title, goal_id }) => ({ id, title, goal_id })) };
|
|
70
|
+
}
|
|
71
|
+
if (/^UPDATE tasks SET status = 'backlog'/.test(sql)) {
|
|
72
|
+
const row = tasks.get(params[0]);
|
|
73
|
+
const guard = /AND status = 'ready'/.test(sql) ? row && row.status === 'ready' : !!row;
|
|
74
|
+
if (!guard) return { rows: [], rowCount: 0 };
|
|
75
|
+
setStatus(row, 'backlog');
|
|
76
|
+
if (/parked_at = now\(\)/.test(sql)) { row.parked_at = new Date(); row.parked_by = params[1] ?? null; }
|
|
77
|
+
return { rows: [{ id: row.id, title: row.title, status: row.status }], rowCount: 1 };
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`unexpected SQL in fake: ${sql.slice(0, 80)}`);
|
|
80
|
+
}
|
|
81
|
+
const client = { query, release() {} };
|
|
82
|
+
return { tasks, query, connect: async () => client };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sweep = (pool) => dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
|
|
86
|
+
|
|
87
|
+
await test('control: an ordinary backlog row with nothing pending IS promoted', async () => {
|
|
88
|
+
// Without this the "still backlog" cases below could pass on a sweep that
|
|
89
|
+
// promotes nothing at all.
|
|
90
|
+
const pool = fakeDb([{ id: 1, title: 'waiting on a trigger', status: 'backlog', goal_id: 7 }]);
|
|
91
|
+
const out = await sweep(pool);
|
|
92
|
+
assert.deepEqual(out.promoted.map((r) => r.id), [1]);
|
|
93
|
+
assert.equal(pool.tasks.get(1).status, 'ready');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test('restore, then the armed sweep: the task is still backlog', async () => {
|
|
97
|
+
const pool = fakeDb([{ id: 2, title: 'retired by mistake', status: 'abandoned', goal_id: 7 }]);
|
|
98
|
+
await dbShip.restoreTask({ taskId: 2, parkedBy: PERSON }, { pool });
|
|
99
|
+
assert.equal(pool.tasks.get(2).status, 'backlog');
|
|
100
|
+
assert.ok(pool.tasks.get(2).parked_at, 'restore must park the task');
|
|
101
|
+
assert.equal(pool.tasks.get(2).parked_by, PERSON, 'and record who parked it');
|
|
102
|
+
const out = await sweep(pool);
|
|
103
|
+
assert.deepEqual(out.candidates, [], 'a restored task is not a promotion candidate');
|
|
104
|
+
assert.equal(pool.tasks.get(2).status, 'backlog',
|
|
105
|
+
'the sweep promoted a restored task: ADR 0152\'s second, explicit promote was skipped');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await test('demote, then the armed sweep: the task is still backlog', async () => {
|
|
109
|
+
const pool = fakeDb([{ id: 3, title: 'taken back off the board', status: 'ready', goal_id: 7 }]);
|
|
110
|
+
assert.equal(await dbTasks.parkTask(3, PERSON, pool), true, 'a ready task demotes');
|
|
111
|
+
assert.equal(pool.tasks.get(3).status, 'backlog');
|
|
112
|
+
assert.equal(pool.tasks.get(3).parked_by, PERSON);
|
|
113
|
+
await sweep(pool);
|
|
114
|
+
assert.equal(pool.tasks.get(3).status, 'backlog',
|
|
115
|
+
'the sweep re-promoted a demoted task one tick later (task 1003397, 2026-10-06)');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await test('demote refuses a task that is no longer ready, and writes nothing', async () => {
|
|
119
|
+
const pool = fakeDb([{ id: 4, title: 'claimed meanwhile', status: 'active', goal_id: 7 }]);
|
|
120
|
+
assert.equal(await dbTasks.parkTask(4, PERSON, pool), false);
|
|
121
|
+
assert.equal(pool.tasks.get(4).status, 'active');
|
|
122
|
+
assert.equal(pool.tasks.get(4).parked_at, null);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await test('the sweep carries the parked clause in BOTH the read and the write', async () => {
|
|
126
|
+
const pool = fakeDb([{ id: 6, title: 'x', status: 'backlog', goal_id: 7 }]);
|
|
127
|
+
const seen = [];
|
|
128
|
+
const spy = { query: async (sql, p) => { seen.push(String(sql)); return pool.query(sql, p); } };
|
|
129
|
+
await sweep(spy);
|
|
130
|
+
assert.match(seen.find((s) => /SELECT/.test(s)), /t\.parked_at IS NULL/);
|
|
131
|
+
assert.match(seen.find((s) => /^\s*UPDATE/.test(s)), /t\.parked_at IS NULL/,
|
|
132
|
+
'the UPDATE re-checks the guard rather than trusting the ids it read');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---------- the routes hand the person in ----------
|
|
136
|
+
|
|
137
|
+
const ROUTES = read('modules/lifecycle/routes/tasks.js');
|
|
138
|
+
const slice = (from, to) => ROUTES.slice(ROUTES.indexOf(from), ROUTES.indexOf(to, ROUTES.indexOf(from)));
|
|
139
|
+
|
|
140
|
+
await test('the restore branch of /promote parks with the caller', () => {
|
|
141
|
+
const promote = slice("router.post('/tasks/:id/promote'", "router.post('/tasks/:id/demote'");
|
|
142
|
+
assert.match(promote, /restoreTask\(\{ taskId: id, parkedBy: req\.builder\.id \}\)/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await test('/demote parks with the caller instead of a bare status flip', () => {
|
|
146
|
+
const demote = slice("router.post('/tasks/:id/demote'", '// Phase 5 lifecycle transitions');
|
|
147
|
+
assert.match(demote, /db\.parkTask\(id, req\.builder\.id\)/);
|
|
148
|
+
assert.doesNotMatch(demote, /updateTaskStatus\(id, 'backlog'\)/,
|
|
149
|
+
'a bare flip leaves no marker and the sweep undoes it');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ---------- core_274: the triggers ----------
|
|
153
|
+
|
|
154
|
+
const MIG = read('migrations/core_274_parked_backlog_stays_parked.sql')
|
|
155
|
+
.replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
|
|
156
|
+
const fnBody = (name) => {
|
|
157
|
+
const start = MIG.indexOf(`CREATE OR REPLACE FUNCTION ${name}()`);
|
|
158
|
+
assert.notEqual(start, -1, `core_274 does not define ${name}`);
|
|
159
|
+
const rest = MIG.slice(start + 1);
|
|
160
|
+
const next = rest.indexOf('CREATE OR REPLACE FUNCTION');
|
|
161
|
+
return next === -1 ? rest : rest.slice(0, next);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
await test('core_274 adds the two nullable marker columns, forward-safe', () => {
|
|
165
|
+
assert.match(MIG, /ADD COLUMN IF NOT EXISTS parked_at timestamptz;/);
|
|
166
|
+
assert.match(MIG, /ADD COLUMN IF NOT EXISTS parked_by bigint REFERENCES builders\(id\) ON DELETE SET NULL;/);
|
|
167
|
+
assert.doesNotMatch(MIG, /NOT NULL|DEFAULT/i, 'no default: an older release must never park anything');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await test('each promotion trigger refuses a parked row (a dependency shipping cannot free it)', () => {
|
|
171
|
+
for (const name of ['promote_dependents_on_ship', 'promote_dependents_on_criterion_satisfy', 'promote_dependents_on_goal_achieve']) {
|
|
172
|
+
assert.match(fnBody(name), /AND t\.parked_at IS NULL/, `${name} promotes a parked task`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await test('leaving backlog un-parks, and only a real status change does', () => {
|
|
177
|
+
const fn = fnBody('clear_parked_on_leave_backlog');
|
|
178
|
+
assert.match(fn, /IF NEW\.status IS DISTINCT FROM 'backlog' THEN NEW\.parked_at := NULL; NEW\.parked_by := NULL;/);
|
|
179
|
+
assert.match(MIG, /BEFORE UPDATE OF status ON tasks FOR EACH ROW WHEN \(OLD\.status IS DISTINCT FROM NEW\.status\) EXECUTE FUNCTION clear_parked_on_leave_backlog\(\)/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
summary();
|
|
@@ -33,7 +33,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
33
33
|
const repoRoot = join(__dirname, '..');
|
|
34
34
|
const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// core_274 (task 1004592) restated all three bodies to add the parked clause,
|
|
37
|
+
// so it is now the latest definition and must carry core_250's guard forward.
|
|
38
|
+
const MIG = 'core_274_parked_backlog_stays_parked.sql';
|
|
37
39
|
|
|
38
40
|
// The three promotion triggers, by the state transition each reacts to.
|
|
39
41
|
const TRIGGERS = [
|
|
@@ -59,7 +61,7 @@ function fnBody(name) {
|
|
|
59
61
|
|
|
60
62
|
const { test, summary } = makeRunner();
|
|
61
63
|
|
|
62
|
-
await test('
|
|
64
|
+
await test('the latest migration redefines all three promotion triggers', () => {
|
|
63
65
|
for (const name of TRIGGERS) {
|
|
64
66
|
assert.match(body, new RegExp(`CREATE OR REPLACE FUNCTION ${name}\\(\\)`), `${name} is redefined`);
|
|
65
67
|
}
|
|
@@ -99,7 +101,7 @@ await test('nothing else was weakened while adding the guard', () => {
|
|
|
99
101
|
}
|
|
100
102
|
});
|
|
101
103
|
|
|
102
|
-
await test('
|
|
104
|
+
await test('the latest migration is the LAST definition of each trigger in migrations/', () => {
|
|
103
105
|
// The drift tripwire, and the assertion that would have caught migration 177
|
|
104
106
|
// on the day it landed. A later whole-body redefinition inherits only what it
|
|
105
107
|
// restates, so this fails the moment someone redefines one of these functions
|