@bongos/core 1.19.1006 → 1.19.1008
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 +25 -20
- package/docs/copy-inventory.md +38 -36
- package/docs/copy-registry.json +74 -56
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/idea.js +2 -2
- package/modules/hall-ui/public/ideas.css +21 -0
- package/modules/hall-ui/public/ideas.html +59 -33
- package/modules/hall-ui/public/ideas.js +128 -17
- package/modules/hall-ui/public/ideas.states.json +3 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_record_world.mjs +24 -3
- package/tests/idea_spark_hall.mjs +195 -4
- package/tests/ideator_large_dead_thought_proof.mjs +399 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// tests/ideator_large_dead_thought_proof.mjs
|
|
2
|
+
//
|
|
3
|
+
// THE PROOF for criterion `ideator-sky-tells-the-truth` (goal 1000095,
|
|
4
|
+
// BONGOS-V2), written under task 1004106 (BV2.R27). Sibling of
|
|
5
|
+
// tests/ideator_traceable_proof.mjs (R21), tests/ideator_fork_pays_proof.mjs
|
|
6
|
+
// (R23) and tests/ideator_full_idea_shapes_space_proof.mjs (R25), and it keeps
|
|
7
|
+
// their standard: a proof whose only possible outcome is "confirmed" is not a
|
|
8
|
+
// proof. Every claim was probed by breaking the real code — see
|
|
9
|
+
// docs/governance/bongos-v2-ideator-sky-tells-the-truth-proof.md.
|
|
10
|
+
//
|
|
11
|
+
// THE SINGLE SCENARIO THE CRITERION EXISTS FOR: a thought that caused
|
|
12
|
+
// substantial work which the project has since removed shows at FULL SIZE and
|
|
13
|
+
// ZERO BRIGHTNESS, and is distinguishable at a glance from a thought that never
|
|
14
|
+
// caused anything at all. If those two render alike, the criterion is not met
|
|
15
|
+
// however green the build is — that distinction is the entire reason size and
|
|
16
|
+
// brightness are two channels instead of one number.
|
|
17
|
+
//
|
|
18
|
+
// WHAT THIS ADDS OVER tests/hall_sky.mjs, which already has a case named "A
|
|
19
|
+
// LARGE DEAD THOUGHT RENDERS LARGE AND DEAD". That case is correct and stays.
|
|
20
|
+
// But it builds its rows BY HAND — its `remnant()` fixture literally writes
|
|
21
|
+
// `size: { credits: 180, scale: 0.86 }` beside `brightness: 0` — so it asserts
|
|
22
|
+
// that a renderer handed a preserved size draws a preserved size. The claim the
|
|
23
|
+
// criterion actually makes is one layer below that: **that the size IS
|
|
24
|
+
// preserved**, by the server, after the removal. A brightness read that dimmed
|
|
25
|
+
// the size as it dimmed the light would leave every assertion in that suite
|
|
26
|
+
// green, because the suite told the fixture what the size was. That comment in
|
|
27
|
+
// hall_sky.mjs says as much — the large-dead case is "the thing BV2.R27 goes on
|
|
28
|
+
// to prove".
|
|
29
|
+
//
|
|
30
|
+
// SO THIS RUNS THE WHOLE PATH, NOT ITS MIDDLE:
|
|
31
|
+
//
|
|
32
|
+
// credit_log rows ──▶ real ideaSizes ──▶ real ideaBrightnesses (real removals
|
|
33
|
+
// port) ──▶ real resolveIdeaState ──▶ the row the route emits ──▶ REAL sky.js
|
|
34
|
+
// in a vm ──▶ the geometry a reader sees
|
|
35
|
+
//
|
|
36
|
+
// Nothing between the ledger and the glyph is hand-written. `scale` is COMPUTED
|
|
37
|
+
// from the ledger after the removal is confirmed, never asserted into place, so
|
|
38
|
+
// "size never falls" is a measurement here rather than a fixture's promise.
|
|
39
|
+
//
|
|
40
|
+
// DB-free: pool.query is patched on the doorway and the store throws on any SQL
|
|
41
|
+
// it does not recognise, so a renamed column cannot turn this green by
|
|
42
|
+
// answering nothing.
|
|
43
|
+
//
|
|
44
|
+
// Run: node tests/ideator_large_dead_thought_proof.mjs
|
|
45
|
+
|
|
46
|
+
import assert from 'node:assert/strict';
|
|
47
|
+
import { createRequire } from 'node:module';
|
|
48
|
+
import { makeRunner, settle, bootSkyPage } from './helpers.mjs';
|
|
49
|
+
|
|
50
|
+
const require = createRequire(import.meta.url);
|
|
51
|
+
const api = require('../src/module-api.js');
|
|
52
|
+
|
|
53
|
+
const { test, summary } = makeRunner();
|
|
54
|
+
const drain = async (n = 20) => { for (let i = 0; i < n; i += 1) await settle(); };
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// The ledger. One table, read by the real economy SQL.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
// credit_log rows: { reason, task_id, delta }. `reason` carries the idea id the
|
|
61
|
+
// way the real writer embeds it — `idea.credit.task_ship:<ideaId>:<taskId>`.
|
|
62
|
+
let creditLog = [];
|
|
63
|
+
|
|
64
|
+
function answer(sql, params) {
|
|
65
|
+
const s = sql.replace(/\s+/g, ' ').trim();
|
|
66
|
+
|
|
67
|
+
if (/FROM credit_log/i.test(s) && /split_part\(reason, ':', 2\)/i.test(s)) {
|
|
68
|
+
const wanted = new Set((params[0] || []).map(String));
|
|
69
|
+
// GROUP BY (idea_id, task_id), exactly as the real read does.
|
|
70
|
+
const groups = new Map();
|
|
71
|
+
for (const row of creditLog) {
|
|
72
|
+
const parts = String(row.reason).split(':');
|
|
73
|
+
// The stream-1 prefix filter. A stream-2 row (`idea.credit.full_pass:…`)
|
|
74
|
+
// is the thought, not the work it caused, and must not be summed here.
|
|
75
|
+
if (!String(row.reason).startsWith('idea.credit.task_ship:')) continue;
|
|
76
|
+
const ideaId = parts[1];
|
|
77
|
+
if (!wanted.has(String(ideaId))) continue;
|
|
78
|
+
const key = `${ideaId}\u0000${row.task_id}`;
|
|
79
|
+
const acc = groups.get(key) || { idea_id: ideaId, task_id: row.task_id, credits: 0, payments: 0 };
|
|
80
|
+
acc.credits += Number(row.delta) || 0;
|
|
81
|
+
acc.payments += 1;
|
|
82
|
+
groups.set(key, acc);
|
|
83
|
+
}
|
|
84
|
+
return { rows: [...groups.values()] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The grade read the size path makes: an idea's grade is what prices the
|
|
88
|
+
// hops the ledger never paid for. Every idea here is graded `quick` and has
|
|
89
|
+
// an empty descent, so nothing is estimated — but the read still happens and
|
|
90
|
+
// the store still has to answer it honestly.
|
|
91
|
+
if (/FROM idea_inbox i LEFT JOIN ideas_grades g/i.test(s)) {
|
|
92
|
+
return { rows: (params[0] || []).map((id) => ({ id: String(id), grade: 'quick', completeness: null })) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw new Error(`the store does not recognise this SQL — a hop moved:\n ${s.slice(0, 200)}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
api.pool.query = async (sql, params = []) => answer(sql, params);
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// The ports. `lifecycle.removals` is the one the whole criterion turns on: a
|
|
102
|
+
// removal is a PERSON'S confirmed verdict (ADR 0313), never a diff.
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
// taskId -> { proposalTaskId, paths }. Reassigned per case.
|
|
106
|
+
let removals = new Map();
|
|
107
|
+
|
|
108
|
+
const removalsPort = {
|
|
109
|
+
confirmedRemovalsForTasks: async (taskIds) => {
|
|
110
|
+
const out = new Map();
|
|
111
|
+
for (const id of taskIds) {
|
|
112
|
+
const hit = removals.get(String(id));
|
|
113
|
+
if (hit) out.set(String(id), hit);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (!api.hasProvider('lifecycle.removals')) api.registerProvider('lifecycle.removals', removalsPort);
|
|
120
|
+
// The descent walk. These ideas' payouts are all direct roots, which is the live
|
|
121
|
+
// shape (see the record: no multi-hop descent exists on this instance), so the
|
|
122
|
+
// walk has nothing to add and an empty descent is the honest answer.
|
|
123
|
+
if (!api.hasProvider('ideas.descent')) {
|
|
124
|
+
api.registerProvider('ideas.descent', { descentForIdeas: async () => new Map() });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const economy = require('../modules/economy/idea-size.js');
|
|
128
|
+
const { compressIdeaSize } = economy;
|
|
129
|
+
const { ideaBrightnesses } = require('../modules/economy/idea-brightness.js');
|
|
130
|
+
const { attachStateToTrail } = require('../modules/ideas/idea-state.js');
|
|
131
|
+
const { attachSizeToTrail } = require('../modules/ideas/trail-size.js');
|
|
132
|
+
|
|
133
|
+
// The `reward` port, carrying the REAL economy functions. Both attachers reach
|
|
134
|
+
// economy only through this door (ADR 0083), so registering the real ones is
|
|
135
|
+
// what makes the two hops below run production code rather than a stand-in.
|
|
136
|
+
if (!api.hasProvider('reward')) {
|
|
137
|
+
api.registerProvider('reward', {
|
|
138
|
+
ideaSizes: economy.ideaSizes,
|
|
139
|
+
ideaBrightnesses,
|
|
140
|
+
// An idea whose payouts are all direct roots has no descent rows to price.
|
|
141
|
+
descentRowsFromTrail: () => [],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// THE SERVER HALF, assembled by real code only. This is what the route emits.
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
// Build the trail rows for a cast of ideas, running every server hop for real.
|
|
150
|
+
async function trailRowsFor(cast) {
|
|
151
|
+
// The rows as the trail read builds them BEFORE size and state exist. Every
|
|
152
|
+
// field below is an ordinary idea_inbox fact; `size` and `state` are left off
|
|
153
|
+
// entirely, because the whole point is that the server puts them there.
|
|
154
|
+
const rows = cast.map((c) => ({
|
|
155
|
+
id: c.id,
|
|
156
|
+
title: c.title,
|
|
157
|
+
kind: 'feature',
|
|
158
|
+
status: c.status,
|
|
159
|
+
captured_at: '2026-09-01T00:00:00.000Z',
|
|
160
|
+
triage_note: null,
|
|
161
|
+
promoted_to_task_id: null,
|
|
162
|
+
promoted_task_title: null,
|
|
163
|
+
promoted_task_status: null,
|
|
164
|
+
merged_into_idea_id: null,
|
|
165
|
+
merged_into_idea_title: null,
|
|
166
|
+
lineage_tasks: [],
|
|
167
|
+
descent_tasks: [],
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
// THE TWO REAL HOPS, in the order the route composes them: size first (state
|
|
171
|
+
// reuses the byTask breakdown size computed), then state.
|
|
172
|
+
await attachSizeToTrail(rows);
|
|
173
|
+
await attachStateToTrail(rows);
|
|
174
|
+
return rows;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const page = (ideas) => ({ ideas, total: ideas.length, offset: 0, limit: 100 });
|
|
178
|
+
|
|
179
|
+
// Read a glyph's geometry back off the markup the real renderer wrote.
|
|
180
|
+
function glyphOf(html, ideaId) {
|
|
181
|
+
const re = new RegExp(`data-idea="${ideaId}"[\\s\\S]*?<span class="sky-glyph"([^>]*)>`);
|
|
182
|
+
const m = re.exec(html);
|
|
183
|
+
if (!m) return null;
|
|
184
|
+
const attrs = m[1];
|
|
185
|
+
return {
|
|
186
|
+
object: (/data-object="([^"]+)"/.exec(attrs) || [])[1] || null,
|
|
187
|
+
d: Number((/--d:(\d+)px/.exec(attrs) || [])[1]),
|
|
188
|
+
lit: Number((/--lit:([\d.]+)/.exec(attrs) || [])[1]),
|
|
189
|
+
unknownSize: /data-size="unknown"/.test(attrs),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const labelOf = (html, ideaId) =>
|
|
193
|
+
(new RegExp(`data-idea="${ideaId}"[\\s\\S]*?aria-label="([^"]*)"`).exec(html) || [])[1] || '';
|
|
194
|
+
|
|
195
|
+
// Render a cast end to end and hand back the glyphs, so a case states the
|
|
196
|
+
// SCENARIO and never the plumbing.
|
|
197
|
+
async function render(cast) {
|
|
198
|
+
const rows = await trailRowsFor(cast);
|
|
199
|
+
const p = bootSkyPage({ pages: [page(rows)] });
|
|
200
|
+
await drain();
|
|
201
|
+
const html = p.field();
|
|
202
|
+
return { html, rows, glyph: (id) => glyphOf(html, id), label: (id) => labelOf(html, id), page: p };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// The cast. Substantial work = four shipped tasks paying 240 credits, which is
|
|
207
|
+
// the shape the criterion's sentence describes.
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
// The four hops, as WORTHS. The task ids are minted per idea below, because a
|
|
211
|
+
// removal is keyed by TASK: two ideas sharing a task id would share its death,
|
|
212
|
+
// and the 'untouched twin' this proof leans on would not be untouched. (That
|
|
213
|
+
// is not hypothetical — the first version of this file shared them, and the
|
|
214
|
+
// twin came back dark.)
|
|
215
|
+
const WORTHS = [120, 60, 40, 20];
|
|
216
|
+
const TOTAL = WORTHS.reduce((a, b) => a + b, 0);
|
|
217
|
+
// Idea 601 owns tasks 6011..6014, idea 602 owns 6021..6024, and so on.
|
|
218
|
+
const tasksOf = (ideaId) => WORTHS.map((delta, i) => [Number(`${ideaId}${i + 1}`), delta]);
|
|
219
|
+
const payFor = (ideaId) =>
|
|
220
|
+
tasksOf(ideaId).map(([taskId, delta]) => ({ reason: `idea.credit.task_ship:${ideaId}:${taskId}`, task_id: taskId, delta }));
|
|
221
|
+
|
|
222
|
+
// 601 — the thought whose work the project tore out. 602 — its twin, untouched.
|
|
223
|
+
// 603 — the thought that never caused anything at all.
|
|
224
|
+
const DEAD = { id: 601, title: 'It caused a version, then the project removed it', status: 'promoted' };
|
|
225
|
+
const LIVE = { id: 602, title: 'It caused the same work, and the work is still there', status: 'promoted' };
|
|
226
|
+
const NEVER = { id: 603, title: 'It never caused anything', status: 'discarded' };
|
|
227
|
+
|
|
228
|
+
function reset() {
|
|
229
|
+
creditLog = [...payFor(601), ...payFor(602)];
|
|
230
|
+
removals = new Map();
|
|
231
|
+
}
|
|
232
|
+
const kill = (taskId) => removals.set(String(taskId), { proposalTaskId: 990, paths: ['modules/gone/'] });
|
|
233
|
+
// Everything idea 601 ever caused, torn out. 602 is never touched.
|
|
234
|
+
const killAll = () => { for (const [taskId] of tasksOf(601)) kill(taskId); };
|
|
235
|
+
|
|
236
|
+
// ═══ THE CRITERION'S OWN SCENARIO ══════════════════════════════════════════
|
|
237
|
+
|
|
238
|
+
await test('THE SCENARIO: a large dead thought draws at FULL SIZE and ZERO brightness', async () => {
|
|
239
|
+
reset();
|
|
240
|
+
killAll();
|
|
241
|
+
const r = await render([DEAD, LIVE, NEVER]);
|
|
242
|
+
const dead = r.glyph(601);
|
|
243
|
+
|
|
244
|
+
assert.equal(dead.object, 'remnant', 'the ledger alone makes it a remnant — nothing set that');
|
|
245
|
+
assert.equal(dead.lit, 0, 'zero brightness: none of the work it caused is still alive');
|
|
246
|
+
// FULL SIZE, measured rather than promised: the diameter is computed from a
|
|
247
|
+
// `scale` the real size read produced AFTER the removal was confirmed.
|
|
248
|
+
const expected = 9 + (32 - 9) * compressIdeaSize(TOTAL);
|
|
249
|
+
assert.ok(Math.abs(dead.d - Math.round(expected)) <= 1,
|
|
250
|
+
`it keeps the diameter its 240 credits earn (${dead.d}px vs ~${expected.toFixed(1)}px)`);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await test('SIZE NEVER FELL — the dead thought is exactly as big as its untouched twin', async () => {
|
|
254
|
+
reset();
|
|
255
|
+
killAll();
|
|
256
|
+
const r = await render([DEAD, LIVE, NEVER]);
|
|
257
|
+
const dead = r.glyph(601);
|
|
258
|
+
const live = r.glyph(602);
|
|
259
|
+
|
|
260
|
+
// The two were paid identically; only one had its work removed. This is the
|
|
261
|
+
// claim hall_sky.mjs cannot make, because there the sizes were written by hand.
|
|
262
|
+
assert.equal(dead.d, live.d, 'removal took the light and left the size alone');
|
|
263
|
+
assert.equal(r.rows[0].size.credits, TOTAL, 'and the credits behind it are untouched too');
|
|
264
|
+
assert.equal(r.rows[0].size.credits, r.rows[1].size.credits);
|
|
265
|
+
assert.equal(live.lit, 1, 'while the twin is still fully lit');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await test('THE DISTINCTION: dead and never-caused do not render alike on EITHER channel', async () => {
|
|
269
|
+
reset();
|
|
270
|
+
killAll();
|
|
271
|
+
const r = await render([DEAD, LIVE, NEVER]);
|
|
272
|
+
const dead = r.glyph(601);
|
|
273
|
+
const never = r.glyph(603);
|
|
274
|
+
|
|
275
|
+
// Channel one — size. The whole reason there are two.
|
|
276
|
+
assert.ok(dead.d > never.d,
|
|
277
|
+
`the thought that mattered is visibly bigger (${dead.d}px vs ${never.d}px)`);
|
|
278
|
+
assert.equal(never.d, 9, 'a thought that caused nothing sits at the floor diameter');
|
|
279
|
+
// Channel two — brightness, and it runs the OPPOSITE way, which is the part a
|
|
280
|
+
// single number could never express: the dead one is dark, the never-caused
|
|
281
|
+
// one is LIT, because nothing has been taken from it.
|
|
282
|
+
assert.equal(dead.lit, 0);
|
|
283
|
+
assert.equal(never.lit, 1);
|
|
284
|
+
assert.notEqual(dead.object, never.object);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await test('and the distinction survives in WORDS, for a reader who cannot see the picture', async () => {
|
|
288
|
+
reset();
|
|
289
|
+
killAll();
|
|
290
|
+
const r = await render([DEAD, LIVE, NEVER]);
|
|
291
|
+
assert.match(r.label(601), /burned out/);
|
|
292
|
+
assert.match(r.label(601), /none of it still alive/);
|
|
293
|
+
assert.match(r.label(601), new RegExp(`size ${TOTAL}`), 'the size is spoken, not only drawn');
|
|
294
|
+
assert.match(r.label(603), /declined/);
|
|
295
|
+
assert.doesNotMatch(r.label(603), /still alive/,
|
|
296
|
+
'a thought that caused nothing says nothing about what survived — there is nothing to survive');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ═══ the edges that make the two channels mean what they say ════════════════
|
|
300
|
+
|
|
301
|
+
await test('ZERO OVER ZERO IS LIT, NOT DARK — a fresh thought is not a burned-out one', async () => {
|
|
302
|
+
reset();
|
|
303
|
+
killAll();
|
|
304
|
+
const fresh = { id: 604, title: 'Filed this morning', status: 'open' };
|
|
305
|
+
const r = await render([DEAD, fresh]);
|
|
306
|
+
const a = r.glyph(601);
|
|
307
|
+
const b = r.glyph(604);
|
|
308
|
+
|
|
309
|
+
assert.equal(b.object, 'protostar', 'nothing removed from it, because nothing came of it yet');
|
|
310
|
+
assert.equal(b.lit, 1, 'it is lit — returning 0 would merge it with the remnant');
|
|
311
|
+
assert.equal(a.lit, 0);
|
|
312
|
+
assert.ok(a.d > b.d, 'and the sizes part them too');
|
|
313
|
+
|
|
314
|
+
// THE SERVER'S OWN ANSWER, not only the drawing. This assertion exists
|
|
315
|
+
// because the case above it did NOT red when idea-brightness.js was made to
|
|
316
|
+
// return 0 for zero-over-zero: the renderer's `litOf` ignores the share
|
|
317
|
+
// entirely when `hasWork` is false, so the glyph stayed lit for a reason that
|
|
318
|
+
// had nothing to do with the rule this case is named after. A proof that
|
|
319
|
+
// names a failure mode and rides a different guard past it is the exact
|
|
320
|
+
// mistake docs/governance/bongos-v2-ideator-fork-proof.md records.
|
|
321
|
+
const freshState = r.rows[1].state;
|
|
322
|
+
assert.equal(freshState.hasWork, false, 'the server says there is no work');
|
|
323
|
+
assert.equal(freshState.brightness, 1,
|
|
324
|
+
'and it says the share is ONE — zero over zero is lit, because nothing has been taken away');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await test('brightness is weighted by WORTH, not by count — losing the big piece dims most', async () => {
|
|
328
|
+
reset();
|
|
329
|
+
// One of four tasks removed. By count that is 25%; by worth, 120 of 240.
|
|
330
|
+
kill(tasksOf(601)[0][0]); // the 120-credit hop of four
|
|
331
|
+
const r = await render([DEAD]);
|
|
332
|
+
const g = r.glyph(601);
|
|
333
|
+
|
|
334
|
+
assert.equal(g.object, 'star', 'partly dimmed is still burning — it goes out only when nothing survives');
|
|
335
|
+
assert.ok(Math.abs(g.lit - 0.5) < 0.001,
|
|
336
|
+
`the share is the payout that died, not the fraction of tasks (${g.lit})`);
|
|
337
|
+
assert.notEqual(g.lit, 0.75, 'which is what counting tasks would have given');
|
|
338
|
+
assert.equal(r.rows[0].size.credits, TOTAL, 'and the size still has not moved');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await test('a dimming thought stays the SAME SIZE the whole way down', async () => {
|
|
342
|
+
const widths = [];
|
|
343
|
+
const hops = tasksOf(601);
|
|
344
|
+
// nothing removed, then the smallest, then the two smallest, then all four
|
|
345
|
+
for (const doomed of [[], hops.slice(3), hops.slice(2), hops]) {
|
|
346
|
+
reset();
|
|
347
|
+
for (const [taskId] of doomed) kill(taskId);
|
|
348
|
+
const r = await render([DEAD]);
|
|
349
|
+
widths.push([r.glyph(601).d, r.glyph(601).lit]);
|
|
350
|
+
}
|
|
351
|
+
const diameters = widths.map((w) => w[0]);
|
|
352
|
+
assert.equal(new Set(diameters).size, 1, `one diameter the whole way down, got ${diameters.join(', ')}`);
|
|
353
|
+
const lits = widths.map((w) => w[1]);
|
|
354
|
+
assert.deepEqual(lits, [...lits].sort((a, b) => b - a), 'while the light falls monotonically');
|
|
355
|
+
assert.equal(lits[0], 1, 'from fully lit');
|
|
356
|
+
assert.equal(lits[lits.length - 1], 0, 'to fully out');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
await test('AN UNREADABLE REMOVAL IS NOT A DEATH — it stays lit and full, and is never a remnant', async () => {
|
|
360
|
+
reset();
|
|
361
|
+
killAll();
|
|
362
|
+
// The port is gone: "we could not look" — a third answer, and the one most
|
|
363
|
+
// easily collapsed into "it died". Collapsing it renders a healthy instance
|
|
364
|
+
// as a field of hollow rings the moment the lifecycle module fails to mount.
|
|
365
|
+
// THE PORT IS PRESENT AND CANNOT ANSWER, which is the branch the real read
|
|
366
|
+
// actually takes: it tests `typeof port.confirmedRemovalsForTasks ===
|
|
367
|
+
// 'function'`. Swapping the RESOLVER instead does not work here and the
|
|
368
|
+
// reason is worth recording — idea-brightness.js destructures
|
|
369
|
+
// `const { resolveOptional } = api` at LOAD time, so a later property swap
|
|
370
|
+
// never reaches it (trail-removal.js avoids that on purpose and says so in
|
|
371
|
+
// its header; this module does not). Removing the method reaches the same
|
|
372
|
+
// branch through the door the code really uses.
|
|
373
|
+
const savedFn = removalsPort.confirmedRemovalsForTasks;
|
|
374
|
+
delete removalsPort.confirmedRemovalsForTasks;
|
|
375
|
+
try {
|
|
376
|
+
const r = await render([DEAD]);
|
|
377
|
+
const g = r.glyph(601);
|
|
378
|
+
assert.notEqual(g.object, 'remnant', 'an unread removal must never be drawn as a confirmed one');
|
|
379
|
+
assert.equal(g.lit, 1, 'the glyph stays FULL — the doubt goes in words, not in the picture');
|
|
380
|
+
assert.match(r.label(601), /unknown/, 'and the words say the share is unknown');
|
|
381
|
+
assert.ok(g.d > 9, 'the size, which never depended on the removal facts, is still drawn');
|
|
382
|
+
} finally {
|
|
383
|
+
removalsPort.confirmedRemovalsForTasks = savedFn;
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await test('the object comes from the LEDGER, not from the status the row carries', async () => {
|
|
388
|
+
reset();
|
|
389
|
+
killAll();
|
|
390
|
+
// Same status as the live twin — `promoted`. Only the ledger differs, and the
|
|
391
|
+
// ledger is what decides. A resolver reading status would call both stars.
|
|
392
|
+
const r = await render([DEAD, LIVE]);
|
|
393
|
+
assert.equal(r.rows[0].status, r.rows[1].status, 'precondition: the two statuses agree');
|
|
394
|
+
assert.equal(r.glyph(601).object, 'remnant');
|
|
395
|
+
assert.equal(r.glyph(602).object, 'star');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
console.log('');
|
|
399
|
+
summary();
|