@bongos/core 1.20.58 → 1.20.60

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 (119) hide show
  1. package/.bongos-core.json +198 -108
  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 +15 -2
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/adr/0358-a-hall-wears-a-look-and-its-night-takes-a-tint.md +52 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +317 -6
  10. package/docs/api-reference.md +8 -3
  11. package/docs/architecture.md +1 -0
  12. package/docs/copy-inventory.md +339 -330
  13. package/docs/copy-registry.json +524 -439
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +4 -1
  16. package/docs/page-readings.json +729 -678
  17. package/migrations/core_268_builders_main_discipline.sql +35 -0
  18. package/modules/copy-desk/artist-stats.js +56 -0
  19. package/modules/copy-desk/module.json +1 -1
  20. package/modules/copy-desk/page-status.js +39 -0
  21. package/modules/copy-desk/routes/copy-desk.js +7 -16
  22. package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
  23. package/modules/economy/credits-by-craft.js +208 -0
  24. package/modules/economy/reward.js +8 -0
  25. package/modules/hall-ui/public/atlas.html +1 -1
  26. package/modules/hall-ui/public/blockers.html +1 -1
  27. package/modules/hall-ui/public/board-room.html +1 -1
  28. package/modules/hall-ui/public/collab.html +1 -1
  29. package/modules/hall-ui/public/commands.html +1 -1
  30. package/modules/hall-ui/public/copy-desk.html +1 -1
  31. package/modules/hall-ui/public/deploy.html +1 -1
  32. package/modules/hall-ui/public/diagrams.html +1 -1
  33. package/modules/hall-ui/public/drachmae.html +1 -1
  34. package/modules/hall-ui/public/fleet.html +1 -1
  35. package/modules/hall-ui/public/gate.html +1 -1
  36. package/modules/hall-ui/public/goal-map.html +1 -1
  37. package/modules/hall-ui/public/goals.html +1 -1
  38. package/modules/hall-ui/public/government.html +1 -1
  39. package/modules/hall-ui/public/hall-render.js +2 -2
  40. package/modules/hall-ui/public/idea.html +1 -1
  41. package/modules/hall-ui/public/ideas.html +1 -1
  42. package/modules/hall-ui/public/index.html +1 -1
  43. package/modules/hall-ui/public/modules.html +1 -1
  44. package/modules/hall-ui/public/primer.html +1 -1
  45. package/modules/hall-ui/public/profile.html +1 -1
  46. package/modules/hall-ui/public/profile.js +1 -1
  47. package/modules/hall-ui/public/project-settings.html +1 -1
  48. package/modules/hall-ui/public/ranks.html +1 -1
  49. package/modules/hall-ui/public/roadmap.html +1 -1
  50. package/modules/hall-ui/public/roster.html +1 -1
  51. package/modules/hall-ui/public/sessions.html +1 -1
  52. package/modules/hall-ui/public/settings.html +1 -1
  53. package/modules/hall-ui/public/shell.js +5 -1
  54. package/modules/hall-ui/public/studio.html +1 -1
  55. package/modules/hall-ui/public/style.css +8 -0
  56. package/modules/hall-ui/public/task.html +1 -1
  57. package/modules/hall-ui/public/thinking.html +1 -1
  58. package/modules/hall-ui/public/tweak-editor.html +1 -1
  59. package/modules/hall-ui/public/watch.html +1 -1
  60. package/modules/hall-ui/public/work.html +1 -1
  61. package/modules/ideas/ideator-stats.js +149 -0
  62. package/modules/ideas/module.json +2 -1
  63. package/modules/ideas/routes/inbox.js +7 -0
  64. package/modules/lifecycle/activity.js +5 -2
  65. package/modules/lifecycle/db-analytics.js +4 -2
  66. package/modules/lifecycle/db-tasks.js +49 -1
  67. package/modules/lifecycle/db.js +3 -2
  68. package/modules/lifecycle/page-tweak-reads.js +23 -5
  69. package/modules/provisioning/look.js +169 -0
  70. package/modules/provisioning/migrations/provisioning_034_project_logo.sql +35 -0
  71. package/modules/provisioning/module.json +2 -1
  72. package/modules/provisioning/provisioning.js +4 -3
  73. package/modules/provisioning/routes/look.js +204 -0
  74. package/modules/provisioning/routes/provisioning.js +5 -59
  75. package/modules/provisioning/settings-push.js +76 -0
  76. package/modules/public-landing/public/assets/cosmos.css +1 -1
  77. package/modules/public-landing/public/projects.html +499 -28
  78. package/modules/public-landing/public/projects.probes.json +2 -2
  79. package/modules/public-landing/public/projects.states.json +6 -3
  80. package/modules/ui-design/kit/serve.js +50 -0
  81. package/package-lock.json +2 -2
  82. package/package.json +1 -1
  83. package/release-notes.json +12 -0
  84. package/scripts/gds/fitness.js +4 -0
  85. package/scripts/gds/run-unit-tests.js +5 -0
  86. package/src/bongos/db.js +44 -6
  87. package/src/bongos/role-stats.js +155 -0
  88. package/src/bongos/routes/builders.js +15 -2
  89. package/src/bongos/routes/me.js +11 -3
  90. package/src/bongos/routes.js +4 -1
  91. package/src/bongos/serve-internal.js +6 -0
  92. package/src/branding.js +26 -1
  93. package/src/hall-look.js +99 -0
  94. package/src/module-api.js +1 -1
  95. package/src/night-tint.js +26 -0
  96. package/tests/copy_desk_page_reads.mjs +1 -1
  97. package/tests/currency_label.mjs +7 -4
  98. package/tests/fixtures/page-ledger.mjs +1 -1
  99. package/tests/hall_tokens.mjs +10 -1
  100. package/tests/hall_tweak_editor.mjs +1 -1
  101. package/tests/look_night_tint.mjs +169 -0
  102. package/tests/main_discipline.mjs +81 -0
  103. package/tests/nav_permission_atoms.mjs +1 -1
  104. package/tests/profile_role_stats.mjs +416 -0
  105. package/tests/projects_hub.mjs +5 -4
  106. package/tests/projects_hub_app_status.mjs +1 -1
  107. package/tests/projects_hub_app_step.mjs +8 -7
  108. package/tests/projects_hub_look.mjs +255 -0
  109. package/tests/projects_hub_module_picker.mjs +2 -2
  110. package/tests/provision_settings_apply.mjs +12 -4
  111. package/tests/provisioning_look.mjs +376 -0
  112. package/tests/provisioning_settings.mjs +10 -7
  113. package/tests/provisioning_settings_apply.mjs +4 -4
  114. package/tests/provisioning_settings_env.mjs +4 -0
  115. package/tests/role_stats_db.mjs +237 -0
  116. package/tests/tweak_replaces_artist_review.mjs +1 -1
  117. package/tests/wizard_draft_resume.mjs +2 -2
  118. package/tests/wizard_front_door.mjs +2 -2
  119. package/tests/wizard_intent_resume.mjs +16 -15
