@bongos/core 1.19.922 → 1.19.924

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,399 @@
1
+ // tests/idea_state.mjs
2
+ //
3
+ // task 1004103 (BV2.R17, goal 1000095, criterion `ideator-sky-tells-the-truth`)
4
+ // — THE FIVE STATES resolve from ledger facts. Spec
5
+ // docs/specs/bongos-v2-ideator-surface.md decisions 4, 19 and 20; the object
6
+ // vocabulary is docs/design/ideator-surface-direction.md §"The five objects".
7
+ //
8
+ // WHAT CAN GO SILENTLY WRONG HERE, which is what this file is aimed at:
9
+ //
10
+ // * A PROTOSTAR DRAWN AS A REMNANT. An idea that has earned nothing comes back
11
+ // from brightness as `share: 1, hasWork: false` — LIT. Resolve the state off
12
+ // the share alone and a freshly-filed thought is indistinguishable from one
13
+ // whose version the project tore out, which is the exact distinction the two
14
+ // channels exist to draw. §2.
15
+ // * "COULD NOT LOOK" READ AS "EVERYTHING WAS REMOVED". `share: null` means the
16
+ // removal facts were unreadable, not that nothing survived. A resolver that
17
+ // treats a falsy share as dead turns one unmounted module into a sky of
18
+ // hollow rings. §3.
19
+ // * A COLD BODY WITH MONEY ON IT. The design defines a cold body as having no
20
+ // size at all; triage can discard a duplicate whose twin already paid, so
21
+ // the property only holds if work is tested BEFORE the status. §4.
22
+ // * A MERGED THOUGHT DRAWN TWICE. It keeps its own size (trail-size.js is
23
+ // emphatic), so an order that lets work win would draw a satellite as an
24
+ // independent star beside the thought that absorbed it. §5.
25
+ // * THE WELL READ OFF THE HINT. `suggested_goal_id` names a goal and is
26
+ // strictly advisory — the routing hint on a filing that did NOT convert. Read
27
+ // it as a home and a protostar joins a nod queue nobody can act on, because
28
+ // no task exists to promote. §6.
29
+ // * THE BINDING FACT NEVER REACHING THE RESOLVER. The state can only tell a
30
+ // well from open space if the trail actually selects `goal_id`; drop it and
31
+ // every thought in the instance quietly reads as homeless. §7 reads the
32
+ // statement.
33
+ // * THE ATTACH NEVER BEING CALLED. A resolver nothing invokes passes its whole
34
+ // suite and reaches no surface — this module's own history (a dead cascade
35
+ // rule) says so. §8 drives the real attacher and §9 holds the route wiring.
36
+ // * THE PAGE PAYING TWICE. Brightness takes its share of the same `byTask`
37
+ // breakdown size already computed; not handing it back doubles the ledger
38
+ // reads on every trail view, with nothing rendering differently. §8.
39
+ //
40
+ // DB-free: the reward port is a stub and the resolver is pure.
41
+ //
42
+ // Run: node tests/idea_state.mjs
43
+
44
+ import { strict as assert } from 'node:assert';
45
+ import { createRequire } from 'node:module';
46
+ import fs from 'node:fs';
47
+ import path from 'node:path';
48
+ import { fileURLToPath } from 'node:url';
49
+
50
+ const require = createRequire(import.meta.url);
51
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
52
+
53
+ const api = require('../src/module-api.js');
54
+ const state = require('../modules/ideas/idea-state.js');
55
+ const reward = require('../modules/economy/reward.js');
56
+
57
+ const { resolveIdeaState, taskFactsFromIdeaRow, attachStateToTrail, IDEA_STATES, IDEA_POSITIONS } = state;
58
+
59
+ let passed = 0;
60
+ let failed = 0;
61
+ async function test(name, fn) {
62
+ try { await fn(); passed += 1; console.log(`ok ${name}`); }
63
+ catch (err) { failed += 1; console.log(`FAIL ${name}\n ${err && (err.message || JSON.stringify(err))}`); }
64
+ }
65
+
66
+ // A lit idea that has been paid: what brightness returns for a live descent.
67
+ const LIVE = { hasWork: true, share: 1, attributable: true };
68
+ // The same descent, every piece of it confirmed removed.
69
+ const DEAD = { hasWork: true, share: 0, attributable: true };
70
+ // A thought that has earned nothing — LIT, because nothing has been taken away.
71
+ const UNIGNITED = { hasWork: false, share: 1, attributable: true };
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // 1. the five objects, each from its facts
75
+ // ---------------------------------------------------------------------------
76
+
77
+ await test('§1 star — it caused work that is still alive', () => {
78
+ const s = resolveIdeaState({ status: 'promoted', ...LIVE });
79
+ assert.equal(s.state, IDEA_STATES.STAR);
80
+ assert.equal(s.brightness, 1);
81
+ });
82
+
83
+ await test('§1 star — partly removed is DIM, not out', () => {
84
+ const s = resolveIdeaState({ status: 'promoted', hasWork: true, share: 0.4, attributable: true });
85
+ assert.equal(s.state, IDEA_STATES.STAR, 'graded, not binary — it goes out only when nothing survives');
86
+ assert.equal(s.brightness, 0.4, 'and the surface reads the dimness off the same answer');
87
+ });
88
+
89
+ await test('§1 protostar — filed, not yet ignited', () => {
90
+ assert.equal(resolveIdeaState({ status: 'open', ...UNIGNITED }).state, IDEA_STATES.PROTOSTAR);
91
+ });
92
+
93
+ await test('§1 protostar — PROMOTED but unshipped is still unignited', () => {
94
+ // Ignition is being paid for something, not being converted into a row. A
95
+ // status-first resolver calls this a star and the sky claims work that has not
96
+ // happened.
97
+ const s = resolveIdeaState({
98
+ status: 'promoted', ...UNIGNITED,
99
+ tasks: [{ taskId: '5', goalId: '9', status: 'ready' }],
100
+ });
101
+ assert.equal(s.state, IDEA_STATES.PROTOSTAR);
102
+ });
103
+
104
+ await test('§1 satellite — folded into another thought', () => {
105
+ assert.equal(resolveIdeaState({ status: 'merged', ...UNIGNITED }).state, IDEA_STATES.SATELLITE);
106
+ });
107
+
108
+ await test('§1 remnant — it burned, then the project removed it', () => {
109
+ const s = resolveIdeaState({ status: 'promoted', ...DEAD });
110
+ assert.equal(s.state, IDEA_STATES.REMNANT);
111
+ assert.equal(s.hasWork, true, 'and it KEEPS its size — large and dead is the whole point');
112
+ });
113
+
114
+ await test('§1 cold body — declined at triage, having caused nothing', () => {
115
+ const s = resolveIdeaState({ status: 'discarded', ...UNIGNITED });
116
+ assert.equal(s.state, IDEA_STATES.COLD_BODY);
117
+ assert.equal(s.hasWork, false);
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // 2. zero over zero — the sharpest edge brightness hands over
122
+ // ---------------------------------------------------------------------------
123
+
124
+ await test('§2 an idea that earned nothing is a PROTOSTAR, never a remnant', () => {
125
+ // brightness returns share 1 / hasWork false for it on purpose. The failure
126
+ // this pins is a resolver that asks "is the share zero" of an idea whose share
127
+ // was never a fraction of anything.
128
+ const fresh = resolveIdeaState({ status: 'open', hasWork: false, share: 1, attributable: true });
129
+ const torn = resolveIdeaState({ status: 'promoted', hasWork: true, share: 0, attributable: true });
130
+ assert.equal(fresh.state, IDEA_STATES.PROTOSTAR);
131
+ assert.equal(torn.state, IDEA_STATES.REMNANT);
132
+ assert.notEqual(fresh.state, torn.state, 'these two must never be the same object');
133
+ });
134
+
135
+ await test('§2 hasWork is what separates them, not the share', () => {
136
+ // Same share on both sides; only hasWork differs.
137
+ const a = resolveIdeaState({ status: 'open', hasWork: false, share: 0, attributable: true });
138
+ const b = resolveIdeaState({ status: 'open', hasWork: true, share: 0, attributable: true });
139
+ assert.equal(a.state, IDEA_STATES.PROTOSTAR);
140
+ assert.equal(b.state, IDEA_STATES.REMNANT);
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // 3. a remnant needs EVIDENCE of removal, never the absence of an answer
145
+ // ---------------------------------------------------------------------------
146
+
147
+ await test('§3 unreadable removal facts give a STAR, flagged unattributable', () => {
148
+ const s = resolveIdeaState({ status: 'promoted', hasWork: true, share: null, attributable: false });
149
+ assert.equal(s.state, IDEA_STATES.STAR, 'we could not look — that is not evidence the work is gone');
150
+ assert.equal(s.attributable, false, 'and the surface is TOLD, so it can say "unknown" rather than "full"');
151
+ assert.equal(s.brightness, null);
152
+ });
153
+
154
+ await test('§3 a share of 0 with attributable false is still not a remnant', () => {
155
+ // Defensive: brightness never emits this pair, and a resolver that reads the
156
+ // share without the flag would call an unreadable idea dead the day it did.
157
+ const s = resolveIdeaState({ status: 'promoted', hasWork: true, share: 0, attributable: false });
158
+ assert.equal(s.state, IDEA_STATES.STAR);
159
+ });
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // 4. work beats a discard — the cold body's stated property
163
+ // ---------------------------------------------------------------------------
164
+
165
+ await test('§4 a DISCARDED idea that caused paid work is not a cold body', () => {
166
+ // Reachable: triage discards a duplicate whose twin already routed and paid,
167
+ // and updateIdea never touches the ledger. A cold body "has no size at all" —
168
+ // testing work first is what keeps that true by construction.
169
+ const s = resolveIdeaState({ status: 'discarded', ...LIVE });
170
+ assert.equal(s.state, IDEA_STATES.STAR);
171
+ });
172
+
173
+ await test('§4 a discarded idea whose work was then removed is a remnant', () => {
174
+ assert.equal(resolveIdeaState({ status: 'discarded', ...DEAD }).state, IDEA_STATES.REMNANT);
175
+ });
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // 5. merged wins over everything — one thought, one point in the sky
179
+ // ---------------------------------------------------------------------------
180
+
181
+ await test('§5 a MERGED idea with money of its own is still a satellite', () => {
182
+ // It keeps its own size (trail-size.js), and that stays true — this changes
183
+ // what the thought IS, not what it earned. Letting work win here draws it as an
184
+ // independent star beside the thought that absorbed it.
185
+ const s = resolveIdeaState({ status: 'merged', ...LIVE });
186
+ assert.equal(s.state, IDEA_STATES.SATELLITE);
187
+ assert.equal(s.hasWork, true, 'and its size is reported, not erased');
188
+ });
189
+
190
+ await test('§5 a merged idea whose work was removed is a satellite too', () => {
191
+ assert.equal(resolveIdeaState({ status: 'merged', ...DEAD }).state, IDEA_STATES.SATELLITE);
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // 6. position — the distinction the object alone cannot carry
196
+ // ---------------------------------------------------------------------------
197
+
198
+ await test('§6 a protostar in a WELL awaits a nod', () => {
199
+ const s = resolveIdeaState({
200
+ status: 'open', ...UNIGNITED,
201
+ tasks: [{ taskId: '5', goalId: '77', status: 'backlog' }],
202
+ });
203
+ assert.equal(s.position, IDEA_POSITIONS.WELL);
204
+ assert.equal(s.wellGoalId, '77');
205
+ assert.equal(s.awaitingNod, true, 'this is what makes the nod queue something a person can SEE');
206
+ });
207
+
208
+ await test('§6 a protostar in OPEN SPACE has nothing holding it', () => {
209
+ const s = resolveIdeaState({ status: 'open', ...UNIGNITED, tasks: [] });
210
+ assert.equal(s.position, IDEA_POSITIONS.OPEN_SPACE);
211
+ assert.equal(s.wellGoalId, null);
212
+ assert.equal(s.awaitingNod, false);
213
+ });
214
+
215
+ await test('§6 suggested_goal_id is a HINT and never a well', () => {
216
+ // It names a goal and it is strictly advisory: the routing hint on a filing
217
+ // that did not convert. Reading it here puts a thought in a nod queue with no
218
+ // task to promote.
219
+ const row = { id: '1', status: 'open', suggested_goal_id: '77', lineage_tasks: [] };
220
+ const s = resolveIdeaState({ status: row.status, ...UNIGNITED, tasks: taskFactsFromIdeaRow(row) });
221
+ assert.equal(s.position, IDEA_POSITIONS.OPEN_SPACE);
222
+ assert.equal(s.wellGoalId, null);
223
+ });
224
+
225
+ await test('§6 a task in a goal that is past the nod is a well, not a queue', () => {
226
+ const s = resolveIdeaState({
227
+ status: 'promoted', ...LIVE,
228
+ tasks: [{ taskId: '5', goalId: '77', status: 'shipped' }],
229
+ });
230
+ assert.equal(s.position, IDEA_POSITIONS.WELL);
231
+ assert.equal(s.awaitingNod, false);
232
+ });
233
+
234
+ await test('§6 the nod is asked of EVERY task, not only the one that named the well', () => {
235
+ // One idea, two tasks: one promoted, one still at backlog. Something still
236
+ // awaits a person, and a resolver that only inspected the first task misses it.
237
+ const s = resolveIdeaState({
238
+ status: 'promoted', ...LIVE,
239
+ tasks: [
240
+ { taskId: '5', goalId: '77', status: 'ready' },
241
+ { taskId: '6', goalId: '77', status: 'backlog' },
242
+ ],
243
+ });
244
+ assert.equal(s.awaitingNod, true);
245
+ });
246
+
247
+ await test('§6 a backlog task with NO goal is not a nod queue entry', () => {
248
+ // The nod is a promote within a goal; a goal-less backlog row has no well to
249
+ // be promoted inside.
250
+ const s = resolveIdeaState({
251
+ status: 'open', ...UNIGNITED,
252
+ tasks: [{ taskId: '5', goalId: null, status: 'backlog' }],
253
+ });
254
+ assert.equal(s.awaitingNod, false);
255
+ assert.equal(s.position, IDEA_POSITIONS.OPEN_SPACE);
256
+ });
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // 7. the facts off a real trail row — and the statement that carries them
260
+ // ---------------------------------------------------------------------------
261
+
262
+ await test('§7 lineage tasks and the legacy column are BOTH read, and never twice', () => {
263
+ // lineage_tasks is the complete answer since ideas_task_lineage; the legacy
264
+ // promoted_to_task_id is the only answer for promotions that predate it. An
265
+ // idea carrying both must not count that task twice.
266
+ const both = taskFactsFromIdeaRow({
267
+ promoted_to_task_id: '5', promoted_task_status: 'ready', promoted_task_goal_id: '77',
268
+ lineage_tasks: [{ task_id: 5, status: 'ready', goal_id: 77 }],
269
+ });
270
+ assert.equal(both.length, 1, 'the same task named twice is one task');
271
+ const legacyOnly = taskFactsFromIdeaRow({
272
+ promoted_to_task_id: '5', promoted_task_status: 'backlog', promoted_task_goal_id: '77',
273
+ lineage_tasks: [],
274
+ });
275
+ assert.equal(legacyOnly.length, 1);
276
+ assert.equal(legacyOnly[0].goalId, '77', 'a pre-lineage promotion still has a home');
277
+ });
278
+
279
+ await test('§7 ids survive as strings — a bigint is never coerced', () => {
280
+ const facts = taskFactsFromIdeaRow({ lineage_tasks: [{ task_id: 9007199254740993, goal_id: '77', status: 'ready' }] });
281
+ assert.equal(facts[0].goalId, '77');
282
+ assert.equal(typeof facts[0].taskId, 'string');
283
+ });
284
+
285
+ await test('§7 the trail SELECTS goal_id on both task joins', () => {
286
+ // The binding fact. Without it every thought reads as homeless and the
287
+ // well-vs-open-space distinction silently disappears — nothing errors, the
288
+ // nod queue is simply empty forever.
289
+ const sql = fs.readFileSync(path.join(ROOT, 'modules/ideas/trail.js'), 'utf8');
290
+ assert.match(sql, /'goal_id',\s*lt\.goal_id/, 'the lineage LATERAL must carry the goal');
291
+ assert.match(sql, /pt\.goal_id\s+AS\s+promoted_task_goal_id/, 'and so must the legacy join');
292
+ });
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // 8. the attach — including the branch where the reward port is not there
296
+ // ---------------------------------------------------------------------------
297
+
298
+ await test('§8 with the reward port absent, the states still resolve — flagged', () => {
299
+ // Run before the port is registered: a port is single-provider and cannot be
300
+ // unregistered, so this is the only honest place for the absent branch. An
301
+ // unmounted economy must not empty the sky.
302
+ assert.equal(api.hasProvider('reward'), false, 'nothing has registered it yet');
303
+ return attachStateToTrail([{ id: '7', status: 'open', lineage_tasks: [] }]).then((ideas) => {
304
+ assert.equal(ideas[0].state.state, IDEA_STATES.PROTOSTAR);
305
+ assert.equal(ideas[0].state.attributable, false, 'the money could not be read, and it says so');
306
+ });
307
+ });
308
+
309
+ // The port stub. It records what it was ASKED, which is what §8's one-read
310
+ // property is pinned on.
311
+ const asked = [];
312
+ api.registerProvider('reward', {
313
+ ...reward,
314
+ async ideaBrightnesses(ids, opts = {}) {
315
+ asked.push({ ids: ids.map(String), sizes: opts.sizes || null });
316
+ const out = new Map();
317
+ for (const id of ids.map(String)) {
318
+ if (id === '7') out.set(id, { ...LIVE, credits: 40, removedTasks: [] });
319
+ if (id === '8') out.set(id, { ...DEAD, credits: 40, removedTasks: [{ taskId: '500', credits: 40 }] });
320
+ if (id === '9') out.set(id, { ...UNIGNITED, credits: 0, removedTasks: [] });
321
+ }
322
+ return out;
323
+ },
324
+ });
325
+
326
+ await test('§8 the attach hangs one state per idea, off the real brightness', async () => {
327
+ const ideas = [
328
+ { id: '7', status: 'promoted', lineage_tasks: [{ task_id: 500, goal_id: 77, status: 'shipped' }] },
329
+ { id: '8', status: 'promoted', lineage_tasks: [{ task_id: 500, goal_id: 77, status: 'shipped' }] },
330
+ { id: '9', status: 'open', lineage_tasks: [] },
331
+ ];
332
+ await attachStateToTrail(ideas);
333
+ assert.equal(ideas[0].state.state, IDEA_STATES.STAR);
334
+ assert.equal(ideas[1].state.state, IDEA_STATES.REMNANT);
335
+ assert.equal(ideas[2].state.state, IDEA_STATES.PROTOSTAR);
336
+ assert.equal(ideas[2].state.position, IDEA_POSITIONS.OPEN_SPACE);
337
+ });
338
+
339
+ await test('§8 an already-computed size is HANDED BACK, so the page reads once', async () => {
340
+ // attachSizeToTrail leaves economy's own byTask breakdown on the row, and
341
+ // brightness takes its share of exactly that. Not passing it makes every trail
342
+ // view pay for the ledger twice, and nothing renders differently.
343
+ asked.length = 0;
344
+ const withSize = [
345
+ { id: '7', status: 'promoted', lineage_tasks: [], size: { credits: 40, byTask: new Map([['500', 40]]) } },
346
+ ];
347
+ await attachStateToTrail(withSize);
348
+ assert.ok(asked[0].sizes instanceof Map, 'the breakdown must travel with the request');
349
+ assert.equal(asked[0].sizes.get('7').credits, 40);
350
+
351
+ // And a page where the sizes are NOT all in hand asks for none of them, rather
352
+ // than handing over a partial map: brightness reads a missing entry as "no
353
+ // work", which would draw a paid star as a protostar.
354
+ asked.length = 0;
355
+ await attachStateToTrail([
356
+ { id: '7', status: 'promoted', lineage_tasks: [], size: { credits: 40, byTask: new Map() } },
357
+ { id: '8', status: 'promoted', lineage_tasks: [], size: null },
358
+ ]);
359
+ assert.equal(asked[0].sizes, null, 'all of them or none — a partial map silently unlights a star');
360
+ });
361
+
362
+ await test('§8 an empty page costs nothing at all', async () => {
363
+ asked.length = 0;
364
+ assert.deepEqual(await attachStateToTrail([]), []);
365
+ assert.equal(asked.length, 0);
366
+ });
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // 9. the wiring — a resolver nothing calls reaches no surface
370
+ // ---------------------------------------------------------------------------
371
+
372
+ await test('§9 the trail route attaches the state, AFTER the size', async () => {
373
+ const route = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/inbox.js'), 'utf8');
374
+ assert.match(route, /require\('\.\.\/idea-state'\)/, 'the route must import it');
375
+ const size = route.indexOf('await attachSizeToTrail(ideas)');
376
+ const stateAt = route.indexOf('await attachStateToTrail(ideas)');
377
+ assert.ok(size > 0 && stateAt > 0, 'both attaches must be called on the trail');
378
+ assert.ok(stateAt > size, 'state reads the byTask breakdown size leaves behind, so it runs second');
379
+ });
380
+
381
+ await test('§9 the reward port exposes brightness at all', async () => {
382
+ // The ideas module may only reach credit_log through this port (ADR 0083 rule
383
+ // 1). Shipping brightness without putting it on the port leaves the module able
384
+ // to draw how big a thought is and unable to say whether it is still alive.
385
+ assert.equal(typeof reward.ideaBrightnesses, 'function');
386
+ assert.equal(typeof reward.ideaBrightness, 'function');
387
+ });
388
+
389
+ await test('§9 the five objects are spelled ONCE, and the module exports them', async () => {
390
+ // A renderer switching on hand-copied strings drifts by a hyphen and draws
391
+ // nothing, silently.
392
+ assert.deepEqual(
393
+ [...state.IDEA_STATE_VALUES].sort(),
394
+ ['cold-body', 'protostar', 'remnant', 'satellite', 'star']
395
+ );
396
+ });
397
+
398
+ console.log(`\n${passed} passed, ${failed} failed`);
399
+ process.exit(failed === 0 ? 0 : 1);