@bongos/core 1.20.57 → 1.20.59

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.
Files changed (73) hide show
  1. package/.bongos-core.json +188 -53
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +14 -4
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/api/openapi.json +220 -20
  8. package/docs/api-reference.md +8 -3
  9. package/docs/architecture.md +1 -0
  10. package/docs/copy-inventory.md +21 -5
  11. package/docs/copy-registry.json +169 -15
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +8 -3
  14. package/docs/page-readings.json +72 -72
  15. package/migrations/core_268_builders_main_discipline.sql +35 -0
  16. package/modules/copy-desk/artist-stats.js +56 -0
  17. package/modules/copy-desk/module.json +1 -1
  18. package/modules/copy-desk/page-status.js +39 -0
  19. package/modules/copy-desk/routes/copy-desk.js +7 -16
  20. package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
  21. package/modules/economy/credits-by-craft.js +208 -0
  22. package/modules/economy/reward.js +8 -0
  23. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea-cut.webp +0 -0
  24. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
  25. package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
  26. package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
  27. package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
  28. package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
  29. package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
  30. package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
  31. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
  32. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
  33. package/modules/hall-ui/public/genesis-home.js +349 -0
  34. package/modules/hall-ui/public/hall-render.js +2 -2
  35. package/modules/hall-ui/public/index-genesis-decide.states.json +13 -0
  36. package/modules/hall-ui/public/index-genesis.states.json +13 -0
  37. package/modules/hall-ui/public/index.html +2 -0
  38. package/modules/hall-ui/public/profile.js +1 -1
  39. package/modules/hall-ui/public/style.css +65 -0
  40. package/modules/hall-ui/records/genesis-home.md +18 -0
  41. package/modules/ideas/ideator-stats.js +149 -0
  42. package/modules/ideas/module.json +2 -1
  43. package/modules/ideas/routes/inbox.js +7 -0
  44. package/modules/lifecycle/activity.js +5 -2
  45. package/modules/lifecycle/db-analytics.js +4 -2
  46. package/modules/lifecycle/db-tasks.js +49 -1
  47. package/modules/lifecycle/db.js +3 -2
  48. package/modules/lifecycle/kickoff-checklist.js +54 -1
  49. package/modules/lifecycle/kickoff-seed.js +61 -2
  50. package/modules/lifecycle/page-tweak-reads.js +23 -5
  51. package/modules/lifecycle/routes/kickoff-seed.js +7 -1
  52. package/modules/onboarding/genesis-home.js +274 -0
  53. package/modules/onboarding/routes/onboarding.js +56 -0
  54. package/modules/ui-design/kit/fixtures/founding-genesis-decide.json +108 -0
  55. package/modules/ui-design/kit/fixtures/founding-genesis-stage.json +162 -0
  56. package/modules/ui-design/kit/fixtures/government-constitution-sample.json +205 -0
  57. package/modules/ui-design/kit/serve.js +7 -1
  58. package/package-lock.json +2 -2
  59. package/package.json +1 -1
  60. package/release-notes.json +12 -0
  61. package/scripts/gds/run-unit-tests.js +5 -0
  62. package/src/bongos/db.js +44 -6
  63. package/src/bongos/role-stats.js +155 -0
  64. package/src/bongos/routes/builders.js +15 -2
  65. package/src/bongos/routes/me.js +11 -3
  66. package/src/module-api.js +1 -1
  67. package/tests/copy_desk_page_reads.mjs +1 -1
  68. package/tests/fixtures/page-ledger.mjs +1 -1
  69. package/tests/genesis_home.mjs +288 -0
  70. package/tests/main_discipline.mjs +81 -0
  71. package/tests/profile_role_stats.mjs +416 -0
  72. package/tests/role_stats_db.mjs +237 -0
  73. package/tests/tweak_replaces_artist_review.mjs +1 -1
