@bongos/core 1.19.925 → 1.19.927

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,458 @@
1
+ // tests/idea_descent_surface.mjs
2
+ //
3
+ // task 1004094 (BV2.R10, goal 1000095, criterion `ideator-tag-survives-the-fork`)
4
+ // — THE DESCENT SURFACE on /idea/:id. Spec docs/specs/bongos-v2-ideator-surface.md;
5
+ // ADR 0312 (the edges), 0313 (removal proposed then confirmed), 0322 (the five
6
+ // states).
7
+ //
8
+ // THE DONE-WHEN IS THE FIRST TEST, and it is the only one that matters on its
9
+ // own: a person can trace a thing shipped months later back to the line that
10
+ // caused it, THROUGH rewrites, splits and merges. So §1 renders a real
11
+ // four-deep chain whose hops are a rewrite, two splits and a fold, and asserts
12
+ // that the reader is handed every hop, the word for each edge, and a path back
13
+ // to each task.
14
+ //
15
+ // TESTED AT THE RENDERED LAYER, not at the read behind it. That is the first of
16
+ // the three findings this goal's work inherited from the abandoned task 1004054
17
+ // (see modules/ideas/trail-descent.js): the prior attempt asserted on the
18
+ // server-side builder alone, which is exactly how a renderer bug survived it.
19
+ // Every case below boots the REAL modules/hall-ui/public/idea.js through
20
+ // tests/helpers.mjs bootIdeaPage and reads the HTML a reader would get.
21
+ //
22
+ // THE THREE TRAPS THIS FILE EXISTS TO CATCH, each of which paints a page that
23
+ // looks completely fine:
24
+ //
25
+ // 1. THE ROOTS GO MISSING. attachDescentToTrail deliberately EXCLUDES an
26
+ // idea's own root tasks from descent_tasks (repeating them would
27
+ // double-count the first hop for the payout). A renderer that draws only
28
+ // descent_tasks therefore loses the first thing the thought ever caused —
29
+ // the one hop every other surface already shows — and the rail still
30
+ // renders, just beginning in the middle of the story.
31
+ // 2. UNREADABLE IS DRAWN AS ALIVE. `removed_tasks` is `{}` when nothing went
32
+ // out and `null` when the lifecycle facts could not be read at all. Those
33
+ // are different answers, and collapsing the second into the first promises
34
+ // a reader that every branch survived. Nothing errors when it does.
35
+ // 3. THE EMPTY STATE IS A HEADING OVER NOTHING. A thought that caused nothing
36
+ // must render NO section, not an empty rail — the same judgement the page
37
+ // already makes about a quick idea's five-field skeleton.
38
+ //
39
+ // DB-free: no route, no pool, no browser. Run: node tests/idea_descent_surface.mjs
40
+
41
+ import { strict as assert } from 'node:assert';
42
+ import { createRequire } from 'node:module';
43
+ import { bootIdeaPage, settle } from './helpers.mjs';
44
+
45
+ const require = createRequire(import.meta.url);
46
+
47
+ let passed = 0;
48
+ let failed = 0;
49
+ async function test(name, fn) {
50
+ try { await fn(); passed += 1; console.log(`ok ${name}`); }
51
+ catch (err) { failed += 1; console.log(`FAIL ${name}\n ${err && (err.message || JSON.stringify(err))}`); }
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // the fixture world
56
+ // ---------------------------------------------------------------------------
57
+
58
+ // One thought, one root, and a four-deep descent below it whose hops are the
59
+ // three relations the done-when names by name — a rewrite, a split and a merge
60
+ // — plus a fold that later WENT OUT and a cascade that has not landed yet. The
61
+ // deepest hop lives in a LATER version, because the descent crosses version
62
+ // boundaries deliberately (ADR 0312) and a surface that hides that is lying by
63
+ // omission.
64
+ const ROOT = { task_id: '9001', origin: 'promote', title: 'Env vars in project setup', status: 'shipped', goal_id: '600' };
65
+
66
+ const DESCENT = [
67
+ { task_id: '9002', depth: 1, relation: 'rewrite', origin_task_id: '9001', title: 'Scope widened to secrets', status: 'shipped', version_id: 'BONGOS-V1', kind: 'feature' },
68
+ { task_id: '9003', depth: 2, relation: 'split', origin_task_id: '9001', title: 'The env var editor', status: 'shipped', version_id: 'BONGOS-V1', kind: 'feature' },
69
+ { task_id: '9004', depth: 2, relation: 'merge', origin_task_id: '9001', title: 'Encrypted secret storage', status: 'shipped', version_id: 'BONGOS-V2', kind: 'feature' },
70
+ { task_id: '9005', depth: 3, relation: 'fold', origin_task_id: '9001', title: 'The settings panel it lived in', status: 'shipped', version_id: 'BONGOS-V1', kind: 'refactor' },
71
+ { task_id: '9006', depth: 1, relation: 'cascade', origin_task_id: '9001', title: 'Import a .env file', status: 'backlog', version_id: 'BONGOS-V2', kind: 'feature' },
72
+ ];
73
+
74
+ function anIdea(over = {}) {
75
+ return {
76
+ id: '1',
77
+ title: 'A place to put env vars',
78
+ body_md: 'The thought.',
79
+ grade: 'quick',
80
+ status: 'promoted',
81
+ kind: 'feature',
82
+ suggested_version: 'BONGOS-V1',
83
+ captured_by: '1',
84
+ captured_at: '2026-07-01T12:00:00.000Z',
85
+ promoted_to_task_id: '9001',
86
+ promoted_task_title: ROOT.title,
87
+ promoted_task_status: ROOT.status,
88
+ lineage_tasks: [ROOT],
89
+ descent_tasks: DESCENT,
90
+ removed_tasks: {},
91
+ size: { credits: 278, payments: 5, scale: 3, estimated: 0, historical: false },
92
+ state: { state: 'star', position: 'well', wellGoalId: '600', awaitingNod: false, brightness: 0.82, hasWork: true, attributable: true },
93
+ ...over,
94
+ };
95
+ }
96
+
97
+ async function renderIdea(over = {}) {
98
+ const page = bootIdeaPage({ idea: anIdea(over) });
99
+ await settle();
100
+ await settle();
101
+ await settle();
102
+ return page.html();
103
+ }
104
+
105
+ // The rows, in render order, each as { id, mark, meta }.
106
+ function rowsOf(html) {
107
+ const out = [];
108
+ const re = /<li class="ig-desc__row" data-mark="([^"]+)" style="--d:(\d+)">([\s\S]*?)<\/li>/g;
109
+ let m;
110
+ while ((m = re.exec(html)) !== null) {
111
+ const body = m[3];
112
+ const id = (/<span class="ig-desc__id">(\d+)<\/span>/.exec(body) || [])[1];
113
+ const meta = (/<span class="ig-desc__meta">([\s\S]*?)<\/span>\s*$/.exec(body.split('ig-desc__meta')[1] ? body : body) || [])[0];
114
+ out.push({
115
+ id,
116
+ mark: m[1],
117
+ depth: Number(m[2]),
118
+ meta: (/<span class="ig-desc__meta">([\s\S]*?)<\/span>/.exec(body) || [])[1] || '',
119
+ title: (/<a class="ig-desc__title"[^>]*>([\s\S]*?)<\/a>/.exec(body) || [])[1] || '',
120
+ href: (/<a class="ig-desc__title" href="([^"]+)"/.exec(body) || [])[1] || '',
121
+ });
122
+ }
123
+ return out;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // §1 — the done-when
128
+ // ---------------------------------------------------------------------------
129
+
130
+ await test('§1 DONE-WHEN: a thing shipped later traces back through the rewrites, splits and merges', async () => {
131
+ const html = await renderIdea();
132
+ const rows = rowsOf(html);
133
+
134
+ // Every hop is there: the root plus all five descendants. Nothing is capped
135
+ // on this page — profile.js caps at three and links HERE for the rest, which
136
+ // is the only thing that makes that cap honest.
137
+ assert.equal(rows.length, 6, `expected the root + 5 descendants, got ${rows.length}`);
138
+ assert.deepEqual(rows.map((r) => r.id), ['9001', '9002', '9003', '9004', '9005', '9006']);
139
+
140
+ // The three relations the done-when names are each stated IN WORDS. A reader
141
+ // tracing 9005 back to the origin has to be able to see what kind of hop each
142
+ // step was, or the rail is a list of tasks rather than a story.
143
+ const meta = (id) => rows.find((r) => r.id === id).meta;
144
+ assert.match(meta('9002'), /rewritten/, 'the rewrite must say so');
145
+ assert.match(meta('9003'), /split off/, 'the split must say so');
146
+ assert.match(meta('9004'), /merged in/, 'the merge must say so');
147
+ assert.match(meta('9005'), /folded in/, 'the fold must say so');
148
+ assert.match(meta('9006'), /caused by/, 'the cascade must say so');
149
+
150
+ // And every hop is a door back to the work itself.
151
+ for (const r of rows) {
152
+ assert.equal(r.href, `/task/${r.id}`, `row ${r.id} must link to its task`);
153
+ }
154
+ });
155
+
156
+ await test('§1 the depth of a hop survives into the rail, so the shape is readable', async () => {
157
+ const rows = rowsOf(await renderIdea());
158
+ // The server counts hops FROM a root; the rail draws the root at 0, so every
159
+ // descendant sits one step deeper than the number the read carries.
160
+ assert.deepEqual(
161
+ rows.map((r) => [r.id, r.depth]),
162
+ [['9001', 0], ['9002', 2], ['9003', 3], ['9004', 3], ['9005', 4], ['9006', 2]],
163
+ );
164
+ });
165
+
166
+ await test('§1 a descendant in a LATER version says which one; one in the same version does not', async () => {
167
+ const rows = rowsOf(await renderIdea());
168
+ const meta = (id) => rows.find((r) => r.id === id).meta;
169
+ // 9004 and 9006 are BONGOS-V2 against an idea filed at BONGOS-V1.
170
+ assert.match(meta('9004'), /in BONGOS-V2/, 'a cross-version hop must name its version');
171
+ assert.match(meta('9006'), /in BONGOS-V2/);
172
+ // 9002 is in the idea's own version, so saying so would be noise on every row.
173
+ assert.doesNotMatch(meta('9002'), /in BONGOS-V1/, 'a same-version hop must not repeat the version');
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // §2 — the roots (trap 1)
178
+ // ---------------------------------------------------------------------------
179
+
180
+ await test('§2 TRAP: the ROOT is drawn, though the server leaves it out of descent_tasks', async () => {
181
+ const rows = rowsOf(await renderIdea());
182
+ const root = rows[0];
183
+ assert.equal(root.id, '9001', 'the first thing the thought caused must lead the rail');
184
+ assert.equal(root.depth, 0);
185
+ assert.match(root.meta, /became work/, 'a root has no relation — it became work');
186
+ assert.match(root.title, /Env vars in project setup/);
187
+ });
188
+
189
+ await test('§2 an idea that became MANY tasks shows every one of them as a root', async () => {
190
+ const rows = rowsOf(await renderIdea({
191
+ lineage_tasks: [
192
+ ROOT,
193
+ { task_id: '9010', origin: 'develop', title: 'The second thing it became', status: 'ready', goal_id: '600' },
194
+ ],
195
+ }));
196
+ const roots = rows.filter((r) => r.depth === 0).map((r) => r.id);
197
+ assert.deepEqual(roots, ['9001', '9010'], 'both lineage tasks are roots');
198
+ });
199
+
200
+ await test('§2 a promotion that predates the lineage table still shows its one root', async () => {
201
+ // lineage_tasks empty, promoted_to_task_id set — the only answer for an old
202
+ // promotion, and the reason the legacy column is still read.
203
+ const rows = rowsOf(await renderIdea({ lineage_tasks: [], descent_tasks: [] }));
204
+ assert.deepEqual(rows.map((r) => r.id), ['9001']);
205
+ assert.match(rows[0].meta, /became work/);
206
+ });
207
+
208
+ await test('§2 an idea carrying BOTH lineage and the legacy column does not list its root twice', async () => {
209
+ const rows = rowsOf(await renderIdea({ descent_tasks: [] }));
210
+ assert.deepEqual(rows.map((r) => r.id), ['9001'], 'lineage wins; the legacy column is not added on top');
211
+ });
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // §3 — the branches that went out (trap 2)
215
+ // ---------------------------------------------------------------------------
216
+
217
+ await test('§3 a CONFIRMED removal marks its row out, names the ship that did it, and is counted', async () => {
218
+ const html = await renderIdea({
219
+ removed_tasks: { 9005: { proposal_task_id: '9100', ship_task_id: '9101', paths: ['src/settings/panel.js'], decided_at: '2027-01-14T09:00:00.000Z' } },
220
+ });
221
+ const rows = rowsOf(html);
222
+ const gone = rows.find((r) => r.id === '9005');
223
+ assert.equal(gone.mark, 'out', 'a removed hop is marked out');
224
+ assert.match(gone.meta, /went out/);
225
+ assert.match(gone.meta, /task 9101/, 'the removal names the ship that did it');
226
+ assert.match(gone.meta, /href="\/task\/9101"/, 'and that ship is reachable');
227
+ // Every other hop is unaffected — removal is per-node, not per-thought.
228
+ assert.deepEqual(
229
+ rows.filter((r) => r.id !== '9005').map((r) => r.mark),
230
+ ['live', 'live', 'live', 'live', 'pending'],
231
+ );
232
+ assert.match(html, /<span class="ig-desc__n">1<\/span>\s*<span class="ig-desc__flabel">went out<\/span>/);
233
+ });
234
+
235
+ await test('§3 TRAP: `null` removal facts are NOT drawn as a descent that is wholly alive', async () => {
236
+ const html = await renderIdea({ removed_tasks: null });
237
+ const rows = rowsOf(html);
238
+ // Not one row may claim to be out, and none may claim to be alive either:
239
+ // "we could not look" is a third answer.
240
+ assert.equal(rows.filter((r) => r.mark === 'out').length, 0);
241
+ assert.doesNotMatch(html, /went out<\/span>/, 'no figure may state a removal count that was never read');
242
+ assert.match(html, /could not be read/, 'the page must SAY the liveness is unknown');
243
+ });
244
+
245
+ await test('§3 an empty `{}` means nothing went out — and says nothing, rather than saying it is unknown', async () => {
246
+ const html = await renderIdea({ removed_tasks: {} });
247
+ assert.doesNotMatch(html, /could not be read/, 'a readable, empty answer is not an unreadable one');
248
+ assert.doesNotMatch(html, /went out/, 'nothing went out, so nothing is said about it');
249
+ assert.equal(rowsOf(html).filter((r) => r.mark === 'out').length, 0);
250
+ });
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // §4 — the empty state (trap 3)
254
+ // ---------------------------------------------------------------------------
255
+
256
+ await test('§4 TRAP: a thought that caused NOTHING renders no section at all', async () => {
257
+ const html = await renderIdea({
258
+ status: 'open',
259
+ promoted_to_task_id: null,
260
+ promoted_task_title: null,
261
+ lineage_tasks: [],
262
+ descent_tasks: [],
263
+ size: null,
264
+ state: { state: 'protostar', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: 1, hasWork: false, attributable: true },
265
+ });
266
+ assert.doesNotMatch(html, /ig-desc/, 'no heading, no rail, no figures — the page says "still open" and stops');
267
+ // The page itself still renders: an absent descent must not blank the record.
268
+ assert.match(html, /rec-title/);
269
+ });
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // §5 — the numbers are the server's, and they never lie about what they are
273
+ // ---------------------------------------------------------------------------
274
+
275
+ await test('§5 size is printed verbatim and a HISTORICAL size says so', async () => {
276
+ const plain = await renderIdea();
277
+ assert.match(plain, /<span class="ig-desc__n">278<\/span>/);
278
+ assert.match(plain, /size — credits/);
279
+
280
+ // Spec decision 17: a backfilled size was computed from the descent an idea
281
+ // already had, not from payouts actually made. Anywhere the number is shown
282
+ // has to make that clear or it claims to be money that was never spent.
283
+ const hist = await renderIdea({ size: { credits: 278, payments: 0, scale: 3, estimated: 278, historical: true } });
284
+ assert.match(hist, /size — part historical/);
285
+ });
286
+
287
+ await test('§5 an idea with no size at all still draws its rail', async () => {
288
+ const html = await renderIdea({ size: null });
289
+ assert.doesNotMatch(html, /size — /, 'no size, no size figure');
290
+ assert.equal(rowsOf(html).length, 6, 'but every hop it caused is still drawn');
291
+ });
292
+
293
+ await test('§5 the count of things it caused is the rows actually drawn, roots included', async () => {
294
+ const html = await renderIdea();
295
+ assert.match(html, /<span class="ig-desc__n">6<\/span>\s*<span class="ig-desc__flabel">things it caused<\/span>/);
296
+ });
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // §6 — the vocabulary boundary (ADR 0322)
300
+ // ---------------------------------------------------------------------------
301
+
302
+ await test('§6 the page does NOT re-derive a five-object state per descendant', async () => {
303
+ const html = await renderIdea();
304
+ // The five objects are an IDEA's property, resolved once in
305
+ // modules/ideas/idea-state.js. A row here is a TASK and carries no object; if
306
+ // one of these words ever appears against a descent row, some renderer has
307
+ // started deciding a state a second time — which is the exact failure ADR 0322
308
+ // exists to prevent, and it would disagree with the sky on the first read.
309
+ const rail = /<ol class="ig-desc__list">([\s\S]*?)<\/ol>/.exec(html)[1];
310
+ for (const object of ['protostar', 'satellite', 'remnant', 'cold-body']) {
311
+ assert.doesNotMatch(rail, new RegExp(object, 'i'), `no descent row may be labelled a ${object}`);
312
+ }
313
+ });
314
+
315
+ await test('§6 an unknown relation is shown verbatim rather than dropped', async () => {
316
+ // A relation this page has not heard of is a real edge someone wrote. Dropping
317
+ // it would silently shorten the story; showing it unstyled does not.
318
+ const rows = rowsOf(await renderIdea({
319
+ descent_tasks: [{ task_id: '9007', depth: 1, relation: 'transmuted', origin_task_id: '9001', title: 'A hop from the future', status: 'shipped', version_id: 'BONGOS-V1' }],
320
+ }));
321
+ const hop = rows.find((r) => r.id === '9007');
322
+ assert.ok(hop, 'the row must still be drawn');
323
+ assert.match(hop.meta, /transmuted/);
324
+ });
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // §7 — it is a read of hostile data
328
+ // ---------------------------------------------------------------------------
329
+
330
+ await test('§7 a task title carrying markup reaches the page as TEXT, not as markup', async () => {
331
+ const html = await renderIdea({
332
+ descent_tasks: [{ task_id: '9008', depth: 1, relation: 'split', origin_task_id: '9001', title: '<img src=x onerror=alert(1)>', status: 'shipped', version_id: 'BONGOS-V1' }],
333
+ });
334
+ assert.doesNotMatch(html, /<img src=x/, 'the title must be escaped');
335
+ assert.match(html, /&lt;img src=x/);
336
+ });
337
+
338
+ await test('§7 a descent row with no title falls back to its id rather than rendering blank', async () => {
339
+ const rows = rowsOf(await renderIdea({
340
+ descent_tasks: [{ task_id: '9009', depth: 1, relation: 'split', origin_task_id: '9001', title: null, status: 'shipped' }],
341
+ }));
342
+ assert.match(rows.find((r) => r.id === '9009').title, /Task 9009/);
343
+ });
344
+
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // §8 — the read behind it
348
+ //
349
+ // The renderer above is only honest if the ROUTE hands it these fields, and the
350
+ // route reads a different row from the one it used to. Both halves of that are
351
+ // pinned here, DB-free, because both fail SILENTLY: a projection that stops
352
+ // carrying `lineage_tasks` renders a thought with a descent as one with none,
353
+ // and nothing errors.
354
+ // ---------------------------------------------------------------------------
355
+
356
+ const trail = require('../modules/ideas/trail.js');
357
+ const trailRemoval = require('../modules/ideas/trail-removal.js');
358
+
359
+ // A pool stand-in that records the SQL it was handed.
360
+ function recordingPool(rows = []) {
361
+ const log = [];
362
+ return { log, query: async (text, params) => { log.push({ text, params }); return { rows }; } };
363
+ }
364
+
365
+ await test('§8 the detail read and the trail page read use the SAME projection', async () => {
366
+ // THE ANTI-DRIFT CLAIM, and it is invisible in review. The two reads differ
367
+ // only in their WHERE; if someone hand-copies the SELECT for one of them and
368
+ // later adds a column to the other, the detail page silently stops carrying
369
+ // a field the attachers read by name. Comparing the text is what makes the
370
+ // "spelled once" comment in trail.js a fact rather than an intention.
371
+ process.env.NODE_ENV = 'test';
372
+ const one = recordingPool([{ id: '1' }]);
373
+ await trail.getIdeaTrailRow('1', { pool: one });
374
+
375
+ const many = recordingPool([]);
376
+ await trail.listIdeaTrailForBuilder('95', { limit: 5 }, { pool: many });
377
+
378
+ const selectOf = (sql) => sql.slice(0, sql.indexOf(' WHERE'));
379
+ assert.ok(one.log.length === 1 && many.log.length === 1, 'one query each');
380
+ assert.equal(
381
+ selectOf(one.log[0].text),
382
+ selectOf(many.log[0].text),
383
+ 'the detail read must select exactly what the trail read selects',
384
+ );
385
+ // And the thing the attachers actually need is in it.
386
+ assert.match(one.log[0].text, /lineage_tasks/);
387
+ assert.match(one.log[0].text, /promoted_task_goal_id/);
388
+ assert.match(one.log[0].text, /WHERE i\.id = \$1/);
389
+ });
390
+
391
+ await test('§8 getIdeaTrailRow answers null for an id that is not there', async () => {
392
+ process.env.NODE_ENV = 'test';
393
+ const p = recordingPool([]);
394
+ assert.equal(await trail.getIdeaTrailRow('404', { pool: p }), null);
395
+ });
396
+
397
+ // ---- the removal attacher -------------------------------------------------
398
+
399
+ // A module-api stand-in is not available here, so the port is exercised through
400
+ // the real resolveOptional by temporarily registering one. When that is not
401
+ // possible the attacher's OWN contract is still the thing under test: what it
402
+ // writes onto the row for each of the three answers.
403
+ function withPort(port, fn) {
404
+ const api = require('../src/module-api');
405
+ const original = api.resolveOptional;
406
+ api.resolveOptional = (name) => (name === 'lifecycle.removals' ? port : original && original(name));
407
+ return fn().finally(() => { api.resolveOptional = original; });
408
+ }
409
+
410
+ await test('§8 attachRemovalToDescent keys BOTH the roots and the descent', async () => {
411
+ let asked = null;
412
+ const port = {
413
+ confirmedRemovalsForTasks: async (ids) => {
414
+ asked = [...ids].sort();
415
+ return new Map([['9005', { proposalTaskId: '9100', shipTaskId: '9101', paths: ['a.js'], decidedAt: 'x' }]]);
416
+ },
417
+ };
418
+ await withPort(port, async () => {
419
+ const idea = { id: '1', lineage_tasks: [{ task_id: '9001' }], descent_tasks: [{ task_id: '9005' }, { task_id: '9006' }] };
420
+ await trailRemoval.attachRemovalToDescent([idea]);
421
+ // The ROOT is asked about too. attachDescentToTrail leaves roots out of
422
+ // descent_tasks, and a root is the commonest thing to have been torn out —
423
+ // asking only about the descent would draw a dead origin as alive.
424
+ assert.deepEqual(asked, ['9001', '9005', '9006']);
425
+ assert.deepEqual(Object.keys(idea.removed_tasks), ['9005']);
426
+ assert.equal(idea.removed_tasks['9005'].ship_task_id, '9101');
427
+ });
428
+ });
429
+
430
+ await test('§8 nothing removed is `{}`; an unreadable port is `null` — never the same value', async () => {
431
+ await withPort({ confirmedRemovalsForTasks: async () => new Map() }, async () => {
432
+ const idea = { id: '1', lineage_tasks: [{ task_id: '9001' }], descent_tasks: [] };
433
+ await trailRemoval.attachRemovalToDescent([idea]);
434
+ assert.deepEqual(idea.removed_tasks, {}, 'readable and empty');
435
+ });
436
+ await withPort(null, async () => {
437
+ const idea = { id: '1', lineage_tasks: [{ task_id: '9001' }], descent_tasks: [] };
438
+ await trailRemoval.attachRemovalToDescent([idea]);
439
+ assert.equal(idea.removed_tasks, null, 'unreadable is null, and the renderer says so');
440
+ });
441
+ });
442
+
443
+ await test('§8 the whole page costs ONE port call, however many nodes it draws', async () => {
444
+ let calls = 0;
445
+ const port = { confirmedRemovalsForTasks: async () => { calls += 1; return new Map(); } };
446
+ await withPort(port, async () => {
447
+ const ideas = Array.from({ length: 12 }, (_, i) => ({
448
+ id: String(i + 1),
449
+ lineage_tasks: [{ task_id: String(8000 + i) }],
450
+ descent_tasks: Array.from({ length: 6 }, (__, j) => ({ task_id: String(9000 + i * 10 + j) })),
451
+ }));
452
+ await trailRemoval.attachRemovalToDescent(ideas);
453
+ assert.equal(calls, 1, 'one call for 12 ideas and 84 nodes, not one per idea');
454
+ });
455
+ });
456
+
457
+ console.log(`\n${passed} passed, ${failed} failed`);
458
+ process.exit(failed > 0 ? 1 : 0);