@@ -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);
@@ -715,8 +715,9 @@ test('a renumbering of the wizard invalidates old drafts — the key carries the
715
715
  const panels = (SCRIPT.match(/var panels = \{([^}]*)\}/) || [, ''])[1];
716
716
  const count = [...panels.matchAll(/\d+:\s*'panel-/g)].length;
717
717
  // task 1004417 put the front door's two screens (terms, fork) in front: the sixth renumbering
718
- assert.equal(count, 12, 'gate + the front door\'s two + eight numbered steps + done — bump DRAFT_KEY when this changes');
719
- assert.equal(key, 'cb-create-draft-v6', 'and the key moved with the front door');
718
+ // task 1004421 put the Look step between App and Review: the seventh
719
+ assert.equal(count, 13, 'gate + the front door\'s two + nine numbered steps + done — bump DRAFT_KEY when this changes');
720
+ assert.equal(key, 'cb-create-draft-v7', 'and the key moved with the Look step');
720
721
  });
721
722
 
722
723
  test('every row lays the same five parts — the ledger has no ragged row', () => {
@@ -2137,7 +2138,7 @@ test('the wizard takes the world by scope: no card ground, the six-step strip as
2137
2138
  assert.match(block, /#view-new \.card\{background:none;border-radius:0;box-shadow:none;/, 'no card ground');
2138
2139
  // eight tracks since task 1004351 added the App step (seven since 1002339); the count is the
2139
2140
  // rail's own, so it moves with the panels map rather than being a magic number
2140
- assert.match(block, /#view-new \.steps\{display:grid;grid-template-columns:repeat\(8,1fr\);/, 'the strip lays a track per numbered step');
2141
+ assert.match(block, /#view-new \.steps\{display:grid;grid-template-columns:repeat\(9,1fr\);/, 'the strip lays a track per numbered step');
2141
2142
  assert.match(block, /#view-new \.steps li::before\{content:"";position:absolute;top:-4px;left:0;right:auto;width:7px;height:7px;/, 'the connector becomes the 7px dot');
2142
2143
  assert.match(block, /#view-new \.steps li\.now::before\{background:var\(--accent\);border-color:var\(--accent\);outline:4px solid/, 'the lit step carries the accent and an outline halo');
2143
2144
  assert.match(block, /#view-new \.panel h1\{font-weight:300;[^}]*text-transform:lowercase;/, 'the step title in the display voice, lowercase');
@@ -2184,7 +2185,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
2184
2185
  assert.match(block, /\.addrBig code\{display:block;font-family:var\(--font-display\);/, 'the address stands in the display voice');
2185
2186
  assert.doesNotMatch(block, /linear-gradient/, 'no tinted plates');
2186
2187
  assert.doesNotMatch(block, /box-shadow:(?!none)/, 'no box shadow in the wizard block');
2187
- assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="9">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
2188
+ assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="10">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
2188
2189
  assert.match(HUB, /<h2 class="stepsHead" id="doneStepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style (the id is where a skipped invite step hands focus on, task 1002346)');
2189
2190
  // what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
2190
2191
  assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
@@ -240,7 +240,7 @@ test('a repaint never lands on top of an open Retry — from this watch or a fre
240
240
  });
241
241
 
242
242
  test('both surfaces use it: the done panel and the project page', () => {
243
- assert.match(HUB, /if \(box\) watchApp\(instanceId, box, function \(\) \{ return state\.step === 11; \}, 5000\);/);
243
+ assert.match(HUB, /if \(box\) watchApp\(instanceId, box, function \(\) \{ return state\.step === 12; \}, 5000\);/);
244
244
  assert.match(HUB, /<div id="doneApp" hidden><\/div>/);
245
245
  assert.match(HUB, /<div class="card mSec" id="mAppCard" hidden>\n\s*<h2>Where its app runs<\/h2>\n\s*<div id="mApp"><\/div>/);
246
246
  assert.match(HUB, /loadAppStatus\(inst\.id \|\| asked, inst\);/);
@@ -142,16 +142,17 @@ test('the panel is real markup: Render and Decide later, Decide later the defaul
142
142
  assert.doesNotMatch(PANEL, /Coming soon/, 'and is no longer marked soon');
143
143
  assert.match(PANEL, /<input type="radio" name="appPlatform" value="later" checked>/, 'Decide later is the default');
144
144
  assert.match(PANEL, /We host your Builders' hall/, 'the step says the hall is ours to host (ADR 0345 D1), in the page\'s one spelling of it');
145
- assert.match(PANEL, /<form data-next="10">/, 'Next leads to the review');
145
+ assert.match(PANEL, /<form data-next="10">/, 'Next leads to the Look step (task 1004421), then the review');
146
146
  assert.match(PANEL, /data-back="8"/, 'Back returns to the address step');
147
147
  });
148
148
 
149
- test('the step sits between Address and Review in the panels map and on the rail', () => {
150
- assert.match(HUB, /8: 'panel-address', 9: 'panel-app', 10: 'panel-review', 11: 'panel-done'/);
149
+ test('the step sits between Address and the Look step in the panels map and on the rail', () => {
150
+ assert.match(HUB, /8: 'panel-address', 9: 'panel-app', 10: 'panel-look', 11: 'panel-review', 12: 'panel-done'/);
151
151
  assert.match(HUB, /<li data-step="9"><span class="snum">7<\/span><span class="slbl">App<\/span><\/li>/);
152
- assert.match(HUB, /<li data-step="10"><span class="snum">8<\/span><span class="slbl">Create<\/span><\/li>/);
153
- assert.match(HUB, /data-back="9">← Back<\/button>\n\s*<button type="button" class="btn big" id="doCreate">/, "the review's Back returns to the app step");
154
- assert.match(HUB, /if \(step === 9\) \{ syncAppStep\(\); syncRenderSetup\(\); \}\n\s*if \(step === 10\) renderReview\(\);/, 'entering the step repaints it, Render setup included');
152
+ assert.match(HUB, /<li data-step="10"><span class="snum">8<\/span><span class="slbl">Look<\/span><\/li>/);
153
+ assert.match(HUB, /<li data-step="11"><span class="snum">9<\/span><span class="slbl">Create<\/span><\/li>/);
154
+ assert.match(HUB, /data-back="10">← Back<\/button>\n\s*<button type="button" class="btn big" id="doCreate">/, "the review's Back returns to the Look step");
155
+ assert.match(HUB, /if \(step === 9\) \{ syncAppStep\(\); syncRenderSetup\(\); \}\n\s*if \(step === 10\) renderLook\(\);\n\s*if \(step === 11\) renderReview\(\);/, 'entering the step repaints it, Render setup included');
155
156
  assert.match(HUB, /if \(step === 9\) return validateRender\(\);/, 'Next on the step runs the Render check');
156
157
  });
157
158
 
@@ -455,5 +456,5 @@ test('the review names exactly what was chosen, prices included', () => {
455
456
  });
456
457
 
457
458
  test('Create hands the app to Render straight after the project is filed', () => {
458
- assert.match(HUB, /show\(11, true\);\n\s*sendRenderSetup\(data\.instance && data\.instance\.id\);/);
459
+ assert.match(HUB, /show\(12, true\);\n\s*sendRenderSetup\(data\.instance && data\.instance\.id\);/);
459
460
  });