@@ -0,0 +1,416 @@
1
+ // tests/profile_role_stats.mjs — WA6.RP03 (task 1004433): each role read by its
2
+ // own work, behind the role-shaped profile (the owner-approved mock is
3
+ // docs/design/mocks/profile-roles/, task 1004431).
4
+ //
5
+ // DONE WHEN: "the three reads are correct for fixtures of each craft, including
6
+ // an ideator and an artist who have never shipped a task". So the centre of this
7
+ // file is three people the mock already names, driven through the REAL profile
8
+ // route (GET /builders/:id/profile) with the REAL role composer, the REAL
9
+ // ideator summary over states the REAL resolver derived, the REAL artist read
10
+ // over the REAL page inventory, and economy's REAL fold of the ledger rows:
11
+ //
12
+ // Ines Varga an artist who has never shipped a task: pages and ideas only
13
+ // Tobi Rahn an ideator who has never shipped a task: thoughts only
14
+ // Mara Okonkwo an engineer, who also earned a little from an idea
15
+ //
16
+ // The SQL halves (which ledger rows the classification and the per-person
17
+ // earnings reach) run against a real Postgres in tests/role_stats_db.mjs; here
18
+ // the ledger answers with the grouped rows that SQL returns, and everything from
19
+ // there to the JSON is the production path.
20
+ //
21
+ // Also pinned: the public answer carries counts only (decision (a)), the main
22
+ // role's fallbacks, the exact apportioning, and the unknown-not-zero rule when a
23
+ // port is missing.
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { createRequire } from 'node:module';
27
+ import http from 'node:http';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const express = require('express');
31
+
32
+ let passed = 0;
33
+ let failed = 0;
34
+ async function t(name, fn) {
35
+ try { await fn(); passed++; console.log(` ok ${name}`); }
36
+ catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
37
+ }
38
+
39
+ // ---- the people ------------------------------------------------------------
40
+
41
+ const INES = 201; const TOBI = 202; const MARA = 203; const NEW = 204; const UI = 205; const LEA = 206;
42
+ const ROWS = {
43
+ [INES]: { id: INES, github_login: 'ines', display_name: 'Ines Varga', total_credits: 312, karma: 0, rank: 'thetes', preferred_disciplines: ['artist', 'ideator'], main_discipline: null },
44
+ [TOBI]: { id: TOBI, github_login: 'tobi', display_name: 'Tobi Rahn', total_credits: 186, karma: 0, rank: 'thetes', preferred_disciplines: ['ideator'], main_discipline: null },
45
+ [MARA]: { id: MARA, github_login: 'mara', display_name: 'Mara Okonkwo', total_credits: 2480, karma: 118, rank: 'metic', preferred_disciplines: ['engineer', 'ideator'], main_discipline: 'engineer' },
46
+ [NEW]: { id: NEW, github_login: 'new', display_name: 'Newcomer', total_credits: 0, karma: 0, rank: 'xenos', preferred_disciplines: [], main_discipline: null },
47
+ [UI]: { id: UI, github_login: 'priya', display_name: 'Priya Das', total_credits: 60, karma: 0, rank: 'metic', preferred_disciplines: ['engineer', 'ui'], main_discipline: 'ui' },
48
+ [LEA]: { id: LEA, github_login: 'lea', display_name: 'Lea Brandt', total_credits: 30, karma: 0, rank: 'thetes', preferred_disciplines: ['artist'], main_discipline: null },
49
+ };
50
+ const SHIPPED = { [INES]: 0, [TOBI]: 0, [MARA]: 64, [NEW]: 0, [UI]: 2, [LEA]: 0 };
51
+
52
+ // What CREDITS_BY_CRAFT_SQL returns, grouped by (builder, discipline). Ines's
53
+ // session reward was split across two tasks of different crafts, so her rows
54
+ // arrive as halves; the fold has to make them exact again.
55
+ const LEDGER_ROWS = [
56
+ { builder_id: String(INES), discipline: 'artist', amount: 120 },
57
+ { builder_id: String(INES), discipline: 'ideator', amount: 42 },
58
+ { builder_id: String(INES), discipline: 'engineer', amount: 74.5 },
59
+ { builder_id: String(INES), discipline: 'ui', amount: 74.5 },
60
+ { builder_id: String(INES), discipline: 'other', amount: 1 },
61
+ { builder_id: String(TOBI), discipline: 'ideator', amount: 186 },
62
+ { builder_id: String(MARA), discipline: 'engineer', amount: 2460 },
63
+ { builder_id: String(MARA), discipline: 'ideator', amount: 20 },
64
+ { builder_id: String(UI), discipline: 'ui', amount: 60 },
65
+ // a discipline nobody mapped yet counts as engineering, never vanishes
66
+ { builder_id: String(UI), discipline: 'some-module-craft', amount: 0 },
67
+ ];
68
+
69
+ // ---- the page-tweak ledger (Ines and one other artist) ----------------------
70
+
71
+ const pages = require('../modules/copy-desk/pages.js');
72
+ const inventory = require('../docs/page-inventory.json');
73
+ const onSurface = (s) => inventory.pages.filter((p) => p.surface === s).map((p) => p.id);
74
+ const LANDING = onSurface('landing');
75
+ const BUILDERS = onSurface('builders');
76
+ const NOW = new Date();
77
+ const DAY = 24 * 60 * 60 * 1000;
78
+ const PAGE_WORDS = 'DO_NOT_LEAK_PAGE_WORDING';
79
+ let tid = 9000;
80
+ const round = (pageId, n, artistId, status) => ({
81
+ id: String(++tid), source: 'page-tweak', source_ref: pages.roundSourceRef(pageId, n), status, artist_id: String(artistId),
82
+ description: `\`\`\`page-tweak\n${PAGE_WORDS}\n\`\`\``, title: `Tweak: ${PAGE_WORDS}`,
83
+ });
84
+ const PAGE_TASKS = [
85
+ round(LANDING[0], 1, INES, 'shipped'),
86
+ round(LANDING[0], 2, INES, 'shipped'), // the same page twice: two approvals, one page
87
+ round(LANDING[1], 1, INES, 'shipped'),
88
+ round(BUILDERS[0], 1, INES, 'shipped'),
89
+ round(BUILDERS[1], 1, INES, 'ready'), // still being written: not approved
90
+ round(BUILDERS[2], 1, LEA, 'shipped'), // someone else's page
91
+ ];
92
+ const PAGE_CREDITS = {
93
+ [INES]: [
94
+ { reason: `tweak.page_approved:${PAGE_TASKS[0].id}`, delta: 30, recorded_at: new Date(NOW - 20 * DAY).toISOString() },
95
+ { reason: `tweak.page_approved:${PAGE_TASKS[1].id}`, delta: 30, recorded_at: new Date(NOW - 10 * DAY).toISOString() },
96
+ { reason: `tweak.page_approved:${PAGE_TASKS[2].id}`, delta: 30, recorded_at: new Date(NOW - 2 * DAY).toISOString() },
97
+ { reason: `tweak.page_approved:${PAGE_TASKS[3].id}`, delta: 30, recorded_at: new Date(NOW - 1 * DAY).toISOString() },
98
+ ],
99
+ [LEA]: [{ reason: `tweak.page_approved:${PAGE_TASKS[5].id}`, delta: 30, recorded_at: NOW.toISOString() }],
100
+ };
101
+
102
+ // ---- the idea trails, with states derived by the REAL resolver ---------------
103
+
104
+ const { resolveIdeaState } = require('../modules/ideas/idea-state.js');
105
+ const IDEA_WORDS = 'DO_NOT_LEAK_IDEA_TITLE';
106
+ function thought(id, { status = 'open', grade = 'quick', credits = 0, estimated = 0, share = 1, attributable = true } = {}) {
107
+ const hasWork = credits > 0;
108
+ return {
109
+ id: String(id), title: `${IDEA_WORDS} ${id}`, triage_note: IDEA_WORDS, status, grade,
110
+ size: { credits, paid: credits - estimated, estimated, payments: hasWork ? 1 : 0, byTask: new Map() },
111
+ state: resolveIdeaState({ status, hasWork, share: hasWork ? share : 1, attributable, tasks: [] }),
112
+ };
113
+ }
114
+ const TRAILS = {
115
+ [TOBI]: [
116
+ thought(1, { grade: 'full', credits: 64, share: 1 }),
117
+ thought(2, { grade: 'full', credits: 42, share: 0.6 }),
118
+ thought(3),
119
+ thought(4, { credits: 18 }),
120
+ thought(5, { credits: 22, estimated: 10, share: 0 }), // burned out; part of it historical
121
+ thought(6, { status: 'merged' }),
122
+ thought(7, { status: 'discarded' }),
123
+ thought(8, { grade: 'full', credits: 26, share: 0.85 }),
124
+ ],
125
+ [INES]: [thought(21, { credits: 42 }), thought(22), thought(23), thought(24)],
126
+ [MARA]: [thought(31, { credits: 20 })],
127
+ };
128
+ const RATIFIED = new Set(['1']);
129
+ const skyCalls = [];
130
+ const pageReads = [];
131
+
132
+ // ---- the harness: real route, real composer, ports at their real seams -------
133
+
134
+ const authPath = require.resolve('../src/bongos/auth.js');
135
+ require.cache[authPath] = {
136
+ id: authPath, filename: authPath, loaded: true,
137
+ exports: {
138
+ requireBuilder: (_req, _res, next) => next(),
139
+ requirePermission: () => (_req, _res, next) => next(),
140
+ requireRank: () => (_req, _res, next) => next(),
141
+ },
142
+ };
143
+ const dbPath = require.resolve('../src/bongos/db.js');
144
+ require.cache[dbPath] = {
145
+ id: dbPath, filename: dbPath, loaded: true,
146
+ exports: {
147
+ RANKS: ['xenos', 'thetes', 'metic', 'archon'],
148
+ getBuilderById: async (id) => (ROWS[id] ? { ...ROWS[id], created_at: '2026-07-01T00:00:00.000Z', avatar_url: null } : null),
149
+ getBuilderRoleChoice: async (id) => (ROWS[id]
150
+ ? { preferred_disciplines: ROWS[id].preferred_disciplines, main_discipline: ROWS[id].main_discipline }
151
+ : null),
152
+ },
153
+ };
154
+
155
+ const seams = require('../src/module-seams.js');
156
+ seams._reset();
157
+ const creditsByCraftLib = require('../modules/economy/credits-by-craft.js');
158
+ const ledgerSql = [];
159
+ const ledgerClient = {
160
+ async query(sql, params) {
161
+ ledgerSql.push({ sql, params });
162
+ const ids = new Set(params[0].map(String));
163
+ return { rows: LEDGER_ROWS.filter((r) => ids.has(r.builder_id)) };
164
+ },
165
+ };
166
+ seams.registerProvider('lifecycle', {
167
+ countShippedTasksForBuilder: async (id) => SHIPPED[id] || 0,
168
+ recentShippedTasksByBuilder: async () => [],
169
+ // Honours the artist scope the way the SQL does (only rounds whose latest web
170
+ // claim is theirs), and records it, so the read is held to asking for one
171
+ // artist rather than every round in the project.
172
+ listPageTweakTasks: async (opts = {}) => {
173
+ pageReads.push(opts);
174
+ return PAGE_TASKS.filter((r) => opts.artistId == null || r.artist_id === String(opts.artistId)).map((r) => ({ ...r }));
175
+ },
176
+ });
177
+ seams.registerProvider('reward', {
178
+ getBuilderAchievements: async () => [],
179
+ creditsByCraft: (ids) => creditsByCraftLib.creditsByCraft(ids, { client: ledgerClient }),
180
+ craftForPreference: (d) => creditsByCraftLib.craftForPreference(d),
181
+ creditLedger: async (builderId, { reasonPrefix }) => {
182
+ const rows = (PAGE_CREDITS[builderId] || []).filter((r) => r.reason.startsWith(reasonPrefix));
183
+ return { rows, total: rows.length };
184
+ },
185
+ });
186
+ seams.registerProvider('government', { boardPassForIdea: async (id) => (RATIFIED.has(String(id)) ? { outcome: 'passed' } : null) });
187
+ const ideatorStats = require('../modules/ideas/ideator-stats.js');
188
+ seams.registerProvider('ideas.ideatorStats', (id) => ideatorStats.readIdeatorStats(id, {
189
+ readSkyMine: async ({ builderId, viewerRank }) => {
190
+ skyCalls.push({ builderId, viewerRank });
191
+ const ideas = TRAILS[builderId] || [];
192
+ return { ideas, total: ideas.length, truncated: false };
193
+ },
194
+ }));
195
+ // The copy-desk router's factory is the production registration of its port.
196
+ require('../modules/copy-desk/routes/copy-desk.js')();
197
+ assert.equal(typeof seams.resolveOptional('copy-desk.artistStats'), 'function', 'copy-desk registers its read port at boot');
198
+
199
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
200
+ const buildBuildersRouter = require('../src/bongos/routes/builders.js');
201
+ const app = express();
202
+ app.use(express.json());
203
+ app.use(attachFail);
204
+ app.use('/api/bongos', buildBuildersRouter());
205
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
206
+ const port = server.address().port;
207
+ function get(path) {
208
+ return new Promise((resolve, reject) => {
209
+ const req = http.request({ host: '127.0.0.1', port, method: 'GET', path }, (res) => {
210
+ const chunks = [];
211
+ res.on('data', (c) => chunks.push(c));
212
+ res.on('end', () => {
213
+ const text = Buffer.concat(chunks).toString('utf8');
214
+ resolve({ status: res.statusCode, json: JSON.parse(text), text });
215
+ });
216
+ });
217
+ req.on('error', reject);
218
+ req.end();
219
+ });
220
+ }
221
+ const profile = async (id) => {
222
+ const r = await get(`/api/bongos/builders/${id}/profile`);
223
+ assert.equal(r.status, 200, r.text);
224
+ return r;
225
+ };
226
+
227
+ // ---- the artist who has never shipped a task --------------------------------
228
+
229
+ await t('ARTIST, never shipped: leads with artist, by what she earned, and shows ideator beside it', async () => {
230
+ const { json } = await profile(INES);
231
+ assert.equal(json.shipped_count, 0, 'the fixture is the case the task names: no shipped task at all');
232
+ const r = json.roles;
233
+ assert.equal(r.main, 'artist');
234
+ assert.equal(r.main_basis, 'earned', 'no main role chosen yet, so it is inferred and says so');
235
+ assert.deepEqual(r.shown, ['artist', 'ideator', 'engineer'],
236
+ 'main first, then every craft she EARNED in, in her preferred order (her session reward was part engineering)');
237
+ assert.equal(r.engineer.shipped, 0);
238
+ });
239
+
240
+ await t('ARTIST: pages approved, pages, N of M per surface, credits from pages and this week', async () => {
241
+ const a = (await profile(INES)).json.roles.artist;
242
+ assert.equal(a.pages_approved, 4, 'four approved rounds, each one paid');
243
+ assert.equal(a.pages, 3, 'on three distinct pages (one was tweaked twice)');
244
+ assert.equal(a.surfaces_touched, 2);
245
+ assert.equal(a.credits, 120, 'credits from pages are the page rows only, not her other pay');
246
+ assert.equal(a.this_week, 60, 'two approvals inside the trailing week');
247
+ const landing = a.surfaces.find((s) => s.id === 'landing');
248
+ const builders = a.surfaces.find((s) => s.id === 'builders');
249
+ assert.equal(landing.tweaked, 2);
250
+ assert.equal(landing.total, LANDING.length, 'M is the surface page count, the studio denominator');
251
+ assert.equal(landing.line, `2 of ${LANDING.length} tweaked`);
252
+ assert.equal(builders.tweaked, 1, 'her unapproved draft and the other artist page do not count');
253
+ assert.equal(builders.total, BUILDERS.length);
254
+ assert.equal(a.inventory_known, true);
255
+ assert.deepEqual(pageReads[pageReads.length - 1], { artistId: INES }, 'one artist rounds, never the whole project');
256
+ });
257
+
258
+ await t('the credit split is exact: halves rounded by largest remainder, ui folded into artist', async () => {
259
+ const c = (await profile(INES)).json.roles.credits_by_craft;
260
+ assert.deepEqual(c, { engineer: 75, artist: 194, ideator: 42, other: 1, total: 312 },
261
+ '74.5 + 74.5 must not round to 150 or 148; the parts equal the whole');
262
+ assert.equal(c.engineer + c.artist + c.ideator + c.other, c.total);
263
+ });
264
+
265
+ // ---- the ideator who has never shipped a task -------------------------------
266
+
267
+ await t('IDEATOR, never shipped: leads with ideator, shows nothing it did not earn', async () => {
268
+ const { json } = await profile(TOBI);
269
+ assert.equal(json.shipped_count, 0);
270
+ assert.equal(json.roles.main, 'ideator');
271
+ assert.deepEqual(json.roles.shown, ['ideator']);
272
+ assert.equal(json.roles.engineer, null, 'no engineering block for someone with no engineering credit');
273
+ assert.equal(json.roles.artist, null);
274
+ });
275
+
276
+ await t('IDEATOR: filed, ignited, Full Ideas, ratified, total size every stream + the estimate marker, still alive', async () => {
277
+ const i = (await profile(TOBI)).json.roles.ideator;
278
+ assert.equal(i.filed, 8);
279
+ assert.equal(i.ignited, 5, 'the five that were paid for work, a burned-out one included');
280
+ assert.equal(i.full_ideas, 3);
281
+ assert.equal(i.ratified, 1, 'the one the board carried');
282
+ assert.deepEqual(i.size, { total: 196, paid: 186, estimated: 10, historical: true },
283
+ 'the ledger ideator column (every stream) plus the historical estimate, marked');
284
+ const expected = (64 * 1 + 42 * 0.6 + 18 + 22 * 0 + 26 * 0.85) / (64 + 42 + 18 + 22 + 26);
285
+ assert.ok(Math.abs(i.alive.share - expected) < 1e-9, `size-weighted live share, got ${i.alive.share}`);
286
+ assert.equal(i.alive.has_work, true);
287
+ assert.equal(i.truncated, false);
288
+ });
289
+
290
+ await t('the trail is read at the public rank, so a count never changes with who is looking', async () => {
291
+ skyCalls.length = 0;
292
+ await profile(TOBI);
293
+ assert.deepEqual(skyCalls, [{ builderId: TOBI, viewerRank: 'xenos' }]);
294
+ });
295
+
296
+ // ---- the engineer ------------------------------------------------------------
297
+
298
+ await t('ENGINEER: unchanged headline (works shipped), chosen main role, a little ideator beside it', async () => {
299
+ const r = (await profile(MARA)).json.roles;
300
+ assert.equal(r.main, 'engineer');
301
+ assert.equal(r.main_basis, 'chosen');
302
+ assert.deepEqual(r.shown, ['engineer', 'ideator']);
303
+ assert.deepEqual(r.engineer, { shipped: 64, credits: 2460 });
304
+ assert.equal(r.artist, null, 'no page read for someone with no artist credit');
305
+ assert.equal(r.ideator.filed, 1);
306
+ assert.equal(r.ideator.size.paid, 20);
307
+ });
308
+
309
+ // ---- the fallbacks and the boundary ------------------------------------------
310
+
311
+ await t('main role: a chosen ui reads as artist; nothing chosen and nothing earned is engineering, said to be a default', async () => {
312
+ const ui = (await profile(UI)).json.roles;
313
+ assert.equal(ui.main, 'artist');
314
+ assert.equal(ui.main_basis, 'chosen');
315
+ assert.deepEqual(ui.preferred, ['engineer', 'artist']);
316
+ const nobody = (await profile(NEW)).json.roles;
317
+ assert.equal(nobody.main, 'engineer');
318
+ assert.equal(nobody.main_basis, 'default');
319
+ assert.deepEqual(nobody.shown, ['engineer']);
320
+ assert.equal(nobody.credits_by_craft.total, 0, 'zero credits is an answer, not a gap');
321
+ });
322
+
323
+ await t('decision (a): the public answer is counts and sums, never an idea title or a word of a page', async () => {
324
+ for (const id of [INES, TOBI, MARA]) {
325
+ const { text } = await profile(id);
326
+ assert.ok(!text.includes(IDEA_WORDS), 'no idea title or triage note on the public profile');
327
+ assert.ok(!text.includes(PAGE_WORDS), 'no page wording on the public profile');
328
+ }
329
+ });
330
+
331
+ await t('a missing port reads as unknown (null), never as zero', async () => {
332
+ const ideasPort = seams.resolveOptional('ideas.ideatorStats');
333
+ seams._reset();
334
+ seams.registerProvider('lifecycle', { countShippedTasksForBuilder: async () => 0, recentShippedTasksByBuilder: async () => [] });
335
+ seams.registerProvider('reward', { getBuilderAchievements: async () => [], creditsByCraft: (ids) => creditsByCraftLib.creditsByCraft(ids, { client: ledgerClient }) });
336
+ try {
337
+ const r = (await profile(TOBI)).json.roles;
338
+ assert.equal(r.ideator.filed, null);
339
+ assert.equal(r.ideator.unavailable, true);
340
+ } finally {
341
+ seams.registerProvider('ideas.ideatorStats', ideasPort);
342
+ }
343
+ });
344
+
345
+ // ---- the pure halves ----------------------------------------------------------
346
+
347
+ await t('apportion hands out exactly the rounding left over, larger remainder first, ties to the earlier key', () => {
348
+ assert.deepEqual(creditsByCraftLib.apportion({ a: 1.5, b: 1.5, c: 0 }, 3), { a: 2, b: 1, c: 0 });
349
+ assert.deepEqual(creditsByCraftLib.apportion({ a: 0.2, b: 0.7, c: 0.1 }, 1), { a: 0, b: 1, c: 0 });
350
+ assert.deepEqual(creditsByCraftLib.apportion({ a: 10, b: 0 }, 10), { a: 10, b: 0 });
351
+ });
352
+
353
+ await t('the ledger statement classifies by reason, splits the session reward and escapes the artist prefix', () => {
354
+ const sql = creditsByCraftLib.CREDITS_BY_CRAFT_SQL;
355
+ assert.match(sql, /cl\.reason LIKE \$2 THEN 'artist'/);
356
+ assert.match(sql, /idea\.credit\.%' OR cl\.reason = 'idea_promotion_bonus' THEN 'ideator'/);
357
+ assert.match(sql, /GREATEST\(COALESCE\(cardinality\(sr\.task_ids\), 0\), 1\)/, 'an even split, and a session that shipped nothing still counts once');
358
+ const last = ledgerSql[ledgerSql.length - 1];
359
+ assert.equal(last.params[1], 'tweak.page\\_approved:%', 'the underscore is escaped, so it is not a LIKE wildcard');
360
+ assert.deepEqual(last.params[2], ['task.shipped', 'task.confirmed', 'ship.net_negative']);
361
+ });
362
+
363
+ await t('the craft split reads one person rows through existing indexes, never the whole ledger', () => {
364
+ const fs = require('node:fs');
365
+ const path = require('node:path');
366
+ const mig = (f) => fs.readFileSync(path.join(path.dirname(require.resolve('../package.json')), 'migrations', f), 'utf8');
367
+ assert.match(mig('003_pms.sql'), /CREATE INDEX IF NOT EXISTS idx_credit_log_builder ON credit_log \(builder_id, recorded_at\)/,
368
+ 'credit_log is indexed by builder: the WHERE cl.builder_id = ANY($1) in both branches is an index read');
369
+ assert.match(mig('062_session_records.sql'), /session_id\s+text NOT NULL UNIQUE/, 'the session join is a unique-index probe');
370
+ const sql = creditsByCraftLib.CREDITS_BY_CRAFT_SQL;
371
+ assert.equal((sql.match(/WHERE cl\.builder_id = ANY\(\$1::bigint\[\]\)/g) || []).length, 2, 'both branches are scoped to the builders asked for');
372
+ });
373
+
374
+ await t('the per-person fragment only takes a bind reference, and the task payout counts the artist and idea rows', () => {
375
+ const { creditsEarnedBySql, CREDITS_AWARDED_SUBQUERY } = require('../modules/lifecycle/db-tasks.js');
376
+ assert.throws(() => creditsEarnedBySql('1; DROP TABLE tasks'), /bind parameter/);
377
+ const frag = creditsEarnedBySql('$3');
378
+ assert.match(frag, /cl\.builder_id = \$3/);
379
+ assert.match(frag, /sr\.builder_id = \$3/);
380
+ assert.match(frag, /cl\.reason = 'session\.token_reward'/);
381
+ assert.match(CREDITS_AWARDED_SUBQUERY, /tweak\.page\\_approved:%/);
382
+ assert.match(CREDITS_AWARDED_SUBQUERY, /idea\.credit\.%/);
383
+ assert.doesNotMatch(CREDITS_AWARDED_SUBQUERY, /achievement/);
384
+ });
385
+
386
+ await t('the profile lists read what THIS person earned, not the task payout', () => {
387
+ const fs = require('node:fs');
388
+ const activity = fs.readFileSync(require.resolve('../modules/lifecycle/activity.js'), 'utf8');
389
+ const analytics = fs.readFileSync(require.resolve('../modules/lifecycle/db-analytics.js'), 'utf8');
390
+ assert.match(activity, /\$\{creditsEarnedBySql\('\$1'\)\} AS credits_awarded/);
391
+ const byBuilder = analytics.slice(analytics.indexOf('async function recentShippedTasksByBuilder'));
392
+ assert.match(byBuilder.slice(0, 1200), /\$\{creditsEarnedBySql\('\$1'\)\} AS credits_awarded/);
393
+ });
394
+
395
+ await t('the artist-scoped page read starts from that builder web claims and keeps the latest-web-claim rule', () => {
396
+ const fs = require('node:fs');
397
+ const src = fs.readFileSync(require.resolve('../modules/lifecycle/page-tweak-reads.js'), 'utf8');
398
+ const fn = src.slice(src.indexOf('async function listPageTweakTasks'), src.indexOf('async function shippedTasksTouchingSince'));
399
+ assert.match(fn, /FROM claims c JOIN tasks t ON t\.id = c\.task_id\s+WHERE \$2::bigint IS NOT NULL AND c\.builder_id = \$2::bigint/,
400
+ 'an artist rounds are found from their own claims, not by listing every round');
401
+ assert.match(fn, /wc\.builder_id = \$2::bigint/, 'and a round counts only while its latest web claim is still theirs');
402
+ assert.match(fn, /\[prefix, artist\]/);
403
+ });
404
+
405
+ await t('summarizeIdeatorTrail: no work is no share (not zero), an unreadable thought makes the share unknown', () => {
406
+ const none = ideatorStats.summarizeIdeatorTrail([thought(1), thought(2)], { ideatorCredits: 0, ratifiedIds: new Set() });
407
+ assert.deepEqual(none.alive, { share: null, has_work: false, attributable: null });
408
+ const blind = ideatorStats.summarizeIdeatorTrail([thought(1, { credits: 5, attributable: false })], { ideatorCredits: 5, ratifiedIds: null });
409
+ assert.equal(blind.alive.share, null);
410
+ assert.equal(blind.alive.attributable, false);
411
+ assert.equal(blind.ratified, null, 'no board port: ratified is unknown, not zero');
412
+ });
413
+
414
+ server.close();
415
+ console.log(`\n${passed} passed, ${failed} failed`);
416
+ if (failed) process.exit(1);
@@ -0,0 +1,237 @@
1
+ // tests/role_stats_db.mjs — the two new money reads of WA6.RP03 (task 1004433)
2
+ // against a REAL Postgres: economy's credits-by-craft split and the lifecycle's
3
+ // per-person earnings fragment.
4
+ //
5
+ // WHY A REAL DATABASE. Both are WHERE-clause and JOIN questions — which ledger
6
+ // rows a LIKE pattern reaches (the escaped underscore in the artist prefix), how
7
+ // an empty session_records.task_ids array fans out through a LATERAL unnest,
8
+ // and what numeric ROUND does to a half credit. A stubbed client answers
9
+ // exactly the assumption under test (the session_earnings_mirror_db argument),
10
+ // so tests/profile_role_stats.mjs covers the pure halves and this covers the SQL.
11
+ //
12
+ // It SELF-SKIPS (exit 0) without a reachable Postgres and is listed in
13
+ // run-unit-tests.js's INTEGRATION set. Every test runs inside one transaction
14
+ // under a savepoint and the whole run is rolled back: it writes nothing that
15
+ // survives, so it is safe against a dev database.
16
+ //
17
+ // Run: DATABASE_URL=postgres://... node tests/role_stats_db.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { Pool } = require('pg');
24
+ const byCraft = require('../modules/economy/credits-by-craft.js');
25
+ const { creditsEarnedBySql, CREDITS_AWARDED_SUBQUERY } = require('../modules/lifecycle/db-tasks.js');
26
+
27
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
28
+
29
+ let passed = 0;
30
+ let failed = 0;
31
+ let client;
32
+ async function test(name, fn) {
33
+ await client.query('SAVEPOINT t');
34
+ try {
35
+ await fn();
36
+ passed++; console.log(` ok ${name}`);
37
+ await client.query('RELEASE SAVEPOINT t');
38
+ } catch (err) {
39
+ failed++; console.error(` FAIL ${name}\n ${err.message}`);
40
+ await client.query('ROLLBACK TO SAVEPOINT t');
41
+ }
42
+ }
43
+
44
+ // The schema-driven row builder session_earnings_mirror_db uses: fill every
45
+ // NOT NULL column with no default, so the fixture survives a later migration
46
+ // adding one.
47
+ const schemaCache = new Map();
48
+ async function columnsOf(table) {
49
+ if (schemaCache.has(table)) return schemaCache.get(table);
50
+ const { rows } = await client.query(
51
+ `SELECT column_name, data_type, is_nullable, column_default, is_identity
52
+ FROM information_schema.columns
53
+ WHERE table_schema = 'public' AND table_name = $1`,
54
+ [table]
55
+ );
56
+ schemaCache.set(table, rows);
57
+ return rows;
58
+ }
59
+ function placeholderFor(dataType, columnName) {
60
+ switch (dataType) {
61
+ case 'integer': case 'bigint': case 'smallint':
62
+ case 'numeric': case 'real': case 'double precision':
63
+ return 0;
64
+ case 'boolean': return false;
65
+ case 'timestamp with time zone': case 'timestamp without time zone': case 'date':
66
+ return new Date();
67
+ case 'json': case 'jsonb': return {};
68
+ case 'ARRAY': return [];
69
+ default: return `rp03-${columnName}`;
70
+ }
71
+ }
72
+ async function insertRow(table, explicit = {}) {
73
+ const cols = await columnsOf(table);
74
+ if (cols.length === 0) throw new Error(`table ${table} not found — is this database migrated?`);
75
+ const values = { ...explicit };
76
+ for (const c of cols) {
77
+ if (c.column_name in values) continue;
78
+ if (c.is_nullable === 'YES' || c.column_default !== null || c.is_identity === 'YES') continue;
79
+ values[c.column_name] = placeholderFor(c.data_type, c.column_name);
80
+ }
81
+ const names = Object.keys(values);
82
+ const { rows } = await client.query(
83
+ `INSERT INTO ${table} (${names.map((n) => `"${n}"`).join(', ')})
84
+ VALUES (${names.map((_, i) => `$${i + 1}`).join(', ')}) RETURNING *`,
85
+ names.map((n) => values[n])
86
+ );
87
+ return rows[0];
88
+ }
89
+ function credit(builderId, { taskId = null, delta, reason, sessionId = null, basis = null }) {
90
+ return client.query(
91
+ `INSERT INTO credit_log (builder_id, task_id, delta, reason, description, session_id, reward_basis)
92
+ VALUES ($1, $2, $3, $4, 'rp03 test', $5, COALESCE($6::int, 0))`,
93
+ [builderId, taskId, delta, reason, sessionId, basis]
94
+ );
95
+ }
96
+
97
+ // One builder with a row in every branch of the classification, and a second
98
+ // builder holding a row on the same task, who must never leak into the first.
99
+ async function fixture() {
100
+ const tag = `rp03-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
101
+ const b = await insertRow('builders', { github_id: `${tag}-gh`, github_login: tag, display_name: tag, total_credits: 0 });
102
+ const other = await insertRow('builders', { github_id: `${tag}-gh2`, github_login: `${tag}-2`, display_name: `${tag}-2`, total_credits: 0 });
103
+ const version = await insertRow('versions', { id: `${tag}-v`, status: 'building' });
104
+ const mk = (title, discipline) => insertRow('tasks', { title: `${tag} ${title}`, status: 'shipped', version_id: version.id, discipline });
105
+ const eng = await mk('eng', 'engineer');
106
+ const page = await mk('page', 'artist');
107
+ const ui = await mk('ui', 'ui');
108
+ const sid = `${tag}-session`;
109
+ await insertRow('session_records', { session_id: sid, builder_id: b.id, task_ids: [eng.id, ui.id], drachmae_earned: 0, total_tokens: 1 });
110
+ const lone = `${tag}-lone`;
111
+ await insertRow('session_records', { session_id: lone, builder_id: b.id, task_ids: [], drachmae_earned: 0, total_tokens: 1 });
112
+
113
+ await credit(b.id, { taskId: eng.id, delta: 10, reason: 'task.shipped' });
114
+ await credit(b.id, { taskId: page.id, delta: 30, reason: `tweak.page_approved:${page.id}` });
115
+ await credit(b.id, { taskId: eng.id, delta: 4, reason: `idea.credit.task_ship:5:${eng.id}` });
116
+ await credit(b.id, { delta: 5, reason: 'idea.credit.full_pass:5' });
117
+ await credit(b.id, { taskId: eng.id, delta: 2, reason: 'achievement.first_ship' });
118
+ await credit(b.id, { delta: 9, reason: 'session.token_reward', sessionId: sid, basis: 9 });
119
+ await credit(b.id, { delta: 3, reason: 'session.token_reward', sessionId: lone, basis: 3 });
120
+ // A LIKE-wildcard twin: an unescaped underscore would let this row read as an
121
+ // approved page. It is not one, so it must fall to `other`.
122
+ await credit(b.id, { delta: 7, reason: 'tweak.pageXapproved:1' });
123
+ await credit(other.id, { taskId: eng.id, delta: 50, reason: 'task.shipped' });
124
+ return { b, other, eng, page, ui };
125
+ }
126
+
127
+ async function main() {
128
+ try {
129
+ client = await pool.connect();
130
+ } catch {
131
+ console.log('role_stats_db: no Postgres (DATABASE_URL unset/unreachable) — skipping');
132
+ await pool.end().catch(() => {});
133
+ return;
134
+ }
135
+ await client.query('BEGIN');
136
+ try {
137
+ const f = await fixture();
138
+
139
+ await test('credits by craft: every reason lands in its craft and the parts sum to the ledger exactly', async () => {
140
+ const map = await byCraft.creditsByCraft([f.b.id], { client });
141
+ const row = map.get(String(f.b.id));
142
+ // engineer: 10 shipped + half of the 9 session + the 3 lone session = 17.5
143
+ // artist: 30 page + the ui half of the session (4.5) = 34.5
144
+ // ideator: 4 stream 1 + 5 full pass = 9
145
+ // other: 2 achievement + 7 wildcard twin = 9
146
+ assert.equal(row.ideator, 9);
147
+ assert.equal(row.other, 9);
148
+ assert.equal(row.total, 70);
149
+ assert.equal(row.engineer + row.artist + row.ideator + row.other, row.total, 'the parts are exact to the whole');
150
+ // 17.5 and 34.5 floor to 51 of 52; the one left over goes to the larger
151
+ // remainder, and on a tie to the earlier craft, engineer.
152
+ assert.equal(row.engineer, 18);
153
+ assert.equal(row.artist, 34);
154
+ });
155
+
156
+ await test('credits by craft: another builder on the same task is never counted', async () => {
157
+ const map = await byCraft.creditsByCraft([f.other.id], { client });
158
+ assert.equal(map.get(String(f.other.id)).engineer, 50);
159
+ assert.equal(map.get(String(f.other.id)).total, 50);
160
+ });
161
+
162
+ await test('person earnings: the per-task rows plus an even share of the session, this builder only', async () => {
163
+ const { rows } = await client.query(
164
+ `SELECT t.id, ${creditsEarnedBySql('$1')} AS c FROM tasks t WHERE t.id = ANY($2::bigint[]) ORDER BY t.id`,
165
+ [f.b.id, [f.eng.id, f.page.id, f.ui.id]]
166
+ );
167
+ const c = Object.fromEntries(rows.map((r) => [String(r.id), Number(r.c)]));
168
+ // eng: 10 shipped + 4 idea + round(9/2 = 4.5) = 19 (the achievement is not pay for the task)
169
+ assert.equal(c[String(f.eng.id)], 19);
170
+ assert.equal(c[String(f.page.id)], 30, 'the approved page is the artist pay for it');
171
+ assert.equal(c[String(f.ui.id)], 5, 'the other half of the session');
172
+ const { rows: o } = await client.query(`SELECT ${creditsEarnedBySql('$1')} AS c FROM tasks t WHERE t.id = $2`, [f.other.id, f.eng.id]);
173
+ assert.equal(Number(o[0].c), 50, 'the second builder reads their own pay, not the first one');
174
+ });
175
+
176
+ await test('main role: set, kept while still chosen, cleared when its craft is dropped, cleared on purpose', async () => {
177
+ const { UPDATE_BUILDER_DISCIPLINES_SQL: sql } = require('../src/bongos/db.js');
178
+ const run = async (list, setMain, main) =>
179
+ (await client.query(sql, [f.b.id, list, setMain, main])).rows[0].main_discipline;
180
+ assert.equal(await run(['engineer', 'artist'], true, 'artist'), 'artist', 'chosen');
181
+ assert.equal(await run(['engineer', 'artist', 'ideator'], false, null), 'artist', 'not mentioned, still one of the crafts: kept');
182
+ assert.equal(await run(['engineer'], false, null), null, 'the craft was dropped: a main role they no longer practise is cleared');
183
+ assert.equal(await run(['engineer'], true, 'engineer'), 'engineer');
184
+ assert.equal(await run(['engineer'], true, null), null, 'cleared on purpose');
185
+ });
186
+
187
+ await test('artist page read: only rounds whose LATEST web claim is theirs, never a CLI claim, never an ordinary task', async () => {
188
+ const { listPageTweakTasks } = require('../modules/lifecycle/page-tweak-reads.js');
189
+ const tag = `rp03p-${Date.now()}`;
190
+ const version = await insertRow('versions', { id: `${tag}-v`, status: 'building' });
191
+ const mk = (title, extra) => insertRow('tasks', { title: `${tag} ${title}`, status: 'shipped', version_id: version.id, discipline: 'artist', ...extra });
192
+ const mine = await mk('mine', { source: 'page-tweak', source_ref: `page-tweak/landing:${tag}a/r1` });
193
+ const takenOver = await mk('taken over', { source: 'page-tweak', source_ref: `page-tweak/landing:${tag}b/r1` });
194
+ const cliOnly = await mk('cli only', { source: 'page-tweak', source_ref: `page-tweak/landing:${tag}c/r1` });
195
+ const ordinary = await mk('ordinary', {});
196
+ const t0 = Date.now() - 60 * 60 * 1000;
197
+ const claim = (taskId, builderId, minutes, worktree = null) => insertRow('claims', {
198
+ task_id: taskId, builder_id: builderId, worktree_name: worktree, creator_session_id: null,
199
+ claimed_at: new Date(t0 + minutes * 60 * 1000), released_at: new Date(t0 + (minutes + 1) * 60 * 1000),
200
+ });
201
+ await claim(mine.id, f.b.id, 0);
202
+ await claim(takenOver.id, f.b.id, 0);
203
+ await claim(takenOver.id, f.other.id, 5); // the later web claim is someone else's
204
+ await claim(cliOnly.id, f.b.id, 0, `${tag}-wt`); // a worktree claim is an applier, not an artist
205
+ await claim(ordinary.id, f.b.id, 0);
206
+
207
+ const scoped = await listPageTweakTasks({ artistId: f.b.id, client });
208
+ assert.deepEqual(scoped.map((r) => String(r.id)), [String(mine.id)]);
209
+ assert.equal(String(scoped[0].artist_id), String(f.b.id));
210
+
211
+ const theirs = await listPageTweakTasks({ artistId: f.other.id, client });
212
+ assert.deepEqual(theirs.map((r) => String(r.id)), [String(takenOver.id)]);
213
+
214
+ const all = (await listPageTweakTasks({ client })).map((r) => String(r.id));
215
+ for (const id of [mine.id, takenOver.id, cliOnly.id]) assert.ok(all.includes(String(id)), 'the unscoped read is unchanged: every page round');
216
+ assert.ok(!all.includes(String(ordinary.id)));
217
+ });
218
+
219
+ await test('task payout: the approved page and the idea row now count, the achievement still does not', async () => {
220
+ const { rows } = await client.query(
221
+ `SELECT t.id, ${CREDITS_AWARDED_SUBQUERY} AS c FROM tasks t WHERE t.id = ANY($1::bigint[])`,
222
+ [[f.eng.id, f.page.id]]
223
+ );
224
+ const c = Object.fromEntries(rows.map((r) => [String(r.id), Number(r.c)]));
225
+ assert.equal(c[String(f.page.id)], 30);
226
+ assert.equal(c[String(f.eng.id)], 10 + 4 + 50, 'both shippers and the idea, no achievement, no session');
227
+ });
228
+ } finally {
229
+ await client.query('ROLLBACK').catch(() => {});
230
+ client.release();
231
+ await pool.end().catch(() => {});
232
+ }
233
+ console.log(`\n${passed} passed, ${failed} failed`);
234
+ if (failed) process.exit(1);
235
+ }
236
+
237
+ main().catch((err) => { console.error(err); process.exit(1); });
@@ -51,7 +51,7 @@ api.pool = {
51
51
  async query(sql, params = []) {
52
52
  const q = flat(sql);
53
53
  seen.push({ q, params });
54
- if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) return { rows: F.TASKS };
54
+ if (/FROM pt JOIN tasks t ON t\.id = pt\.id LEFT JOIN task_grades g/.test(q)) return { rows: F.TASKS };
55
55
  if (/t\.touches && \$1::text\[\]/.test(q)) {
56
56
  // The real query's predicate, applied to the one ship: shipped after the
57
57
  // page's last round, touching one of the page's files.