@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.
- package/.bongos-core.json +198 -108
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +15 -2
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0358-a-hall-wears-a-look-and-its-night-takes-a-tint.md +52 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +317 -6
- package/docs/api-reference.md +8 -3
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +339 -330
- package/docs/copy-registry.json +524 -439
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +729 -678
- package/migrations/core_268_builders_main_discipline.sql +35 -0
- package/modules/copy-desk/artist-stats.js +56 -0
- package/modules/copy-desk/module.json +1 -1
- package/modules/copy-desk/page-status.js +39 -0
- package/modules/copy-desk/routes/copy-desk.js +7 -16
- package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
- package/modules/economy/credits-by-craft.js +208 -0
- package/modules/economy/reward.js +8 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/hall-render.js +2 -2
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +5 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +8 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/ideas/ideator-stats.js +149 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/inbox.js +7 -0
- package/modules/lifecycle/activity.js +5 -2
- package/modules/lifecycle/db-analytics.js +4 -2
- package/modules/lifecycle/db-tasks.js +49 -1
- package/modules/lifecycle/db.js +3 -2
- package/modules/lifecycle/page-tweak-reads.js +23 -5
- package/modules/provisioning/look.js +169 -0
- package/modules/provisioning/migrations/provisioning_034_project_logo.sql +35 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +4 -3
- package/modules/provisioning/routes/look.js +204 -0
- package/modules/provisioning/routes/provisioning.js +5 -59
- package/modules/provisioning/settings-push.js +76 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/projects.html +499 -28
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +6 -3
- package/modules/ui-design/kit/serve.js +50 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/fitness.js +4 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/bongos/db.js +44 -6
- package/src/bongos/role-stats.js +155 -0
- package/src/bongos/routes/builders.js +15 -2
- package/src/bongos/routes/me.js +11 -3
- package/src/bongos/routes.js +4 -1
- package/src/bongos/serve-internal.js +6 -0
- package/src/branding.js +26 -1
- package/src/hall-look.js +99 -0
- package/src/module-api.js +1 -1
- package/src/night-tint.js +26 -0
- package/tests/copy_desk_page_reads.mjs +1 -1
- package/tests/currency_label.mjs +7 -4
- package/tests/fixtures/page-ledger.mjs +1 -1
- package/tests/hall_tokens.mjs +10 -1
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/look_night_tint.mjs +169 -0
- package/tests/main_discipline.mjs +81 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/profile_role_stats.mjs +416 -0
- package/tests/projects_hub.mjs +5 -4
- package/tests/projects_hub_app_status.mjs +1 -1
- package/tests/projects_hub_app_step.mjs +8 -7
- package/tests/projects_hub_look.mjs +255 -0
- package/tests/projects_hub_module_picker.mjs +2 -2
- package/tests/provision_settings_apply.mjs +12 -4
- package/tests/provisioning_look.mjs +376 -0
- package/tests/provisioning_settings.mjs +10 -7
- package/tests/provisioning_settings_apply.mjs +4 -4
- package/tests/provisioning_settings_env.mjs +4 -0
- package/tests/role_stats_db.mjs +237 -0
- package/tests/tweak_replaces_artist_review.mjs +1 -1
- package/tests/wizard_draft_resume.mjs +2 -2
- package/tests/wizard_front_door.mjs +2 -2
- 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);
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -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
|
-
|
|
719
|
-
assert.equal(
|
|
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\(
|
|
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="
|
|
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 ===
|
|
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
|
|
150
|
-
assert.match(HUB, /8: 'panel-address', 9: 'panel-app', 10: 'panel-
|
|
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">
|
|
153
|
-
assert.match(HUB,
|
|
154
|
-
assert.match(HUB, /
|
|
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\(
|
|
459
|
+
assert.match(HUB, /show\(12, true\);\n\s*sendRenderSetup\(data\.instance && data\.instance\.id\);/);
|
|
459
460
|
});
|