@bongos/core 1.19.900 → 1.19.902
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 -24
- package/docs/copy-inventory.md +8 -7
- package/docs/copy-registry.json +17 -8
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/credits.js +216 -9
- package/modules/economy/reward-policy.js +34 -1
- package/modules/economy/reward.js +4 -0
- package/modules/hall-ui/public/profile.js +51 -5
- package/modules/ideas/descent.js +103 -32
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/inbox.js +36 -0
- package/modules/ideas/trail-descent.js +224 -0
- package/modules/lifecycle/db-ship.js +15 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/helpers.mjs +137 -0
- package/tests/idea_descent_ship_credit.mjs +481 -0
- package/tests/idea_task_descent.mjs +9 -2
- package/tests/idea_trail_descent.mjs +399 -0
- package/tests/profile_activity.mjs +4 -122
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.902'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/helpers.mjs
CHANGED
|
@@ -72,6 +72,7 @@ import path from 'node:path';
|
|
|
72
72
|
import { fileURLToPath } from 'node:url';
|
|
73
73
|
import { createRequire } from 'node:module';
|
|
74
74
|
import { execFileSync } from 'node:child_process';
|
|
75
|
+
import vm from 'node:vm';
|
|
75
76
|
|
|
76
77
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
77
78
|
|
|
@@ -495,3 +496,139 @@ export function shallowCloneReason(root = ROOT) {
|
|
|
495
496
|
return 'shallow clone — full history is unavailable here, so this case cannot be evaluated '
|
|
496
497
|
+ '(git fetch --unshallow to run it)';
|
|
497
498
|
}
|
|
499
|
+
|
|
500
|
+
// bootProfilePage — boot the REAL modules/hall-ui/public/profile.js in a vm
|
|
501
|
+
// against a stub DOM and a stub OTBKit, and hand back what it RENDERED plus
|
|
502
|
+
// every request it made.
|
|
503
|
+
//
|
|
504
|
+
// Shared because two suites now assert on that HTML rather than on the data
|
|
505
|
+
// behind it: tests/profile_activity.mjs (the four idea outcomes, task 1003281)
|
|
506
|
+
// and tests/idea_trail_descent.mjs (the descent under them, task 1004089). The
|
|
507
|
+
// second exists BECAUSE the first finding inherited from the abandoned task
|
|
508
|
+
// 1004054 was that the prior attempt tested the server-side builder only. A
|
|
509
|
+
// second hand-copied harness would be a second thing to keep in step with a
|
|
510
|
+
// page that changes, so the harness moved here and the fixture stayed with
|
|
511
|
+
// each caller — is the /inbox/by-builder/ payload to answer with.
|
|
512
|
+
const PROFILE_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
|
|
513
|
+
// Boot the REAL profile.js against a stub DOM + a stub OTBKit that captures each
|
|
514
|
+
// ledger's rowHtml, and return what it rendered plus every request it made.
|
|
515
|
+
export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
|
|
516
|
+
const els = new Map();
|
|
517
|
+
const makeEl = () => ({
|
|
518
|
+
dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
|
|
519
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
520
|
+
setAttribute() {}, getAttribute() { return null; },
|
|
521
|
+
addEventListener() {},
|
|
522
|
+
querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
|
|
523
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
524
|
+
});
|
|
525
|
+
const documentObj = {
|
|
526
|
+
title: '',
|
|
527
|
+
body: { dataset: { page: 'profile' } },
|
|
528
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
529
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
530
|
+
querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
|
|
531
|
+
querySelectorAll() { return []; },
|
|
532
|
+
createElement() { return makeEl(); },
|
|
533
|
+
addEventListener() {},
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const requests = [];
|
|
537
|
+
const client = {
|
|
538
|
+
request: async (method, url) => {
|
|
539
|
+
requests.push(url);
|
|
540
|
+
if (url.endsWith('/me')) {
|
|
541
|
+
return signedIn
|
|
542
|
+
? { status: 200, ok: true, data: { builder: { id: 95, rank: 'metic' } } }
|
|
543
|
+
: { status: 401, ok: false, data: null };
|
|
544
|
+
}
|
|
545
|
+
if (/\/builders\/\d+\/profile$/.test(url)) {
|
|
546
|
+
return { status: 200, ok: true, data: { builder: { id: 95, github_login: 'jaxri', rank: 'metic' }, shipped_count: 4, achievements: [], recent_ships: [] } };
|
|
547
|
+
}
|
|
548
|
+
if (url.includes('/activity?')) {
|
|
549
|
+
const isGoals = url.includes('section=goals');
|
|
550
|
+
return { status: 200, ok: true, data: { rows: isGoals ? [{ id: 7, title: 'A goal', disciplines: ['artist'], task_shipped: 1, task_total: 2 }] : [{ id: 42, title: 'A work', discipline: 'engineer', shipped_at: '2026-08-01T00:00:00Z' }], total: 1 } };
|
|
551
|
+
}
|
|
552
|
+
if (url.includes('/inbox/by-builder/')) {
|
|
553
|
+
return { status: 200, ok: true, data: { ideas: trail, total: trail.length } };
|
|
554
|
+
}
|
|
555
|
+
return { status: 404, ok: false, data: null };
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// The captured ledgers, keyed by the mount element they were given.
|
|
560
|
+
const ledgers = new Map();
|
|
561
|
+
let tabsOnChange = null;
|
|
562
|
+
const kit = {
|
|
563
|
+
makePager: (prefix, key, opts = {}) => ({
|
|
564
|
+
prefix, key, page: 1, perPage: opts.defaultPerPage || 25,
|
|
565
|
+
perPageOptions: [10, 25], minPerPage: opts.minPerPage ?? 10,
|
|
566
|
+
}),
|
|
567
|
+
pageOffset: (p) => Math.max(0, (p.page - 1) * p.perPage),
|
|
568
|
+
makeLedger: ({ mount, rowHtml }) => {
|
|
569
|
+
const rec = { rows: [], total: 0, html: '' };
|
|
570
|
+
const ledger = {
|
|
571
|
+
render(rows, opts = {}) {
|
|
572
|
+
rec.rows = rows || [];
|
|
573
|
+
rec.total = opts.total || 0;
|
|
574
|
+
rec.html = rec.rows.map((r, i) => rowHtml(r, i)).join('');
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
ledgers.set(mount, rec);
|
|
578
|
+
return ledger;
|
|
579
|
+
},
|
|
580
|
+
makeTabs: ({ tabs, onChange }) => {
|
|
581
|
+
let current = tabs[0].id;
|
|
582
|
+
tabsOnChange = onChange;
|
|
583
|
+
onChange(current);
|
|
584
|
+
return { active: () => current, activate(id) { current = id; onChange(id); } };
|
|
585
|
+
},
|
|
586
|
+
shipVisualThumbHtml: () => '',
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const windowObj = {
|
|
590
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
591
|
+
BongosClient: { createClient: () => client },
|
|
592
|
+
OTBKit: kit,
|
|
593
|
+
OTB: {
|
|
594
|
+
// task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
|
|
595
|
+
// the same reason categoryChip is: an undefined helper throws mid-render.
|
|
596
|
+
roleAxisLabel: () => 'Role',
|
|
597
|
+
roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
598
|
+
roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
|
|
599
|
+
roleIsUnset: (d) => !d || d === 'unclassified',
|
|
600
|
+
ROLE_UNSET: 'unclassified',
|
|
601
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
602
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
603
|
+
fmtNum: (n) => String(n ?? 0),
|
|
604
|
+
fmtDate: (iso) => String(iso ?? '').slice(0, 10),
|
|
605
|
+
rankLabel: (r) => r,
|
|
606
|
+
toast() {},
|
|
607
|
+
},
|
|
608
|
+
location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/profile' },
|
|
609
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
610
|
+
addEventListener() {},
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const sandbox = {
|
|
614
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
615
|
+
console, setTimeout, clearTimeout, URLSearchParams,
|
|
616
|
+
};
|
|
617
|
+
sandbox.globalThis = sandbox;
|
|
618
|
+
vm.createContext(sandbox);
|
|
619
|
+
vm.runInContext(PROFILE_JS, sandbox, { filename: 'profile.js' });
|
|
620
|
+
|
|
621
|
+
const htmlFor = (mountId) => {
|
|
622
|
+
const rec = ledgers.get(documentObj.getElementById(mountId));
|
|
623
|
+
return rec ? rec.html : null;
|
|
624
|
+
};
|
|
625
|
+
return {
|
|
626
|
+
requests,
|
|
627
|
+
htmlFor,
|
|
628
|
+
switchCraft: (id) => { if (tabsOnChange) tabsOnChange(id); },
|
|
629
|
+
el: (id) => documentObj.getElementById(id),
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// One microtask turn — profile.js boots through a chain of awaits.
|
|
634
|
+
export const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
// tests/idea_descent_ship_credit.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004088 (BV2.R09, goal 1000095) — the ideator payout follows the
|
|
4
|
+
// task→task descent and decays by depth. ADR 0312; spec
|
|
5
|
+
// docs/specs/bongos-v2-ideator-surface.md decisions 6, 7 and 16.
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS FILE IS SHARP. This is money. The failure modes are all silent —
|
|
8
|
+
// nothing renders wrong, nobody sees an error, an origin is simply paid the
|
|
9
|
+
// wrong amount or paid twice:
|
|
10
|
+
//
|
|
11
|
+
// * PAID TWICE. A merge makes the descent a DAG, so one origin can sit above
|
|
12
|
+
// a shipping task by two routes of different length, and two ancestor tasks
|
|
13
|
+
// can share one source idea. Each of those is a duplicate payment that
|
|
14
|
+
// looks exactly like a correct one in the ledger.
|
|
15
|
+
// * FARMED. Spec decision 7 draws the line at descendant WORK: a separate
|
|
16
|
+
// thought that merely cites an origin must not pay it. The descent surface
|
|
17
|
+
// deliberately displays MORE than the payout counts, so the payout path has
|
|
18
|
+
// to be provably free of idea→idea relations.
|
|
19
|
+
// * DRIFTED. The direct payout and this one must agree about what an idea's
|
|
20
|
+
// work is worth before depth, or the same idea pays differently depending
|
|
21
|
+
// on which hop the ship happened at.
|
|
22
|
+
// * DIVERGED FROM TODAY. "Existing first-hop behaviour preserved" is a
|
|
23
|
+
// done-when, so the no-port and no-ancestor paths must be provably inert.
|
|
24
|
+
//
|
|
25
|
+
// DB-free: the client is a stub whose credit_log is a set of reasons and whose
|
|
26
|
+
// lineage SELECT answers from a fixture, and the descent port is registered
|
|
27
|
+
// with a stub walk — so the decay arithmetic, the de-duplication and the
|
|
28
|
+
// idempotency are proven without a Postgres. A real-Postgres suite would
|
|
29
|
+
// SELF-SKIP on a machine with no database, and a skipped case prints ok and
|
|
30
|
+
// counts as passed.
|
|
31
|
+
//
|
|
32
|
+
// Run: node tests/idea_descent_ship_credit.mjs
|
|
33
|
+
|
|
34
|
+
import { strict as assert } from 'node:assert';
|
|
35
|
+
import { createRequire } from 'node:module';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
const credits = require('../modules/economy/credits.js');
|
|
43
|
+
const api = require('../src/module-api.js');
|
|
44
|
+
const { rewardPolicy, IDEA_DESCENT_DECAY } = require('../modules/economy/reward-policy.js');
|
|
45
|
+
|
|
46
|
+
let passed = 0;
|
|
47
|
+
let failed = 0;
|
|
48
|
+
async function test(name, fn) {
|
|
49
|
+
try { await fn(); passed += 1; console.log(`ok ${name}`); }
|
|
50
|
+
catch (err) { failed += 1; console.log(`FAIL ${name}\n ${err && (err.message || JSON.stringify(err))}`); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// the harness
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
// The `ideas.descent` port is SINGLE-PROVIDER and has no unregister (a port is
|
|
58
|
+
// a required capability, not a last-write-wins slot — src/module-seams.js). So
|
|
59
|
+
// the stub walk is registered exactly once, and each test varies what it
|
|
60
|
+
// returns rather than re-registering. The one case that needs the port ABSENT
|
|
61
|
+
// runs first, before the registration below.
|
|
62
|
+
let ancestorsFixture = [];
|
|
63
|
+
function registerStubDescentPort() {
|
|
64
|
+
api.registerProvider('ideas.descent', {
|
|
65
|
+
ancestorsOfTask: async () => ancestorsFixture,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const withDescentPort = async (ancestors, fn) => {
|
|
69
|
+
ancestorsFixture = ancestors;
|
|
70
|
+
try { return await fn(); } finally { ancestorsFixture = []; }
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// `lineage` maps an ancestor task id to the row the DISTINCT ON read returns.
|
|
74
|
+
function stubClient(lineage = {}) {
|
|
75
|
+
const credited = [];
|
|
76
|
+
const log = [];
|
|
77
|
+
return {
|
|
78
|
+
credited,
|
|
79
|
+
log,
|
|
80
|
+
async query(sql, params) {
|
|
81
|
+
log.push({ sql, params });
|
|
82
|
+
if (/FROM ideas_task_lineage/i.test(sql)) {
|
|
83
|
+
const ids = (params[0] || []).map(String);
|
|
84
|
+
return { rows: ids.map((id) => lineage[id]).filter(Boolean) };
|
|
85
|
+
}
|
|
86
|
+
if (/INSERT INTO credit_log/i.test(sql)) {
|
|
87
|
+
// THE STUB MUST DEDUPE THE WAY THE REAL SQL DOES — on (builder_id,
|
|
88
|
+
// reason), not on reason alone. bookIdeaSplit books a SPLIT as two rows
|
|
89
|
+
// sharing one reason, so a reason-only stub refuses the second payee and
|
|
90
|
+
// reports a correct 70/30 split as a duplicate. That is a stub that
|
|
91
|
+
// manufactures a failure the database would not have, and it is exactly
|
|
92
|
+
// how a split-payee test gets written to the wrong expectation.
|
|
93
|
+
assert.match(sql, /builder_id = \$1 AND reason = \$4/,
|
|
94
|
+
'the real predicate is builder-scoped; this stub mirrors it, so pin it');
|
|
95
|
+
const reason = params[3];
|
|
96
|
+
const builderId = params[0];
|
|
97
|
+
if (credited.some((r) => r.reason === reason && String(r.builder_id) === String(builderId))) {
|
|
98
|
+
return { rowCount: 0, rows: [] };
|
|
99
|
+
}
|
|
100
|
+
credited.push({ builder_id: builderId, task_id: params[1], delta: params[2], reason });
|
|
101
|
+
return { rowCount: 1, rows: [] };
|
|
102
|
+
}
|
|
103
|
+
return { rows: [] };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const src = (taskId, ideaId, authorId, extra = {}) => ({
|
|
109
|
+
task_id: String(taskId), idea_id: ideaId, captured_by: authorId,
|
|
110
|
+
grade: 'quick', completeness: null, developed_by: null, ...extra,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const ON = rewardPolicy({});
|
|
114
|
+
const BASE = credits.IDEA_TASK_SHIP_BASE;
|
|
115
|
+
const QUICK = credits.QUICK_IDEA_FACTOR;
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// 0. BEFORE the port exists — existing behaviour, preserved
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
// FIRST, and it has to be: a port is single-provider and cannot be unregistered,
|
|
122
|
+
// so the only honest way to test the absent-provider path is to test it before
|
|
123
|
+
// anything registers one.
|
|
124
|
+
await test('with NO descent port, nothing is paid and nothing is asked of the database', async () => {
|
|
125
|
+
// An instance running without the ideas module still ships tasks, and its
|
|
126
|
+
// direct payout is untouched. This is what "existing first-hop behaviour
|
|
127
|
+
// preserved" means when the module is absent.
|
|
128
|
+
assert.equal(api.hasProvider('ideas.descent'), false, 'nothing has registered the port yet');
|
|
129
|
+
const c = stubClient({});
|
|
130
|
+
const r = await credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON });
|
|
131
|
+
assert.equal(r.credited, 0);
|
|
132
|
+
assert.equal(r.reason, 'no_descent_port');
|
|
133
|
+
assert.equal(c.log.length, 0, 'no query is issued at all');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
registerStubDescentPort();
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// 1. the decay
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
await test('a task ONE hop below an idea pays it the halved share', async () => {
|
|
143
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
144
|
+
const r = await withDescentPort([{ id: 500, depth: 1, relation: 'split' }],
|
|
145
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
146
|
+
assert.equal(r.credited, Math.round(BASE * QUICK * 0.5), '5 × 0.6 × 0.5 = 2 (rounded once)');
|
|
147
|
+
assert.equal(r.payments[0].depth, 1);
|
|
148
|
+
assert.equal(c.credited[0].builder_id, 3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('each further hop halves again, and the rate is the DIAL not a constant', async () => {
|
|
152
|
+
const anc = [{ id: 500, depth: 1 }, { id: 501, depth: 2 }, { id: 502, depth: 3 }];
|
|
153
|
+
// FULL-grade origins at 100%, so all three hops still round to a credit and
|
|
154
|
+
// the halving is visible as a sequence rather than truncated by the floor.
|
|
155
|
+
const full = { grade: 'full', completeness: 100 };
|
|
156
|
+
const lineage = {
|
|
157
|
+
500: src(500, 7, 3, full), 501: src(501, 8, 4, full), 502: src(502, 9, 5, full),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const half = stubClient(lineage);
|
|
161
|
+
const rHalf = await withDescentPort(anc,
|
|
162
|
+
() => credits.awardIdeaDescentShipCredit(half, { taskId: 900, config: ON }));
|
|
163
|
+
assert.deepEqual(rHalf.payments.map((p) => p.delta), [
|
|
164
|
+
Math.round(BASE * 0.5), Math.round(BASE * 0.25), Math.round(BASE * 0.125),
|
|
165
|
+
], 'each hop is the one above it, halved');
|
|
166
|
+
|
|
167
|
+
// A project that wants a shallower descent to matter less says so, and the
|
|
168
|
+
// arithmetic follows the setting rather than a hard-coded half.
|
|
169
|
+
const steep = stubClient(lineage);
|
|
170
|
+
const rSteep = await withDescentPort(anc,
|
|
171
|
+
() => credits.awardIdeaDescentShipCredit(steep, { taskId: 900, config: rewardPolicy({ ideaDescentDecay: 0.1 }) }));
|
|
172
|
+
assert.ok(rSteep.credited < rHalf.credited, 'a steeper decay pays a descent less');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test('a descendant stops paying when its share no longer ROUNDS to a credit', async () => {
|
|
176
|
+
// The floor the spec left open (Q1) is arithmetic, not a hop limit: at the
|
|
177
|
+
// default rate a quick idea's share is under half a credit by depth 4.
|
|
178
|
+
const deep = [];
|
|
179
|
+
const lineage = {};
|
|
180
|
+
for (let d = 1; d <= 8; d += 1) {
|
|
181
|
+
deep.push({ id: 600 + d, depth: d });
|
|
182
|
+
lineage[600 + d] = src(600 + d, 100 + d, 3);
|
|
183
|
+
}
|
|
184
|
+
const c = stubClient(lineage);
|
|
185
|
+
const r = await withDescentPort(deep,
|
|
186
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
187
|
+
assert.ok(r.payments.length > 0 && r.payments.length < 8,
|
|
188
|
+
`the deep tail pays nothing and is not booked — paid ${r.payments.length} of 8`);
|
|
189
|
+
assert.ok(r.payments.every((p) => p.delta > 0), 'no zero-credit row is ever written');
|
|
190
|
+
const maxDepth = Math.max(...r.payments.map((p) => p.depth));
|
|
191
|
+
assert.ok(deep.some((a) => a.depth > maxDepth), 'and there were deeper ancestors it declined to pay');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await test('a descent of ANY depth pays, in total, no more than the direct task did', async () => {
|
|
195
|
+
// The property that makes a hop limit unnecessary (spec decision 6). Sum of
|
|
196
|
+
// (1/2)^d over every hop is 1, so even an unbounded chain of DISTINCT origins
|
|
197
|
+
// cannot, between them, take more out of one ship than the ship's own direct
|
|
198
|
+
// payout. Sixty-four hops, every one a different idea — the worst case.
|
|
199
|
+
const anc = [];
|
|
200
|
+
const lineage = {};
|
|
201
|
+
for (let d = 1; d <= 64; d += 1) {
|
|
202
|
+
anc.push({ id: 700 + d, depth: d });
|
|
203
|
+
lineage[700 + d] = src(700 + d, 200 + d, 3);
|
|
204
|
+
}
|
|
205
|
+
const c = stubClient(lineage);
|
|
206
|
+
const r = await withDescentPort(anc,
|
|
207
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
208
|
+
const direct = Math.round(BASE * QUICK);
|
|
209
|
+
assert.ok(r.credited <= direct,
|
|
210
|
+
`the whole 64-hop descent paid ${r.credited}, which must not exceed the direct share ${direct}`);
|
|
211
|
+
assert.ok(r.payments.length < 5, 'and the deep tail is not even booked');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await test('the grade factor is the SAME one the direct payout applies', async () => {
|
|
215
|
+
// A full idea at 80% one hop down: 5 × 0.80 × 0.5. If the two paths kept
|
|
216
|
+
// separate copies of the clamp they could disagree, and the same idea would
|
|
217
|
+
// be worth different amounts depending on which hop shipped.
|
|
218
|
+
const c = stubClient({ 500: src(500, 7, 3, { grade: 'full', completeness: 80 }) });
|
|
219
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }],
|
|
220
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
221
|
+
assert.equal(r.credited, Math.round(BASE * 0.8 * 0.5), '5 × 0.80 × 0.5 = 2');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await test('a SPARK someone else developed splits the decayed share 70/30, like the direct one', async () => {
|
|
225
|
+
// R147 / ADR 0185: when someone other than the author developed the idea, both
|
|
226
|
+
// ideator streams split with them. The descent payout wires splitIdeaCredit up
|
|
227
|
+
// the same way, so the split has to be DRIVEN here — a fixture that always
|
|
228
|
+
// carries developed_by: null never enters that branch, and the branch that is
|
|
229
|
+
// never entered is the one that silently pays the wrong person.
|
|
230
|
+
const c = stubClient({ 500: src(500, 7, 3, { grade: 'full', completeness: 100, developed_by: 9 }) });
|
|
231
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }],
|
|
232
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
233
|
+
const delta = Math.round(BASE * 1 * 0.5);
|
|
234
|
+
assert.equal(r.credited, delta, 'the TOTAL is the decayed share — the split divides it, never inflates it');
|
|
235
|
+
assert.equal(c.credited.length, 2, 'two ledger rows, one per payee');
|
|
236
|
+
const byBuilder = Object.fromEntries(c.credited.map((x) => [String(x.builder_id), x.delta]));
|
|
237
|
+
assert.ok(byBuilder['9'] >= byBuilder['3'], 'the developer takes the larger 70% share');
|
|
238
|
+
assert.equal(byBuilder['9'] + byBuilder['3'], delta, 'and the two shares conserve the total');
|
|
239
|
+
assert.equal(new Set(c.credited.map((x) => x.reason)).size, 1,
|
|
240
|
+
'both rows share ONE reason — which is why the dedup must be builder-scoped');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await test('a SELF-developed idea collapses to one payee at the decayed rate', async () => {
|
|
244
|
+
// splitIdeaCredit collapses this deliberately: two rows for one builder would
|
|
245
|
+
// UNDER-pay under the per-builder idempotency predicate, not double-pay.
|
|
246
|
+
const c = stubClient({ 500: src(500, 7, 3, { grade: 'full', completeness: 100, developed_by: 3 }) });
|
|
247
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }],
|
|
248
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
249
|
+
assert.equal(c.credited.length, 1, 'one payee, one row');
|
|
250
|
+
assert.equal(r.credited, Math.round(BASE * 1 * 0.5), 'paid the whole decayed share');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// 2. paid ONCE — the DAG cases
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
await test('one origin above a ship by TWO routes is paid ONCE, at the shorter', async () => {
|
|
258
|
+
// Ancestors 500 (depth 1) and 501 (depth 3) both trace to idea 7 — the shape a
|
|
259
|
+
// merge produces. Paying both would pay one origin twice for one ship.
|
|
260
|
+
const c = stubClient({ 500: src(500, 7, 3), 501: src(501, 7, 3) });
|
|
261
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }, { id: 501, depth: 3 }],
|
|
262
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
263
|
+
assert.equal(r.payments.length, 1, 'one payment, not two');
|
|
264
|
+
assert.equal(r.payments[0].depth, 1, 'at the SHORTEST depth of the two');
|
|
265
|
+
assert.equal(c.credited.length, 1, 'and one ledger row');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await test('the DIRECT payout wins over a decayed one for the same idea', async () => {
|
|
269
|
+
// Idea 7 is both this task's own source AND an ancestor's source. It must be
|
|
270
|
+
// paid the full direct share, not the full share plus a decayed one.
|
|
271
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
272
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }],
|
|
273
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, skipIdeaIds: [7], config: ON }));
|
|
274
|
+
assert.equal(r.credited, 0);
|
|
275
|
+
assert.equal(r.payments.length, 0, 'the already-settled origin is skipped, not re-booked');
|
|
276
|
+
assert.equal(c.credited.length, 0);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await test('re-running the same ship never pays an origin twice', async () => {
|
|
280
|
+
const lineage = { 500: src(500, 7, 3), 501: src(501, 8, 4) };
|
|
281
|
+
const c = stubClient(lineage);
|
|
282
|
+
const anc = [{ id: 500, depth: 1 }, { id: 501, depth: 1 }];
|
|
283
|
+
const first = await withDescentPort(anc, () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
284
|
+
assert.ok(first.credited > 0);
|
|
285
|
+
const rows = c.credited.length;
|
|
286
|
+
const again = await withDescentPort(anc, () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
287
|
+
assert.equal(again.credited, 0, 'the second run books nothing');
|
|
288
|
+
assert.equal(again.reason, 'already_paid');
|
|
289
|
+
assert.equal(c.credited.length, rows, 'and writes no further ledger rows');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await test('the reason key is per (idea, task) — the SAME key the direct payout uses', async () => {
|
|
293
|
+
// Not per (idea, task, depth): if the depth rode in the key, re-walking a
|
|
294
|
+
// descent that had changed shape would pay the same origin again for one ship.
|
|
295
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
296
|
+
await withDescentPort([{ id: 500, depth: 2 }],
|
|
297
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
298
|
+
assert.equal(c.credited[0].reason, 'idea.credit.task_ship:7:900');
|
|
299
|
+
assert.doesNotMatch(c.credited[0].reason, /:2$|depth/, 'no depth in the key');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// 3. the farming boundary — spec decision 7
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
await test('the payout reads no idea-to-idea relation — a citation cannot farm an origin', async () => {
|
|
307
|
+
// The descent SURFACE follows a merge (modules/ideas/trail-descent.js) so a
|
|
308
|
+
// merged thought still sees the work it caused. The PAYOUT must not, or filing
|
|
309
|
+
// a thought that cites an old one would mint credit from someone else's work.
|
|
310
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
311
|
+
await withDescentPort([{ id: 500, depth: 1 }],
|
|
312
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
313
|
+
for (const q of c.log) {
|
|
314
|
+
assert.doesNotMatch(q.sql, /merged_into_idea_id/,
|
|
315
|
+
'a merge is an idea-to-idea relation and must not reach the money');
|
|
316
|
+
}
|
|
317
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
|
|
318
|
+
const fn = SRC.slice(SRC.indexOf('async function awardIdeaDescentShipCredit'));
|
|
319
|
+
const body = fn.slice(0, fn.indexOf('\n}\n'));
|
|
320
|
+
assert.doesNotMatch(body, /merged_into_idea_id|attachDescentToTrail|trail-descent/,
|
|
321
|
+
'nor may the function reach for the surface read that does follow merges');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await test('it walks the TASK edge table, not the idea lineage, to find its ancestors', async () => {
|
|
325
|
+
// The ancestors come from the port (ideas_task_descent, task→task); the
|
|
326
|
+
// lineage read only NAMES the idea behind each ancestor task. Reversing those
|
|
327
|
+
// two is how a citation graph would sneak in.
|
|
328
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
|
|
329
|
+
const fn = SRC.slice(SRC.indexOf('async function awardIdeaDescentShipCredit'));
|
|
330
|
+
const body = fn.slice(0, fn.indexOf('\n}\n'));
|
|
331
|
+
assert.match(body, /ancestorsOfTask/, 'the descent comes from the walk');
|
|
332
|
+
assert.match(body, /FROM ideas_task_lineage/, 'and the lineage read only resolves the origin');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// 4. existing behaviour preserved
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
await test('a task with NO ancestors pays nothing and issues no lineage read', async () => {
|
|
340
|
+
const c = stubClient({});
|
|
341
|
+
const r = await withDescentPort([], () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
342
|
+
assert.equal(r.credited, 0);
|
|
343
|
+
assert.equal(r.reason, 'no_ancestors');
|
|
344
|
+
assert.equal(c.log.length, 0);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await test('the ideator lane being OFF suppresses the descent payout too', async () => {
|
|
348
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
349
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }],
|
|
350
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: { ideatorCredit: false } }));
|
|
351
|
+
assert.equal(r.credited, 0);
|
|
352
|
+
assert.equal(r.reason, 'ideator_lane_off');
|
|
353
|
+
assert.equal(c.log.length, 0, 'the lane gate is checked before the walk, not after it');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await test('a decay of 0 restores exactly the old first-hop-only behaviour', async () => {
|
|
357
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
358
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }],
|
|
359
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: rewardPolicy({ ideaDescentDecay: 0 }) }));
|
|
360
|
+
assert.equal(r.credited, 0);
|
|
361
|
+
assert.equal(r.reason, 'decay_off');
|
|
362
|
+
assert.equal(c.log.length, 0, 'and it costs nothing — no walk, no read');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// 5. the dial, and the wiring
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
await test('the PORT METHOD re-applies the convergence bound, not just the resolver', async () => {
|
|
370
|
+
// awardIdeaDescentShipCredit is reachable through the reward port with a
|
|
371
|
+
// caller-supplied config that never passed through rewardPolicy(). A rate of
|
|
372
|
+
// 1.5 would make a deeper ancestor pay MORE than a nearer one — the exact
|
|
373
|
+
// property the comments promise cannot happen.
|
|
374
|
+
const c = stubClient({ 500: src(500, 7, 3), 501: src(501, 8, 4) });
|
|
375
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }, { id: 501, depth: 2 }],
|
|
376
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: { ideatorCredit: true, ideaDescentDecay: 1.5 } }));
|
|
377
|
+
const deltas = r.payments.map((p) => p.delta);
|
|
378
|
+
for (let i = 1; i < deltas.length; i += 1) {
|
|
379
|
+
assert.ok(deltas[i] <= deltas[i - 1], `a deeper hop may never pay more — got ${JSON.stringify(deltas)}`);
|
|
380
|
+
}
|
|
381
|
+
assert.ok(r.credited <= Math.round(BASE * QUICK), 'and the total stays bounded by the direct share');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await test('the walk is capped at the depth the money can actually reach', async () => {
|
|
385
|
+
// Past the depth where even a 100%-complete idea rounds to nothing, every
|
|
386
|
+
// further hop is a round trip that provably pays nobody. The cap is derived
|
|
387
|
+
// from the rate, so a steeper decay walks LESS, never more.
|
|
388
|
+
let askedFor = null;
|
|
389
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
390
|
+
ancestorsFixture = [{ id: 500, depth: 1 }];
|
|
391
|
+
const port = api.resolve('ideas.descent');
|
|
392
|
+
const realWalk = port.ancestorsOfTask;
|
|
393
|
+
port.ancestorsOfTask = async (id, opts) => { askedFor = opts && opts.maxDepth; return realWalk(id, opts); };
|
|
394
|
+
try {
|
|
395
|
+
await credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON });
|
|
396
|
+
const atHalf = askedFor;
|
|
397
|
+
assert.ok(Number.isInteger(atHalf) && atHalf > 0 && atHalf < 64,
|
|
398
|
+
`the walk gets a derived cap, not the 64 backstop — got ${atHalf}`);
|
|
399
|
+
await credits.awardIdeaDescentShipCredit(c, { taskId: 901, config: rewardPolicy({ ideaDescentDecay: 0.1 }) });
|
|
400
|
+
assert.ok(askedFor < atHalf, `a steeper decay walks shallower — ${askedFor} vs ${atHalf}`);
|
|
401
|
+
} finally {
|
|
402
|
+
port.ancestorsOfTask = realWalk;
|
|
403
|
+
ancestorsFixture = [];
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
await test('the decay dial defaults to halving and refuses a non-converging rate', async () => {
|
|
408
|
+
assert.equal(rewardPolicy({}).ideaDescentDecay, IDEA_DESCENT_DECAY);
|
|
409
|
+
assert.equal(IDEA_DESCENT_DECAY, 0.5, 'spec decision 16: halving each hop');
|
|
410
|
+
assert.equal(rewardPolicy({ ideaDescentDecay: 0.25 }).ideaDescentDecay, 0.25, 'a project may set its own');
|
|
411
|
+
assert.equal(rewardPolicy({ ideaDescentDecay: 0 }).ideaDescentDecay, 0, '0 is legal — only the direct task pays');
|
|
412
|
+
for (const bad of [1, 1.5, -0.5, 'half', null, undefined, NaN]) {
|
|
413
|
+
assert.equal(rewardPolicy({ ideaDescentDecay: bad }).ideaDescentDecay, IDEA_DESCENT_DECAY,
|
|
414
|
+
`a rate of ${String(bad)} must fall back to the default, not poison an award`);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await test('the ship hook CALLS it, and hands it what the direct payout already settled', async () => {
|
|
419
|
+
// A payout nothing calls is dead code. The skipIdeaIds hand-off is what stops
|
|
420
|
+
// a direct source that is also an ancestor's source being paid twice, so the
|
|
421
|
+
// call site is pinned, not just the function.
|
|
422
|
+
const SHIP = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/db-ship.js'), 'utf8');
|
|
423
|
+
// ANCHORED ON THE CALL, not on the name. /reward\.awardIdeaDescentShipCredit/
|
|
424
|
+
// on its own still matches `reward.awardIdeaDescentShipCreditXX` — a typo'd
|
|
425
|
+
// call that resolves to undefined, takes the guard's else branch and skips
|
|
426
|
+
// the payout forever. Reverting the wiring has to red this, and with the bare
|
|
427
|
+
// name it did not. The open paren is what makes it assert a CALL.
|
|
428
|
+
assert.match(SHIP, /await reward\.awardIdeaDescentShipCredit\(/,
|
|
429
|
+
'the ship hook CALLS it through the reward port');
|
|
430
|
+
assert.match(SHIP, /skipIdeaIds:[\s\S]{0,120}ideaBonus[\s\S]{0,40}ideaId/,
|
|
431
|
+
'and passes the idea the direct payout just settled');
|
|
432
|
+
const PORT = fs.readFileSync(path.join(ROOT, 'modules/economy/reward.js'), 'utf8');
|
|
433
|
+
assert.match(PORT, /awardIdeaDescentShipCredit: \(\.\.\.a\) => credits\.awardIdeaDescentShipCredit\(/,
|
|
434
|
+
'the reward port publishes it, or the ship hook guard silently skips forever');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await test('ideas PROVIDES the port, and economy deliberately does NOT declare it as consumed', async () => {
|
|
438
|
+
// THE DECLARATION THAT WOULD BREAK BOOT, and the reason this is a test rather
|
|
439
|
+
// than a comment. `consumes` means REQUIRED: src/module-loader/loader.js
|
|
440
|
+
// verifyEnabledPorts THROWS and refuses to boot when an enabled module
|
|
441
|
+
// consumes a port no enabled module provides. The ideas module can be
|
|
442
|
+
// disabled; economy is default-on. So declaring ideas.descent in economy's
|
|
443
|
+
// consumes would turn "this instance does not use ideas" into "this instance
|
|
444
|
+
// does not start" — for a capability that is optional by construction and
|
|
445
|
+
// degrades to exactly today's first-hop payout.
|
|
446
|
+
//
|
|
447
|
+
// The next reader will see an undeclared port and want to tidy it up. This is
|
|
448
|
+
// the test that tells them why not.
|
|
449
|
+
const ideas = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/ideas/module.json'), 'utf8'));
|
|
450
|
+
const economy = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/economy/module.json'), 'utf8'));
|
|
451
|
+
assert.ok(ideas.provides.includes('ideas.descent'), 'ideas provides it');
|
|
452
|
+
assert.ok(!(economy.consumes || []).includes('ideas.descent'),
|
|
453
|
+
'economy must NOT require it — an instance with the ideas module off still has to boot');
|
|
454
|
+
|
|
455
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
|
|
456
|
+
assert.match(SRC, /resolveOptional\('ideas\.descent'\)/,
|
|
457
|
+
'which is only safe because it is resolved OPTIONALLY, never through resolve()');
|
|
458
|
+
assert.doesNotMatch(SRC, /[^t]resolve\('ideas\.descent'\)/,
|
|
459
|
+
'a hard resolve would throw on the request path instead of degrading');
|
|
460
|
+
|
|
461
|
+
const ROUTES = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/inbox.js'), 'utf8');
|
|
462
|
+
assert.match(ROUTES, /registerProvider\('ideas\.descent'/, 'and something actually registers it');
|
|
463
|
+
assert.match(ROUTES, /hasProvider\('ideas\.descent'\)/,
|
|
464
|
+
'guarded, or rebuilding the router trips the single-provider guard');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await test('the port publishes only names the walk actually exports', async () => {
|
|
468
|
+
// A guessed method name resolves to undefined and the payout silently pays
|
|
469
|
+
// nobody — the exact failure a port cannot surface at boot.
|
|
470
|
+
const descent = require('../modules/ideas/descent.js');
|
|
471
|
+
const ROUTES = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/inbox.js'), 'utf8');
|
|
472
|
+
const block = ROUTES.slice(ROUTES.indexOf("registerProvider('ideas.descent'"));
|
|
473
|
+
for (const name of [...block.slice(0, block.indexOf('}')).matchAll(/(\w+):\s*descent\.(\w+)/g)]) {
|
|
474
|
+
assert.equal(typeof descent[name[2]], 'function',
|
|
475
|
+
`descent.js must export ${name[2]} — the port publishes it`);
|
|
476
|
+
}
|
|
477
|
+
assert.equal(typeof descent.ancestorsOfTask, 'function', 'and the payout calls ancestorsOfTask by that name');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
481
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -66,7 +66,14 @@ function graphClient(edges) {
|
|
|
66
66
|
const up = /parent_task_id AS id/.test(text);
|
|
67
67
|
const rows = edges
|
|
68
68
|
.filter((e) => frontier.includes(Number(up ? e.task_id : e.parent_task_id)))
|
|
69
|
-
|
|
69
|
+
// from_id is what the real statements select, and what lets one query
|
|
70
|
+
// answer several roots at once — the fake must carry it or the batched
|
|
71
|
+
// walk would be exercised against a shape production never returns.
|
|
72
|
+
.map((e) => ({
|
|
73
|
+
id: up ? e.parent_task_id : e.task_id,
|
|
74
|
+
from_id: up ? e.task_id : e.parent_task_id,
|
|
75
|
+
relation: e.relation,
|
|
76
|
+
}));
|
|
70
77
|
return { rows };
|
|
71
78
|
},
|
|
72
79
|
};
|
|
@@ -282,7 +289,7 @@ await test('syncDescentForTask REPORTS a refusal instead of throwing', async ()
|
|
|
282
289
|
return { rows: [{ id: 5, source: 'manual', source_ref: null, parent_task_id: 9, folded_in: null }] };
|
|
283
290
|
}
|
|
284
291
|
// The cycle probe: 9 already descends from 5.
|
|
285
|
-
if (/parent_task_id = ANY/.test(text)) return { rows: [{ id: 9, relation: 'split' }] };
|
|
292
|
+
if (/parent_task_id = ANY/.test(text)) return { rows: [{ id: 9, from_id: 5, relation: 'split' }] };
|
|
286
293
|
return { rows: [] };
|
|
287
294
|
},
|
|
288
295
|
};
|