@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.
- package/.bongos-core.json +188 -53
- 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 +14 -4
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/api/openapi.json +220 -20
- package/docs/api-reference.md +8 -3
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +21 -5
- package/docs/copy-registry.json +169 -15
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +8 -3
- package/docs/page-readings.json +72 -72
- 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/genesis/r5-life-1-primordial-sea-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
- package/modules/hall-ui/public/genesis-home.js +349 -0
- package/modules/hall-ui/public/hall-render.js +2 -2
- package/modules/hall-ui/public/index-genesis-decide.states.json +13 -0
- package/modules/hall-ui/public/index-genesis.states.json +13 -0
- package/modules/hall-ui/public/index.html +2 -0
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/style.css +65 -0
- package/modules/hall-ui/records/genesis-home.md +18 -0
- 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/kickoff-checklist.js +54 -1
- package/modules/lifecycle/kickoff-seed.js +61 -2
- package/modules/lifecycle/page-tweak-reads.js +23 -5
- package/modules/lifecycle/routes/kickoff-seed.js +7 -1
- package/modules/onboarding/genesis-home.js +274 -0
- package/modules/onboarding/routes/onboarding.js +56 -0
- package/modules/ui-design/kit/fixtures/founding-genesis-decide.json +108 -0
- package/modules/ui-design/kit/fixtures/founding-genesis-stage.json +162 -0
- package/modules/ui-design/kit/fixtures/government-constitution-sample.json +205 -0
- package/modules/ui-design/kit/serve.js +7 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -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/module-api.js +1 -1
- package/tests/copy_desk_page_reads.mjs +1 -1
- package/tests/fixtures/page-ledger.mjs +1 -1
- package/tests/genesis_home.mjs +288 -0
- package/tests/main_discipline.mjs +81 -0
- package/tests/profile_role_stats.mjs +416 -0
- package/tests/role_stats_db.mjs +237 -0
- package/tests/tweak_replaces_artist_review.mjs +1 -1
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// tests/genesis_home.mjs — THE GENESIS HOME (task 1004423 / BV2.PS08, goal 1000121).
|
|
2
|
+
//
|
|
3
|
+
// Done-when: a founded project walks decide → five stages → alive, and the full hall
|
|
4
|
+
// opens. Spec D7: a stage closes when its tasks are finished AND the constitution in
|
|
5
|
+
// force decides it (the Board Room's genesis-stage sitting). D4: a solo founder gets a
|
|
6
|
+
// fast-track, defaults pre-picked and one confirm per stage, and genesis is never
|
|
7
|
+
// skipped. The blend rule: a hall that is not founding renders, and requests, exactly
|
|
8
|
+
// what it did before.
|
|
9
|
+
//
|
|
10
|
+
// Everything here RUNS: the real checklist and seeder over a fake pool, the real
|
|
11
|
+
// genesis-home flow over an in-memory project_settings store with fake lifecycle and
|
|
12
|
+
// government ports, and the real hall card in a vm.
|
|
13
|
+
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import vm from 'node:vm';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const { genesisChecklist } = require('../modules/lifecycle/kickoff-checklist.js');
|
|
25
|
+
const seed = require('../modules/lifecycle/kickoff-seed.js');
|
|
26
|
+
const gh = require('../modules/onboarding/genesis-home.js');
|
|
27
|
+
const fm = require('../modules/onboarding/founding-mode.js');
|
|
28
|
+
|
|
29
|
+
const STAGES5 = ['scope', 'strategize', 'charter', 'legalize', 'legislation'];
|
|
30
|
+
|
|
31
|
+
// ── the checklist + the seeder ───────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
test('genesis files a task per stage, keyed by a ref that names the step, not its place', () => {
|
|
34
|
+
const plain = genesisChecklist();
|
|
35
|
+
assert.deepEqual(Object.keys(plain), STAGES5);
|
|
36
|
+
for (const s of STAGES5) {
|
|
37
|
+
assert.equal(plain[s].length, 1);
|
|
38
|
+
assert.equal(plain[s][0].ref, `genesis:${s}`);
|
|
39
|
+
assert.equal(plain[s][0].kind, 'decision');
|
|
40
|
+
assert.equal(plain[s][0].discipline, 'ideator');
|
|
41
|
+
}
|
|
42
|
+
const existing = genesisChecklist({ existing: true });
|
|
43
|
+
assert.deepEqual(existing.scope.map((t) => t.ref), ['genesis:scope:existing', 'genesis:scope'],
|
|
44
|
+
'the brownfield step comes first and does not re-key scope\'s own step');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('the seeder files every genesis step once, under the catch-all, and never twice', async () => {
|
|
48
|
+
const filed = [];
|
|
49
|
+
const deps = {
|
|
50
|
+
pool: { query: async () => ({ rows: [{ id: 'V1' }] }) },
|
|
51
|
+
createTask: async (row) => { filed.push(row); },
|
|
52
|
+
findTaskBySourceRef: async ({ source, sourceRef }) => filed.find((r) => r.source === source && r.sourceRef === sourceRef) || null,
|
|
53
|
+
};
|
|
54
|
+
const first = await seed.seedGenesisStages({ builderId: 1 }, deps);
|
|
55
|
+
assert.equal(first.seeded, 5);
|
|
56
|
+
assert.ok(filed.every((r) => r.status === 'ready' && r.source === 'genesis' && r.allowCatchAll === true && r.versionId === 'V1'));
|
|
57
|
+
const again = await seed.seedGenesisStages({ builderId: 1, existing: true }, deps);
|
|
58
|
+
assert.equal(again.seeded, 1, 'a second run files only the step that was not there (the brownfield one)');
|
|
59
|
+
assert.equal(filed.length, 6);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('the board read groups genesis tasks by the stage their ref names', async () => {
|
|
63
|
+
const pool = { query: async (sql, params) => {
|
|
64
|
+
assert.deepEqual(params, ['genesis']);
|
|
65
|
+
return { rows: [
|
|
66
|
+
{ id: 1, title: 'a', status: 'shipped', source_ref: 'genesis:scope' },
|
|
67
|
+
{ id: 2, title: 'b', status: 'ready', source_ref: 'genesis:scope:existing' },
|
|
68
|
+
{ id: 3, title: 'c', status: 'ready', source_ref: 'genesis:charter' },
|
|
69
|
+
] };
|
|
70
|
+
} };
|
|
71
|
+
const out = await seed.genesisStageTasks({ pool });
|
|
72
|
+
assert.deepEqual(Object.keys(out), ['scope', 'charter']);
|
|
73
|
+
assert.deepEqual(out.scope.map((t) => t.id), ['1', '2']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── the plan ─────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
test('an empty plan is the recommended defaults: the solo fast-track is one click', () => {
|
|
79
|
+
const r = gh.parsePlan({});
|
|
80
|
+
assert.equal(r.ok, true);
|
|
81
|
+
assert.deepEqual(r.plan.days, { scope: 7, strategize: 7, charter: 4, legalize: 7, legislation: 3 });
|
|
82
|
+
assert.equal(r.plan.passes, 1);
|
|
83
|
+
assert.equal(r.plan.advisors, 'none');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a plan out of range is refused with the reason', () => {
|
|
87
|
+
assert.equal(gh.parsePlan({ days: { scope: 0 } }).reason, 'bad_days');
|
|
88
|
+
assert.equal(gh.parsePlan({ passes: 3 }).reason, 'bad_passes');
|
|
89
|
+
assert.equal(gh.parsePlan({ advisors: 'robots' }).reason, 'bad_advisors');
|
|
90
|
+
assert.equal(gh.parsePlan({ government: 'Not A Key' }).reason, 'bad_government');
|
|
91
|
+
assert.equal(gh.parsePlan({ expected_builders: 0 }).reason, 'bad_expected_builders');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── the flow, end to end over fakes ──────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function world({ withGovernment = true } = {}) {
|
|
97
|
+
const store = new Map();
|
|
98
|
+
const tasks = {};
|
|
99
|
+
const board = {};
|
|
100
|
+
let now = new Date('2026-10-01T00:00:00Z');
|
|
101
|
+
const opened = [];
|
|
102
|
+
const lifecycle = {
|
|
103
|
+
seedGenesis: async () => {
|
|
104
|
+
for (const s of STAGES5) tasks[s] = [{ id: String(100 + STAGES5.indexOf(s)), title: s, status: 'ready' }];
|
|
105
|
+
return { seeded: 5 };
|
|
106
|
+
},
|
|
107
|
+
genesisTasks: async () => JSON.parse(JSON.stringify(tasks)),
|
|
108
|
+
};
|
|
109
|
+
const government = withGovernment ? {
|
|
110
|
+
genesisStageStatus: async () => ({ stages: STAGES5.map((key) => ({ key, ...(board[key] || { state: 'not_put', waiting_on: [] }) })) }),
|
|
111
|
+
openGenesisStageClose: async ({ stageKey, taskIds }) => {
|
|
112
|
+
opened.push({ stageKey, taskIds });
|
|
113
|
+
if (!(tasks[stageKey] || []).every((t) => ['shipped', 'abandoned'].includes(t.status))) {
|
|
114
|
+
return { opened: false, reason: 'stage_tasks_unfinished', unfinished: [{ task_id: taskIds[0] }] };
|
|
115
|
+
}
|
|
116
|
+
board[stageKey] = { state: 'sitting', item_id: '9', one_vote_decides: true, waiting_on: [{ builder_id: '1' }] };
|
|
117
|
+
return { opened: true, item: { id: 9 } };
|
|
118
|
+
},
|
|
119
|
+
} : null;
|
|
120
|
+
const deps = () => ({
|
|
121
|
+
getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
|
|
122
|
+
setSetting: async (k, v) => { store.set(k, String(v)); return { key: k, value: String(v) }; },
|
|
123
|
+
lifecycle, government, now,
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
store, tasks, board, opened, deps,
|
|
127
|
+
finish: (s) => tasks[s].forEach((t) => { t.status = 'shipped'; }),
|
|
128
|
+
pass: (s) => { board[s] = { state: 'passed', item_id: '9', waiting_on: [] }; },
|
|
129
|
+
tick: (days) => { now = new Date(now.getTime() + days * 86400000); },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test('decide → five stages through the Board Room → alive, and the hall opens', async () => {
|
|
134
|
+
const w = world();
|
|
135
|
+
assert.equal(await gh.readGenesis(w.deps()), null, 'not founding: there is no genesis home');
|
|
136
|
+
assert.equal((await gh.startFounding(1, w.deps())).ok, true);
|
|
137
|
+
let g = await gh.readGenesis(w.deps());
|
|
138
|
+
assert.equal(g.deciding, true);
|
|
139
|
+
assert.equal(g.stage, 'decide');
|
|
140
|
+
|
|
141
|
+
const saved = await gh.savePlan({}, 1, w.deps());
|
|
142
|
+
assert.equal(saved.ok, true);
|
|
143
|
+
assert.equal(saved.seeded, 5);
|
|
144
|
+
assert.ok(JSON.parse(w.store.get(gh.PLAN_KEY)).days, 'the plan is recorded');
|
|
145
|
+
g = await gh.readGenesis(w.deps());
|
|
146
|
+
assert.equal(g.stage, 'scope');
|
|
147
|
+
assert.equal(g.can_close, false, 'a stage with unfinished tasks cannot be put to the board');
|
|
148
|
+
assert.equal((await gh.savePlan({}, 1, w.deps())).reason, 'not_deciding', 'the plan is settled once');
|
|
149
|
+
|
|
150
|
+
for (const s of STAGES5) {
|
|
151
|
+
assert.equal((await gh.readGenesis(w.deps())).stage, s);
|
|
152
|
+
assert.equal((await gh.closeStage(s, 1, w.deps())).reason, 'stage_tasks_unfinished', `${s}: the Board Room's gate refuses unfinished work`);
|
|
153
|
+
w.finish(s);
|
|
154
|
+
assert.equal((await gh.readGenesis(w.deps())).can_close, true);
|
|
155
|
+
const closed = await gh.closeStage(s, 1, w.deps());
|
|
156
|
+
assert.equal(closed.ok, true);
|
|
157
|
+
assert.equal(closed.item_id, '9');
|
|
158
|
+
assert.deepEqual(w.opened.at(-1), { stageKey: s, taskIds: [String(100 + STAGES5.indexOf(s))] }, 'the stage\'s own task ids go to the gate');
|
|
159
|
+
g = await gh.readGenesis(w.deps());
|
|
160
|
+
assert.equal(g.stage, s, 'sitting is not passing: the stage holds until the vote');
|
|
161
|
+
assert.equal(g.can_close, false);
|
|
162
|
+
if (s !== 'legislation') assert.equal((await gh.comeAlive(1, w.deps())).ok, false);
|
|
163
|
+
w.pass(s);
|
|
164
|
+
await gh.onStagePassed(w.deps());
|
|
165
|
+
}
|
|
166
|
+
g = await gh.readGenesis(w.deps());
|
|
167
|
+
assert.equal(g.stage, 'legislation', 'legislation does not advance past itself');
|
|
168
|
+
assert.equal(g.can_come_alive, true);
|
|
169
|
+
assert.equal((await gh.comeAlive(1, w.deps())).ok, true);
|
|
170
|
+
assert.equal(await gh.readGenesis(w.deps()), null, 'founding mode is off: the full hall opens');
|
|
171
|
+
assert.ok(w.store.get(fm.KEYS.ENDED_AT), 'the founding window closed, for the founding mark');
|
|
172
|
+
assert.equal((await gh.startFounding(1, w.deps())).reason, 'already_alive', 'genesis happens once');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('only the stage in progress can be put to the board', async () => {
|
|
176
|
+
const w = world();
|
|
177
|
+
await gh.startFounding(1, w.deps());
|
|
178
|
+
await gh.savePlan({}, 1, w.deps());
|
|
179
|
+
const r = await gh.closeStage('charter', 1, w.deps());
|
|
180
|
+
assert.equal(r.reason, 'not_current_stage');
|
|
181
|
+
assert.equal(w.opened.length, 0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('a returned stage stays put and can be put up again', async () => {
|
|
185
|
+
const w = world();
|
|
186
|
+
await gh.startFounding(1, w.deps());
|
|
187
|
+
await gh.savePlan({}, 1, w.deps());
|
|
188
|
+
w.finish('scope');
|
|
189
|
+
w.board.scope = { state: 'returned', item_id: '9', waiting_on: [] };
|
|
190
|
+
const g = await gh.readGenesis(w.deps());
|
|
191
|
+
assert.equal(g.stage, 'scope');
|
|
192
|
+
assert.equal(g.can_close, true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('with no Board Room module the founder\'s confirm closes a finished stage', async () => {
|
|
196
|
+
const w = world({ withGovernment: false });
|
|
197
|
+
await gh.startFounding(1, w.deps());
|
|
198
|
+
await gh.savePlan({}, 1, w.deps());
|
|
199
|
+
assert.equal((await gh.closeStage('scope', 1, w.deps())).reason, 'stage_tasks_unfinished');
|
|
200
|
+
w.finish('scope');
|
|
201
|
+
assert.equal((await gh.closeStage('scope', 1, w.deps())).ok, true);
|
|
202
|
+
assert.equal((await gh.readGenesis(w.deps())).stage, 'strategize');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('the view: states, wakes, the life plates and a due date from the plan', () => {
|
|
206
|
+
const mode = { stage: 'strategize', stage_number: 2, stage_count: 5, day: 9 };
|
|
207
|
+
const g = gh.shapeGenesis({
|
|
208
|
+
mode, plan: { days: { scope: 7, strategize: 10, charter: 4, legalize: 7, legislation: 3 } },
|
|
209
|
+
tasks: { strategize: [{ id: '1', title: 't', status: 'active' }] },
|
|
210
|
+
stageAt: { strategize: '2026-10-08T00:00:00Z' },
|
|
211
|
+
});
|
|
212
|
+
assert.deepEqual(g.stages.map((s) => s.state), ['done', 'now', 'later', 'later', 'later']);
|
|
213
|
+
assert.deepEqual(g.stages[1].wakes, ['work']);
|
|
214
|
+
assert.equal(g.stages[1].plate, 'r5-life-2-first-cells');
|
|
215
|
+
assert.equal(g.stages[1].due, '2026-10-18T00:00:00.000Z');
|
|
216
|
+
assert.equal(g.stages[1].finished, 0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ── the routes ───────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
test('every genesis write is the project owner\'s; the read is any signed-in builder\'s', () => {
|
|
222
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'onboarding', 'routes', 'onboarding.js'), 'utf8');
|
|
223
|
+
assert.match(src, /const owns = api\.requirePermission\('project\.settings\.manage'\);/);
|
|
224
|
+
for (const p of ['/founding/start', '/founding/plan', '/founding/stages/:stage/close', '/founding/alive']) {
|
|
225
|
+
assert.ok(src.includes(`router.post('${p}', api.requireBuilder, owns,`), `${p} is owner-gated`);
|
|
226
|
+
}
|
|
227
|
+
assert.match(src, /router\.get\('\/founding\/genesis', api\.requireBuilder,/);
|
|
228
|
+
assert.match(src, /api\.on\('board\.genesis_stage\.passed'/, 'a pass moves the stage on without waiting for a read');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ── the hall card, RUN ───────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
const CARD = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis-home.js'), 'utf8');
|
|
234
|
+
|
|
235
|
+
function bootCard({ founding }) {
|
|
236
|
+
const calls = [];
|
|
237
|
+
let widget = null;
|
|
238
|
+
let observer = null;
|
|
239
|
+
const attrs = founding ? { 'data-founding': 'on' } : {};
|
|
240
|
+
const sandbox = {
|
|
241
|
+
window: {
|
|
242
|
+
OTB: { escapeHtml: (s) => String(s), contributeHallWidget: (w) => { widget = w; } },
|
|
243
|
+
BongosClient: { createClient: () => ({ request: async (m, p) => { calls.push(`${m} ${p}`); return { ok: true, status: 200, data: { genesis: null } }; } }) },
|
|
244
|
+
},
|
|
245
|
+
document: { documentElement: { getAttribute: (k) => attrs[k] || null } },
|
|
246
|
+
MutationObserver: class { constructor(fn) { observer = fn; } observe() {} disconnect() {} },
|
|
247
|
+
Promise,
|
|
248
|
+
};
|
|
249
|
+
vm.createContext(sandbox);
|
|
250
|
+
vm.runInContext(CARD, sandbox, { filename: 'genesis-home.js' });
|
|
251
|
+
const host = { hidden: true, innerHTML: '', addEventListener() {}, querySelector: () => null, querySelectorAll: () => [] };
|
|
252
|
+
widget.mount(host);
|
|
253
|
+
return { widget, host, calls, attrs, fire: () => observer && observer() };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
test('the card registers in the Home banner, ahead of the welcome, hidden by default', () => {
|
|
257
|
+
const { widget } = bootCard({ founding: false });
|
|
258
|
+
assert.equal(widget.zone, 'home-banner');
|
|
259
|
+
assert.equal(widget.view, 'home');
|
|
260
|
+
assert.equal(widget.hiddenByDefault, true);
|
|
261
|
+
assert.ok(widget.order < 0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('a hall that is not founding: no markup and no request', async () => {
|
|
265
|
+
const { host, calls } = bootCard({ founding: false });
|
|
266
|
+
await new Promise((r) => setImmediate(r));
|
|
267
|
+
assert.equal(host.innerHTML, '');
|
|
268
|
+
assert.deepEqual(calls, []);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('founding mode stamped after mount still starts the card, once', async () => {
|
|
272
|
+
const b = bootCard({ founding: false });
|
|
273
|
+
b.attrs['data-founding'] = 'on';
|
|
274
|
+
b.fire();
|
|
275
|
+
b.fire();
|
|
276
|
+
await new Promise((r) => setImmediate(r));
|
|
277
|
+
assert.deepEqual(b.calls, ['GET /api/bongos/founding/genesis']);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('the hall carries the five life plates byte-identical to the world\'s', () => {
|
|
281
|
+
const world = path.join(ROOT, 'modules', 'public-landing', 'public', 'assets', 'world');
|
|
282
|
+
const hall = path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis');
|
|
283
|
+
for (const s of Object.values(gh.STAGE_INFO)) {
|
|
284
|
+
for (const f of [`${s.plate}.jpg`, `${s.plate}-cut.webp`]) {
|
|
285
|
+
assert.ok(fs.readFileSync(path.join(world, f)).equals(fs.readFileSync(path.join(hall, f))), `${f} matches the world plate`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// tests/main_discipline.mjs — the builder's own MAIN role (task 1004433 /
|
|
2
|
+
// WA6.RP03, core_268): what updateBuilderDisciplines sends for each of the
|
|
3
|
+
// three answers, and what it refuses before anything is written.
|
|
4
|
+
//
|
|
5
|
+
// The statement's own behaviour (keep, clear, set) runs against a real Postgres
|
|
6
|
+
// in tests/role_stats_db.mjs; this pins the half before it, DB-free: the
|
|
7
|
+
// "named or not" flag, the value, and the refusal of a main role that is not
|
|
8
|
+
// one of the builder's chosen crafts.
|
|
9
|
+
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
|
|
15
|
+
let passed = 0;
|
|
16
|
+
let failed = 0;
|
|
17
|
+
async function t(name, fn) {
|
|
18
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
19
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const calls = [];
|
|
23
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
24
|
+
require.cache[poolPath] = {
|
|
25
|
+
id: poolPath, filename: poolPath, loaded: true,
|
|
26
|
+
exports: {
|
|
27
|
+
pool: {
|
|
28
|
+
async query(sql, params) {
|
|
29
|
+
calls.push({ sql, params });
|
|
30
|
+
return { rows: [{ id: params[0], preferred_disciplines: params[1], main_discipline: params[2] ? params[3] : null }] };
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
const db = require('../src/bongos/db.js');
|
|
36
|
+
const { enabledDisciplines } = require('../src/modules.js');
|
|
37
|
+
const offered = enabledDisciplines();
|
|
38
|
+
const last = () => calls[calls.length - 1];
|
|
39
|
+
|
|
40
|
+
await t('the write goes through the one exported statement', async () => {
|
|
41
|
+
await db.updateBuilderDisciplines(7, ['engineer']);
|
|
42
|
+
assert.equal(last().sql, db.UPDATE_BUILDER_DISCIPLINES_SQL);
|
|
43
|
+
assert.match(db.UPDATE_BUILDER_DISCIPLINES_SQL, /main_discipline = CASE WHEN \$3::boolean THEN \$4::text/);
|
|
44
|
+
assert.match(db.UPDATE_BUILDER_DISCIPLINES_SQL, /WHEN main_discipline = ANY\(\$2::text\[\]\) THEN main_discipline\s+ELSE NULL END/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await t('not mentioned: the flag is false, so the stored main role is left to the statement to keep or clear', async () => {
|
|
48
|
+
await db.updateBuilderDisciplines(7, ['engineer', 'ideator']);
|
|
49
|
+
assert.deepEqual(last().params.slice(2), [false, null]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await t('named: the flag is true and the role travels', async () => {
|
|
53
|
+
await db.updateBuilderDisciplines(7, ['ideator', 'engineer'], { mainDiscipline: 'ideator' });
|
|
54
|
+
assert.deepEqual(last().params.slice(2), [true, 'ideator']);
|
|
55
|
+
assert.deepEqual(last().params[1], offered.filter((d) => ['ideator', 'engineer'].includes(d)),
|
|
56
|
+
'the set is still stored in canonical order: the main role is what carries the choice');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await t('null clears it on purpose', async () => {
|
|
60
|
+
await db.updateBuilderDisciplines(7, ['engineer'], { mainDiscipline: null });
|
|
61
|
+
assert.deepEqual(last().params.slice(2), [true, null]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await t('a main role that is not one of the chosen crafts is refused, and nothing is written', async () => {
|
|
65
|
+
const before = calls.length;
|
|
66
|
+
await assert.rejects(() => db.updateBuilderDisciplines(7, ['engineer'], { mainDiscipline: 'ideator' }),
|
|
67
|
+
(err) => err.code === 'invalid_main_discipline' && err.bad[0] === 'ideator');
|
|
68
|
+
assert.equal(calls.length, before);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await t('the PATCH route hands main_discipline through only when the body names it', async () => {
|
|
72
|
+
const fs = require('node:fs');
|
|
73
|
+
const src = fs.readFileSync(require.resolve('../src/bongos/routes/me.js'), 'utf8');
|
|
74
|
+
const route = src.slice(src.indexOf("router.patch('/me/disciplines'"), src.indexOf("router.patch('/me/disciplines'") + 2500);
|
|
75
|
+
assert.match(route, /hasOwnProperty\.call\(body, 'main_discipline'\)/);
|
|
76
|
+
assert.match(route, /db\.updateBuilderDisciplines\(req\.builder\.id, input, mainOpt\)/);
|
|
77
|
+
assert.match(route, /invalid_main_discipline/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
81
|
+
if (failed) process.exit(1);
|