@bongos/core 1.19.472 → 1.19.474

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,222 @@
1
+ // tests/hall_reference_world.mjs — the REFERENCE room in the chrome world:
2
+ // drachmae, and what the reading-room archetype owes a second document
3
+ // (task 1003473, goal 1000081).
4
+ //
5
+ // tests/hall_reading_world.mjs pins the reading rooms (the primer, the diagrams
6
+ // index, the deck, the ladder); this is the same job for the one document
7
+ // people SCAN rather than read. Everything here is a fact that is silent in a
8
+ // browser: the page still renders, it just renders the primer's instruments on
9
+ // a document that has no end to reach, or an outline that stops one level
10
+ // short of the questions people came with.
11
+ //
12
+ // What it locks:
13
+ // * ONE ROOM, ONE MODIFIER. drachmae opts into `room room--reference` and
14
+ // loads room.css alone. primer.css holds only the acknowledge gate and
15
+ // was loaded here for nothing; the reference rules live in room.css §8, so
16
+ // the next scanned document takes them by one body class.
17
+ // * THE LINEAR-READ INSTRUMENTS ARE OFF. No progress hairline, no section
18
+ // counters, no "About a five-minute read" — and the primer keeps all three.
19
+ // * THE OUTLINE GOES TO h3 DEPTH and lights the entry AND its part.
20
+ // * EVERY HEADING IS AN ADDRESS. `.room-anchor` on h2/h3/h4, a 24px box of
21
+ // its own, copying the absolute link on click; a deep link lands after the
22
+ // async render, or the browser's own jump finds nothing.
23
+ // * THE PARSER REACHES h4, and a heading that carries a link slugs by its
24
+ // text. The economy doc's six earning sources are `####`, and the shared
25
+ // reader stopped at `###`, so they rendered as paragraphs with a literal
26
+ // "####" — the largest defect on the page, and invisible to every floor.
27
+ // * NESTED EMPHASIS RENDERS. "**It's the API *list* price**" showed its
28
+ // asterisks because the bold rule refused an inner `*`.
29
+ // * THE LIVE TABLE IS A SECTION AHEAD OF THE CLOSING LINKS, not an appendix
30
+ // after "dig deeper".
31
+ //
32
+ // Reproduce the renders these came from (the populated document needs the
33
+ // fixture harness; the kit stub serves neither the markdown nor the bounty feed):
34
+ // node scripts/hall-preview/server.js --port 4620 --fixture-me
35
+ // node modules/ui-design/kit/render.js --states modules/hall-ui/public/drachmae.states.json \
36
+ // --base-in http://builders.localhost:4620 --base-out http://builders.localhost:4620
37
+
38
+ import { test } from 'node:test';
39
+ import assert from 'node:assert/strict';
40
+ import fs from 'node:fs';
41
+ import path from 'node:path';
42
+ import vm from 'node:vm';
43
+ import { fileURLToPath } from 'node:url';
44
+
45
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
46
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
47
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
48
+
49
+ const roomCss = read('room.css');
50
+ const mdReaderJs = read('md-reader.js');
51
+ const drachmaeHtml = read('drachmae.html');
52
+ const drachmaeJs = read('drachmae.js');
53
+ const drachmaeStates = JSON.parse(read('drachmae.states.json'));
54
+ const primerHtml = read('primer.html');
55
+ const primerJs = read('primer.js');
56
+
57
+ const decomment = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
58
+ const dehtmlcomment = (html) => html.replace(/<!--[\s\S]*?-->/g, '');
59
+ const dejscomment = (js) => js.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
60
+ // The body of the first rule matching `selector {`, comments stripped. Takes a
61
+ // PLAIN selector and escapes it here.
62
+ const rule = (css, selector) => {
63
+ const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
64
+ const m = decomment(css).match(new RegExp(`(^|[},])\\s*${esc}\\s*\\{([^}]*)\\}`, 'm'));
65
+ return m ? m[2] : null;
66
+ };
67
+
68
+ // md-reader.js is a browser IIFE that only touches `window` and `location`.
69
+ function loadReader() {
70
+ const sandbox = { window: {}, location: { hostname: 'builders.example.com', href: 'https://builders.example.com/drachmae' } };
71
+ vm.createContext(sandbox);
72
+ vm.runInContext(mdReaderJs, sandbox, { filename: 'md-reader.js' });
73
+ return sandbox.window.OTBMdReader;
74
+ }
75
+
76
+ // ---- 1. one room, one modifier ----------------------------------------------
77
+
78
+ test('drachmae opts into the room and its reference rules, and loads the archetype sheet alone', () => {
79
+ assert.match(drachmaeHtml, /<body class="room room--reference"/, 'the reference is the room with one modifier, not a second archetype');
80
+ assert.match(drachmaeHtml, /href="\/builders\/room\.css"/, 'the archetype is loaded');
81
+ assert.doesNotMatch(dehtmlcomment(drachmaeHtml), /primer\.css/,
82
+ 'primer.css holds only the acknowledge gate, which a reference has no use for');
83
+ assert.match(drachmaeHtml, /class="page-head page-head--room"/, 'the head keeps the room voice');
84
+ });
85
+
86
+ test('the reference rules live in room.css, keyed on the body modifier', () => {
87
+ const css = decomment(roomCss);
88
+ assert.match(css, /\.room--reference \.primer-article h2::before,\s*\.room--reference \.primer-toc__list a::before\s*\{\s*content:\s*none/,
89
+ 'the counters are off on a reference');
90
+ assert.ok(rule(roomCss, '.room--reference .primer-toc__sub'), 'the nested contents have a rule');
91
+ assert.ok(rule(roomCss, '.room--reference .primer-toc__list a.is-trail'), 'the part of the lit entry is the trail');
92
+ assert.match(rule(roomCss, '.primer-article h2::before') || '', /counter-increment/,
93
+ 'and the primer keeps its counter — the modifier turns it off, the archetype still draws it');
94
+ });
95
+
96
+ // ---- 2. the linear-read instruments are off ---------------------------------
97
+
98
+ test('the reference carries no progress hairline, no acknowledge gate and no read time', () => {
99
+ const html = dehtmlcomment(drachmaeHtml);
100
+ assert.doesNotMatch(html, /primer-progress/, 'no progress hairline: a reference has no end to reach');
101
+ assert.doesNotMatch(dejscomment(drachmaeJs), /primer-progress|setupProgressBar/, 'and the script drives none');
102
+ assert.doesNotMatch(html, /primer-ack/, 'no acknowledge gate');
103
+ assert.doesNotMatch(html, /minute read/i, 'the sub line says what kind of document this is, not how long it takes');
104
+ });
105
+
106
+ test('the primer keeps all three — the modifier is drachmae\'s, not the room\'s', () => {
107
+ assert.doesNotMatch(primerHtml, /room--reference/);
108
+ assert.match(primerHtml, /primer-progress__bar/);
109
+ assert.match(primerJs, /function setupProgressBar/, 'the primer\'s gate is fed by its progress');
110
+ });
111
+
112
+ // ---- 3. the outline -----------------------------------------------------------
113
+
114
+ test('the outline is read from the rendered h2s AND h3s and nests them', () => {
115
+ assert.match(drachmaeJs, /querySelectorAll\('h2\[id\], h3\[id\]'\)/, 'the contents are collected from the DOM, to h3 depth');
116
+ assert.match(drachmaeJs, /class="primer-toc__sub"/, 'h3s nest under their part');
117
+ assert.match(drachmaeJs, /classList\.toggle\('is-trail'/, 'the spy marks the lit entry\'s part');
118
+ assert.match(drachmaeHtml, /id="primer-toc-current"/, 'the phone toggle has a middle that names the part');
119
+ assert.match(drachmaeJs, /current\.textContent = active \? active\.part : ''/, 'and the spy writes it');
120
+ });
121
+
122
+ test('the toggle\'s chevron is drawn, and the contents are named for a reference', () => {
123
+ assert.doesNotMatch(dehtmlcomment(drachmaeHtml), /▾/, 'no glyph standing in for an icon');
124
+ assert.match(drachmaeHtml, /<svg class="primer-toc__chevron"/);
125
+ assert.match(drachmaeHtml, /id="primer-toc-heading">On this page</, 'a reference is "on this page", not "in this guide"');
126
+ });
127
+
128
+ // ---- 4. every heading is an address -------------------------------------------
129
+
130
+ test('every h2, h3 and h4 gets an anchor that is a real link and copies the address', () => {
131
+ assert.match(drachmaeJs, /querySelectorAll\('h2\[id\], h3\[id\], h4\[id\]'\)/);
132
+ assert.match(drachmaeJs, /className = 'room-anchor'/);
133
+ assert.match(drachmaeJs, /a\.href = `#\$\{h\.id\}`/, 'the anchor is the heading\'s own id, so it works with no script');
134
+ assert.match(drachmaeJs, /navigator\.clipboard\.writeText\(url\)/, 'the click copies the absolute address');
135
+ const anchor = rule(roomCss, '.room--reference .primer-article .room-anchor');
136
+ assert.ok(anchor, 'the anchor has its own rule, scoped to the reference, at higher specificity than the prose link');
137
+ assert.match(anchor, /width:\s*24px/); assert.match(anchor, /height:\s*24px/);
138
+ assert.match(anchor, /text-decoration:\s*none/);
139
+ assert.ok(rule(roomCss, '.room--reference .primer-article :is(:target, .is-target) > .room-anchor'),
140
+ 'the one the reader arrived at takes the accent — by :target, or by the mark the landing sets when :target resolved against nothing');
141
+ });
142
+
143
+ test('a deep link lands after the async render, the live section and the fonts', () => {
144
+ assert.match(drachmaeJs, /function landOnHash\(\)[\s\S]*classList\.add\('is-target'\)[\s\S]*scrollIntoView/);
145
+ const src = dejscomment(drachmaeJs);
146
+ const inserted = src.indexOf('await insertBountySection(article)');
147
+ const landed = src.indexOf('.then(landOnHash)');
148
+ assert.ok(inserted > -1 && landed > inserted, 'landOnHash runs once the rate card has taken its place, or the offsets it lands on are wrong');
149
+ assert.match(src, /document\.fonts\.ready[\s\S]*\.then\(landOnHash\)/, 'and after the fonts: a swap after the jump moves everything above the target');
150
+ });
151
+
152
+ test('on a phone the collapsed outline rides under the top bar, so the toggle can name the part from anywhere', () => {
153
+ const m = decomment(roomCss).match(/@media \(max-width: 760px\)\s*\{\s*\.room--reference \.primer-toc\s*\{([^}]*)\}/);
154
+ assert.ok(m, 'the reference re-sticks the contents at phone width (the archetype makes them static there)');
155
+ assert.match(m[1], /position:\s*sticky/);
156
+ assert.match(m[1], /z-index:\s*var\(--z-progress\)/, 'below the top bar in the ladder');
157
+ });
158
+
159
+ // ---- 5. the live table --------------------------------------------------------
160
+
161
+ test('the live rate card is a section ahead of the closing links, not an appendix after them', () => {
162
+ assert.match(drachmaeJs, /CLOSING_H2 = \/\^see it\/i/);
163
+ assert.match(drachmaeJs, /article\.insertBefore\(section, closing\)/);
164
+ assert.match(drachmaeJs, /else article\.appendChild\(section\)/, 'a retitled footer falls back to appending');
165
+ });
166
+
167
+ // ---- 6. the parser, actually run ----------------------------------------------
168
+
169
+ test('the shared parser reaches h4 — the six earning sources rendered as paragraphs with a literal ####', () => {
170
+ const MD = loadReader();
171
+ const { html, headings } = MD.parseMarkdown('#### 4. Achievements\n\nUnlocking one pays its bonus.\n', (h) => h);
172
+ assert.equal(headings.length, 1);
173
+ assert.equal(headings[0].level, 4);
174
+ assert.match(html, /<h4 id="4-achievements">4\. Achievements<\/h4>/);
175
+ assert.doesNotMatch(html, /####/);
176
+ });
177
+
178
+ test('a paragraph breaks before an h4 the way it breaks before an h3', () => {
179
+ const MD = loadReader();
180
+ const { html } = MD.parseMarkdown('a line\n#### A head\n', (h) => h);
181
+ assert.match(html, /<p>a line<\/p>\s*<h4/);
182
+ });
183
+
184
+ test('a heading that carries a link slugs by its text, so the copied address stays readable', () => {
185
+ const MD = loadReader();
186
+ const withLink = '2. Per-task credits, adjusted by *kind* ([ADR 0023](../adr/0023-kind-multipliers-and-idea-bonus.md))';
187
+ assert.equal(MD.slugify(withLink), MD.slugify('2. Per-task credits, adjusted by *kind* (ADR 0023)'));
188
+ assert.doesNotMatch(MD.slugify(withLink), /adr0023|md$/, 'the target path is not part of the address');
189
+ });
190
+
191
+ test('emphasis inside a bold run renders — the API list-price line showed its asterisks', () => {
192
+ const MD = loadReader();
193
+ const { html } = MD.parseMarkdown('- **It is the API *list* price, deliberately** and not the rest\n', (h) => h);
194
+ assert.match(html, /<strong>It is the API <em>list<\/em> price, deliberately<\/strong> and not the rest/);
195
+ const two = MD.parseMarkdown('**a** and **b**\n', (h) => h).html;
196
+ assert.match(two, /<strong>a<\/strong> and <strong>b<\/strong>/, 'and two bold runs on one line stay two');
197
+ });
198
+
199
+ // ---- 7. the room's h4 -----------------------------------------------------------
200
+
201
+ test('the room gives the fourth level a rule, the measure and the anchor offset', () => {
202
+ const h4 = rule(roomCss, '.primer-article h4');
203
+ assert.ok(h4, 'room.css styles h4 now that the parser emits it');
204
+ assert.match(h4, /scroll-margin-top/, 'a deep link to an h4 clears the top bar');
205
+ const measured = decomment(roomCss).match(/\.primer-article\s*>\s*p,[\s\S]*?\{([^}]*)\}/);
206
+ assert.ok(measured, 'the measured-children list exists');
207
+ assert.match(measured[0], /\.primer-article\s*>\s*h4/, 'and the h4 is measured with the prose');
208
+ });
209
+
210
+ // ---- 8. the states file and the one h1 ------------------------------------------
211
+
212
+ test('the states file measures the reference\'s own states, including the deep link', () => {
213
+ for (const name of ['read', 'toc-open', 'jump', 'out']) assert.ok(drachmaeStates.states[name], `state ${name}`);
214
+ assert.match(drachmaeStates.states.jump.url, /#how-karma-is-earned-and-lost$/, 'the jump lands on an h3, so the trail is what the shot proves');
215
+ assert.ok(drachmaeStates.reducedMotion, 'the kill switch is exercised');
216
+ });
217
+
218
+ test('the reference renders exactly one h1, the page head\'s', () => {
219
+ const html = dehtmlcomment(drachmaeHtml);
220
+ assert.equal([...html.matchAll(/<h1\b/g)].length, 1);
221
+ assert.match(html, /<h1 class="page-head__h">/);
222
+ });
@@ -0,0 +1,326 @@
1
+ // tests/idea_credit_streams_db.mjs
2
+ //
3
+ // task 1003000 (BV1.R176 / IC6, goal 1000067) — the ideator credit lane's three
4
+ // streams against a REAL Postgres, so the claims IC6 makes are executable rather
5
+ // than argued. ADR 0172; docs/governance/bongos-v1-ideator-pay-proofs.md is the
6
+ // proof record this harness belongs to.
7
+ //
8
+ // WHY A DB TEST EXISTS AT ALL, when three DB-free suites already cover these
9
+ // functions: those suites stub the client and SIMULATE `INSERT ... WHERE NOT
10
+ // EXISTS` in JavaScript. The whole idempotency argument of the credit lane — the
11
+ // thing that stops an author being paid twice for one idea — is therefore
12
+ // asserted against a fake. Here the statement meets a real transaction engine,
13
+ // real unique indexes, and the real `pms_apply_credit` trigger that moves
14
+ // builders.total_credits. That is the only way to prove the money semantics
15
+ // rather than restate them.
16
+ //
17
+ // NON-DESTRUCTIVE BY CONSTRUCTION: every fixture and every credit row is written
18
+ // inside ONE transaction that is ALWAYS rolled back, so this is safe to point at
19
+ // a dev database that holds real rows. It never TRUNCATEs and never commits.
20
+ //
21
+ // Real-DB (self-skips without Postgres; belongs in the INTEGRATION set so the
22
+ // DB-free lane stays DB-free).
23
+ //
24
+ // Run: DATABASE_URL=postgres://... node tests/idea_credit_streams_db.mjs
25
+ //
26
+ // PROVENANCE, stated plainly: written from the migration DDL and NOT executed by
27
+ // its author — the machine it was written on could not run Postgres at all
28
+ // (no docker, no psql, and an embedded server died on the Windows shared-memory
29
+ // reserve, error 487). The first person to run it with a DATABASE_URL is the
30
+ // first person to see it pass. Treat a failure here as a bug in this file until
31
+ // proven otherwise, and record the outcome in the proof document above.
32
+
33
+ import { strict as assert } from 'node:assert';
34
+ import { createRequire } from 'node:module';
35
+
36
+ const require = createRequire(import.meta.url);
37
+ const { Pool } = require('pg');
38
+ const credits = require('../modules/economy/credits.js');
39
+
40
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
41
+
42
+ let passed = 0;
43
+ let failed = 0;
44
+ async function test(name, fn) {
45
+ try { await fn(); passed++; console.log(` ok ${name}`); }
46
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
47
+ }
48
+
49
+ // The reward policy the live instance runs: builder estimate OFF, ideator lane
50
+ // ON. Every stream below is driven through THIS, so a pass here is also the
51
+ // ADR 0172 §2 exemption holding under the real configuration.
52
+ const COST_PLUS_ONLY = credits.rewardPolicy({ mode: 'cost-plus-only' });
53
+
54
+ // insertRow — insert into `table`, supplying `explicit` and auto-filling every
55
+ // other NOT NULL column that has no default. Schema-driven rather than
56
+ // hand-listed: the fixture tables (versions, tasks, idea_inbox) carry columns
57
+ // this test does not care about, and a hand-written column list would rot the
58
+ // first time a migration adds a NOT NULL one. Identity/serial columns are left
59
+ // to the sequence. Returns the inserted row.
60
+ const schemaCache = new Map();
61
+
62
+ async function columnsOf(client, table) {
63
+ if (schemaCache.has(table)) return schemaCache.get(table);
64
+ const { rows } = await client.query(
65
+ `SELECT column_name, data_type, is_nullable, column_default, is_identity
66
+ FROM information_schema.columns
67
+ WHERE table_schema = 'public' AND table_name = $1`,
68
+ [table]
69
+ );
70
+ schemaCache.set(table, rows);
71
+ return rows;
72
+ }
73
+
74
+ // `table` and the column names are interpolated rather than parameterised
75
+ // because SQL cannot parameterise an identifier. Both are trusted by
76
+ // construction: `table` is always a literal from this file's own fixture(), and
77
+ // the column names come from information_schema metadata for that table — never
78
+ // from input. Nothing here is reachable from a request.
79
+ async function insertRow(client, table, explicit = {}) {
80
+ const cols = await columnsOf(client, table);
81
+ if (cols.length === 0) throw new Error(`table ${table} not found — is this database migrated?`);
82
+ const values = { ...explicit };
83
+ for (const c of cols) {
84
+ if (c.column_name in values) continue;
85
+ if (c.is_nullable === 'YES') continue; // NULL is fine
86
+ if (c.column_default !== null) continue; // the default covers it
87
+ if (c.is_identity === 'YES') continue; // sequence owns it
88
+ values[c.column_name] = placeholderFor(c.data_type, c.column_name);
89
+ }
90
+ const names = Object.keys(values);
91
+ const params = names.map((_, i) => `$${i + 1}`);
92
+ const { rows } = await client.query(
93
+ `INSERT INTO ${table} (${names.map((n) => `"${n}"`).join(', ')})
94
+ VALUES (${params.join(', ')}) RETURNING *`,
95
+ names.map((n) => values[n])
96
+ );
97
+ return rows[0];
98
+ }
99
+
100
+ function placeholderFor(dataType, columnName) {
101
+ switch (dataType) {
102
+ case 'integer': case 'bigint': case 'smallint':
103
+ case 'numeric': case 'real': case 'double precision':
104
+ return 0;
105
+ case 'boolean': return false;
106
+ case 'timestamp with time zone': case 'timestamp without time zone': case 'date':
107
+ return new Date();
108
+ case 'json': case 'jsonb': return {};
109
+ case 'ARRAY': return [];
110
+ default: return `ic6-${columnName}`;
111
+ }
112
+ }
113
+
114
+ // One fully-wired idea: an author, an optional developer, a Full Idea at
115
+ // `completeness`, and `taskCount` shipped tasks descended from it (the IC3
116
+ // lineage stream 1 counts). Ids come from the sequences, never hardcoded.
117
+ async function fixture(client, { completeness = 70, developed = false, taskCount = 0 } = {}) {
118
+ const author = await insertRow(client, 'builders', {
119
+ github_id: `ic6-author-${Date.now()}-${Math.random()}`,
120
+ github_login: 'ic6-author', display_name: 'ic6 author', total_credits: 0,
121
+ });
122
+ const developer = developed
123
+ ? await insertRow(client, 'builders', {
124
+ github_id: `ic6-dev-${Date.now()}-${Math.random()}`,
125
+ github_login: 'ic6-dev', display_name: 'ic6 developer', total_credits: 0,
126
+ })
127
+ : null;
128
+ const idea = await insertRow(client, 'idea_inbox', {
129
+ title: 'ic6 — a Full Idea used to prove the ideator credit lane',
130
+ kind: 'feature', status: 'open', captured_by: author.id,
131
+ });
132
+ await insertRow(client, 'ideas_grades', {
133
+ idea_id: idea.id,
134
+ grade: 'full',
135
+ completeness_score: completeness,
136
+ ...(developer ? { developed_by: developer.id } : {}),
137
+ });
138
+ const version = await insertRow(client, 'versions', { id: `IC6-${Date.now()}-${Math.random()}`.slice(0, 40) });
139
+ const tasks = [];
140
+ for (let i = 0; i < taskCount; i++) {
141
+ const task = await insertRow(client, 'tasks', {
142
+ version_id: version.id, title: `ic6 task ${i + 1}`, status: 'shipped',
143
+ });
144
+ await insertRow(client, 'ideas_task_lineage', {
145
+ task_id: task.id, idea_id: idea.id, origin: 'promote',
146
+ });
147
+ tasks.push(task);
148
+ }
149
+ return { author, developer, idea, tasks };
150
+ }
151
+
152
+ // Every ideator-lane row for ONE idea, matched on EXACT reason keys.
153
+ //
154
+ // The obvious spelling of this — `reason LIKE 'idea.credit.%' || ideaId || '%'` —
155
+ // is an unanchored substring match on the id, so for idea 5 it also matches
156
+ // `idea.credit.task_ship:15:99` and `idea.credit.full_pass:51`. Against a
157
+ // database that already holds real ideator rows (which this file promises is
158
+ // safe) that silently inflates the counts the assertions below turn on, which
159
+ // is the opposite of the guarantee. So: the two per-idea keys are compared for
160
+ // equality, and the per-task key is matched on the `<id>:` prefix — anchored by
161
+ // the delimiter, so idea 5 can never collide with idea 15 or 51.
162
+ async function ledger(client, ideaId) {
163
+ const id = String(ideaId);
164
+ const { rows } = await client.query(
165
+ `SELECT builder_id, delta, reason FROM credit_log
166
+ WHERE reason = 'idea.credit.full_pass:' || $1
167
+ OR reason = 'idea.credit.governance_pass:' || $1
168
+ OR reason LIKE 'idea.credit.task_ship:' || $1 || ':%'
169
+ ORDER BY id`,
170
+ [id]
171
+ );
172
+ return rows;
173
+ }
174
+
175
+ // Every test runs in its own always-rolled-back transaction.
176
+ async function inTx(fn) {
177
+ const client = await pool.connect();
178
+ try {
179
+ await client.query('BEGIN');
180
+ await fn(client);
181
+ } finally {
182
+ await client.query('ROLLBACK').catch(() => {});
183
+ client.release();
184
+ }
185
+ }
186
+
187
+ try {
188
+ await pool.query('SELECT 1');
189
+ } catch (e) {
190
+ console.log(`idea_credit_streams_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
191
+ process.exit(0);
192
+ }
193
+
194
+ await test('the reward policy under test is the live one: estimate off, ideator lane on', async () => {
195
+ assert.equal(COST_PLUS_ONLY.perTaskCredit, false);
196
+ assert.equal(COST_PLUS_ONLY.ideatorCredit, true);
197
+ });
198
+
199
+ await test('stream 2 — a passing Full Idea books exactly 5, and the REAL trigger moves the balance', async () => {
200
+ await inTx(async (client) => {
201
+ const { author, idea } = await fixture(client, { completeness: 72 });
202
+ const r = await credits.awardIdeaFullPassCredit(client, {
203
+ ideaId: idea.id, authorId: author.id, completenessScore: 72, config: COST_PLUS_ONLY,
204
+ });
205
+ assert.equal(r.credited, 5);
206
+ const rows = await ledger(client, idea.id);
207
+ assert.equal(rows.length, 1);
208
+ assert.equal(rows[0].reason, `idea.credit.full_pass:${idea.id}`);
209
+ // The DB-free suites cannot see this: pms_apply_credit is a real trigger.
210
+ const { rows: [b] } = await client.query('SELECT total_credits FROM builders WHERE id = $1', [author.id]);
211
+ assert.equal(Number(b.total_credits), 5, 'builders.total_credits is moved by the ledger trigger, not by the award fn');
212
+ });
213
+ });
214
+
215
+ await test('stream 2 — REAL SQL idempotency: three calls, one row, one payment', async () => {
216
+ await inTx(async (client) => {
217
+ const { author, idea } = await fixture(client, { completeness: 72 });
218
+ const args = { ideaId: idea.id, authorId: author.id, completenessScore: 72, config: COST_PLUS_ONLY };
219
+ const first = await credits.awardIdeaFullPassCredit(client, args);
220
+ const second = await credits.awardIdeaFullPassCredit(client, args);
221
+ const third = await credits.awardIdeaFullPassCredit(client, args);
222
+ assert.equal(first.credited, 5);
223
+ assert.equal(second.credited, 0);
224
+ assert.equal(second.reason, 'already_paid');
225
+ assert.equal(third.credited, 0);
226
+ assert.equal((await ledger(client, idea.id)).length, 1, 'WHERE NOT EXISTS held against a real engine');
227
+ const { rows: [b] } = await client.query('SELECT total_credits FROM builders WHERE id = $1', [author.id]);
228
+ assert.equal(Number(b.total_credits), 5, 'no double-pay reached the balance either');
229
+ });
230
+ });
231
+
232
+ await test('stream 1 — each shipped task under the idea pays, grade-scaled, once per task', async () => {
233
+ await inTx(async (client) => {
234
+ const { author, idea, tasks } = await fixture(client, { completeness: 80, taskCount: 3 });
235
+ let total = 0;
236
+ for (const t of tasks) {
237
+ const r = await credits.awardIdeaTaskShipCredit(client, { taskId: t.id, config: COST_PLUS_ONLY });
238
+ assert.equal(r.reason, 'paid', `task ${t.id} should pay`);
239
+ total += r.credited;
240
+ }
241
+ // round(5 × 80/100) = 4, three times.
242
+ assert.equal(total, 12);
243
+ // Re-driving every one pays nothing more.
244
+ for (const t of tasks) {
245
+ const again = await credits.awardIdeaTaskShipCredit(client, { taskId: t.id, config: COST_PLUS_ONLY });
246
+ assert.equal(again.credited, 0);
247
+ assert.equal(again.reason, 'already_paid');
248
+ }
249
+ const { rows: [b] } = await client.query('SELECT total_credits FROM builders WHERE id = $1', [author.id]);
250
+ assert.equal(Number(b.total_credits), 12, 'one idea, many tasks — paid per task, never twice');
251
+ });
252
+ });
253
+
254
+ await test('stream 3 — a board-ratified Full Idea books its completeness percent, once per idea', async () => {
255
+ await inTx(async (client) => {
256
+ const { author, idea } = await fixture(client, { completeness: 70 });
257
+ const args = { ideaId: idea.id, authorId: author.id, completenessScore: 70, config: COST_PLUS_ONLY };
258
+ const r = await credits.awardIdeaGovernancePassCredit(client, args);
259
+ assert.equal(r.credited, 70, "the owner's worked example: 70%-complete + passes = 70");
260
+ const again = await credits.awardIdeaGovernancePassCredit(client, args);
261
+ assert.equal(again.credited, 0);
262
+ assert.equal(again.reason, 'already_paid');
263
+ const rows = await ledger(client, idea.id);
264
+ assert.equal(rows.length, 1);
265
+ assert.equal(rows[0].reason, `idea.credit.governance_pass:${idea.id}`);
266
+ });
267
+ });
268
+
269
+ await test('stream 3 — a constitutional amendment books nothing', async () => {
270
+ await inTx(async (client) => {
271
+ const { author, idea } = await fixture(client, { completeness: 90 });
272
+ const r = await credits.awardIdeaGovernancePassCredit(client, {
273
+ ideaId: idea.id, authorId: author.id, completenessScore: 90,
274
+ subjectType: 'constitutional_amendment', config: COST_PLUS_ONLY,
275
+ });
276
+ assert.equal(r.credited, 0);
277
+ assert.equal(r.reason, 'not_a_full_idea');
278
+ assert.equal((await ledger(client, idea.id)).length, 0);
279
+ });
280
+ });
281
+
282
+ await test('the spark split is real rows: 70/30 under ONE reason key, total conserved', async () => {
283
+ await inTx(async (client) => {
284
+ const { author, developer, idea } = await fixture(client, { completeness: 70, developed: true });
285
+ const r = await credits.awardIdeaGovernancePassCredit(client, {
286
+ ideaId: idea.id, authorId: author.id, completenessScore: 70, config: COST_PLUS_ONLY,
287
+ });
288
+ assert.equal(r.credited, 70);
289
+ const rows = await ledger(client, idea.id);
290
+ assert.equal(rows.length, 2, 'two payees, one reason — the builder-scoped predicate is load-bearing here');
291
+ assert.equal(rows[0].reason, rows[1].reason);
292
+ const byBuilder = new Map(rows.map((x) => [String(x.builder_id), Number(x.delta)]));
293
+ assert.equal(byBuilder.get(String(developer.id)), 49, '70% of 70, rounded');
294
+ assert.equal(byBuilder.get(String(author.id)), 21, 'the remainder — never independently rounded');
295
+ });
296
+ });
297
+
298
+ await test('all three streams coexist on one idea without double-paying', async () => {
299
+ await inTx(async (client) => {
300
+ const { author, idea, tasks } = await fixture(client, { completeness: 70, taskCount: 1 });
301
+ await credits.awardIdeaFullPassCredit(client, { ideaId: idea.id, authorId: author.id, completenessScore: 70, config: COST_PLUS_ONLY });
302
+ await credits.awardIdeaTaskShipCredit(client, { taskId: tasks[0].id, config: COST_PLUS_ONLY });
303
+ await credits.awardIdeaGovernancePassCredit(client, { ideaId: idea.id, authorId: author.id, completenessScore: 70, config: COST_PLUS_ONLY });
304
+ const rows = await ledger(client, idea.id);
305
+ const reasons = rows.map((r) => r.reason.split(':')[0]).sort();
306
+ assert.deepEqual([...new Set(reasons)], ['idea.credit.full_pass', 'idea.credit.governance_pass', 'idea.credit.task_ship']);
307
+ // 5 (grade) + round(5 × 0.7) = 4 (one shipped task) + 70 (ratification) = 79
308
+ const { rows: [b] } = await client.query('SELECT total_credits FROM builders WHERE id = $1', [author.id]);
309
+ assert.equal(Number(b.total_credits), 79, 'three distinct reasons, three distinct payments, no overlap');
310
+ });
311
+ });
312
+
313
+ // DELIBERATELY NOT ASSERTED HERE: claim 6, that the retired ADR 0023 slice no
314
+ // longer fires alongside stream 1. The tempting test — call
315
+ // awardIdeaTaskShipCredit, then check no `idea_promotion_bonus` row appeared —
316
+ // is a TAUTOLOGY: that function contains no code path that could write that
317
+ // reason, so the assertion passes without touching the thing claim 6 is about,
318
+ // which is the CALL SITE in modules/lifecycle/db-ship.js. Driving the real
319
+ // awardLandingCredit needs a claim/session fixture well beyond this harness, so
320
+ // claim 6's evidence stays the static read recorded in the proof document
321
+ // (db-ship.js:236 calls stream 1 and nothing else, and no ship path calls the
322
+ // slice). A test that cannot fail is worse than no test: it reads as coverage.
323
+
324
+ console.log(`\nidea_credit_streams_db: ${passed} passed, ${failed} failed`);
325
+ await pool.end();
326
+ if (failed > 0) process.exit(1);