@bongos/core 1.20.83 → 1.21.2
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 +186 -81
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +153 -4
- package/docs/api-reference.md +5 -3
- package/docs/architecture.md +3 -0
- package/docs/copy-inventory.md +60 -59
- package/docs/copy-registry.json +72 -63
- package/docs/design/project-startup-direction.md +63 -0
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +8 -0
- package/docs/page-inventory.json +9 -3
- package/docs/page-readings.json +427 -410
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/migrations/core_269_session_transcripts.sql +35 -0
- package/migrations/core_270_craft_achievements.sql +128 -0
- package/modules/economy/achievements.js +199 -4
- package/modules/economy/credits.js +16 -0
- package/modules/economy/reward.js +3 -0
- package/modules/hall-ui/public/builders.js +1 -1
- package/modules/hall-ui/public/genesis-home.js +17 -3
- package/modules/hall-ui/public/hall-render.js +24 -2
- package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
- package/modules/hall-ui/public/roster.css +21 -0
- package/modules/hall-ui/public/roster.html +3 -0
- package/modules/hall-ui/public/roster.js +121 -21
- package/modules/hall-ui/public/roster.states.json +26 -1
- package/modules/hall-ui/records/builders-roster.md +20 -0
- package/modules/lifecycle/publish-reconciler.js +25 -0
- package/modules/lifecycle/workflow-dispatch.js +21 -11
- package/modules/npm-release/release.js +10 -1
- package/modules/onboarding/founding-birth.js +116 -0
- package/modules/onboarding/genesis-home.js +72 -5
- package/modules/onboarding/port.js +9 -2
- package/modules/onboarding/routes/onboarding.js +8 -1
- package/modules/provisioning/birth.js +64 -0
- package/modules/provisioning/dns-resolves.js +54 -0
- package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
- package/modules/provisioning/pollers/liveness-sweep.js +5 -0
- package/modules/provisioning/provisioning.js +7 -6
- package/modules/provisioning/routes/provisioning.js +6 -2
- package/modules/provisioning/tests/liveness.mjs +48 -0
- package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
- package/modules/public-landing/public/projects.html +40 -15
- package/modules/sessions/db.js +58 -0
- package/modules/sessions/routes/sessions.js +97 -0
- package/modules/status-ui/public/status.js +39 -7
- package/modules/status-ui/public/style.css +24 -0
- package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
- package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +45 -0
- package/scripts/gds/provision.js +77 -53
- package/scripts/gds/session-digest.js +11 -3
- package/scripts/gds/session-transcript-build.js +155 -0
- package/scripts/gds/session-transcript-upload.js +29 -0
- package/scripts/gds/ship-session-upload.js +6 -1
- package/scripts/gds/upgrade-commit-identity.js +32 -0
- package/scripts/gds/upgrade.js +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +2 -0
- package/src/bongos/db.js +13 -0
- package/src/bongos/role-stats.js +36 -2
- package/src/bongos/routes/public.js +13 -2
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/achievements_reconcile.mjs +13 -0
- package/tests/craft_achievements.mjs +349 -0
- package/tests/founding_birth.mjs +200 -0
- package/tests/founding_builders.mjs +2 -1
- package/tests/founding_mode.mjs +3 -1
- package/tests/genesis_home.mjs +71 -2
- package/tests/hall_builders_page_pass.mjs +2 -1
- package/tests/hub_map_orb_states.mjs +5 -1
- package/tests/leaderboard_roles.mjs +272 -0
- package/tests/npm_release_release.mjs +37 -2
- package/tests/projects_hub.mjs +5 -3
- package/tests/projects_hub_dns_ready.mjs +153 -0
- package/tests/projects_hub_pre_uat.mjs +9 -5
- package/tests/provision_dns_first.mjs +171 -0
- package/tests/provision_settings_apply.mjs +6 -1
- package/tests/provisioning_settings_env.mjs +49 -0
- package/tests/session_transcript.mjs +273 -0
- package/tests/ui_design_kit.mjs +1 -1
- package/tests/upgrade_commit_identity.mjs +89 -0
- package/tests/wizard_demo.mjs +29 -3
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// tests/craft_achievements.mjs — achievements for artist and ideator moments
|
|
2
|
+
// (task 1004438 / WA6.RP08, goal 1000095, criterion wa6-roles-read-true-in-public).
|
|
3
|
+
//
|
|
4
|
+
// The done-when: "fixtures earn each new achievement exactly once, and existing
|
|
5
|
+
// artists and ideators get theirs on backfill". Run for real:
|
|
6
|
+
//
|
|
7
|
+
// 1. THE CATALOG. migrations/core_270 seeds seven craft achievements, renames the
|
|
8
|
+
// misleading art-pipeline-shipper, and its LIKE literals are economy's own
|
|
9
|
+
// reason prefixes (a drift there awards nothing and errors nowhere).
|
|
10
|
+
// 2. THE RECONCILE. The REAL reconcileBuilderAchievements over a stateful fake
|
|
11
|
+
// ledger: an ideator and an artist earn each badge at its threshold, and a
|
|
12
|
+
// second and third reconcile unlock nothing more.
|
|
13
|
+
// 3. THE WRITERS. The REAL awardIdeaFullPassCredit and awardArtistPageCredit pay
|
|
14
|
+
// the payee AND put the moment on their shelf — the payment happens inside
|
|
15
|
+
// someone else's ship, so the shipper's reconcile would never reach them —
|
|
16
|
+
// and a reconcile that throws never fails the payment.
|
|
17
|
+
// 4. THE BACKFILL. The migration's backfill statements key on the same facts and
|
|
18
|
+
// stamp the FIRST qualifying credit, idempotently.
|
|
19
|
+
// 5. THE PICTURES. The roster's and the status page's emoji maps carry every id
|
|
20
|
+
// with the migration's own icon.
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { makeRunner } from './helpers.mjs';
|
|
28
|
+
|
|
29
|
+
process.env.NODE_ENV = 'test';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
32
|
+
const { test, summary } = makeRunner();
|
|
33
|
+
const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8').replace(/\r\n/g, '\n');
|
|
34
|
+
const MIGRATION = read('migrations/core_270_craft_achievements.sql');
|
|
35
|
+
const seams = require('../src/module-seams.js');
|
|
36
|
+
const achievements = require('../modules/economy/achievements.js');
|
|
37
|
+
const credits = require('../modules/economy/credits.js');
|
|
38
|
+
|
|
39
|
+
const CRAFT = ['first-thought-ignited', 'first-full-idea', 'first-board-ratification', 'thought-built-ten',
|
|
40
|
+
'first-page-approved', 'ten-pages-approved', 'whole-surface-tweaked'];
|
|
41
|
+
|
|
42
|
+
// the catalog rows, read out of the migration's VALUES list
|
|
43
|
+
function catalog() {
|
|
44
|
+
const out = new Map();
|
|
45
|
+
for (const m of MIGRATION.matchAll(/\('([a-z0-9-]+)', '([^']+)',\s*'([^']*)',\s*'([^']+)', (\d+), (\d+)\)/g)) {
|
|
46
|
+
out.set(m[1], { id: m[1], name: m[2], description: m[3], icon: m[4], credit_bonus: Number(m[5]), sort_order: Number(m[6]) });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
const CATALOG = catalog();
|
|
51
|
+
|
|
52
|
+
// ---- a stateful fake ledger ---------------------------------------------------------
|
|
53
|
+
// Understands exactly the statements the reconcile and the two writers send; every
|
|
54
|
+
// other read answers "nothing" (the engineer evaluators find no claims, no streak).
|
|
55
|
+
const likeRe = (pat) => new RegExp('^' + pat.replace(/[.*+?^${}()|[\]]/g, (c) => `\\${c}`).replace(/\\\\_/g, '_').replace(/%/g, '.*') + '$');
|
|
56
|
+
function fakeLedger({ failReconcile = false } = {}) {
|
|
57
|
+
const ledger = [];
|
|
58
|
+
const unlocked = [];
|
|
59
|
+
const sql = [];
|
|
60
|
+
let nextId = 1;
|
|
61
|
+
const rowsFor = (builderId, pat) => ledger.filter((r) => String(r.builder_id) === String(builderId) && likeRe(pat).test(r.reason) && r.delta > 0);
|
|
62
|
+
const client = {
|
|
63
|
+
ledger, unlocked, sql,
|
|
64
|
+
async query(text, params = []) {
|
|
65
|
+
const q = text.replace(/\s+/g, ' ').trim();
|
|
66
|
+
sql.push(q);
|
|
67
|
+
if (/^(SAVEPOINT|RELEASE SAVEPOINT|ROLLBACK TO SAVEPOINT)/.test(q)) return { rows: [], rowCount: 0 };
|
|
68
|
+
if (q.startsWith('SELECT 1 FROM builder_achievements WHERE builder_id=$1 AND achievement_id=$2')) {
|
|
69
|
+
if (failReconcile) throw new Error('reconcile blew up');
|
|
70
|
+
const hit = unlocked.some((u) => String(u.builder_id) === String(params[0]) && u.achievement_id === params[1]);
|
|
71
|
+
return { rows: hit ? [{}] : [], rowCount: hit ? 1 : 0 };
|
|
72
|
+
}
|
|
73
|
+
if (q.startsWith('SELECT id, name, credit_bonus FROM achievements WHERE id=$1')) {
|
|
74
|
+
const a = CATALOG.get(params[0]) || (params[0] === 'first-ship' ? null : null);
|
|
75
|
+
return a ? { rows: [a], rowCount: 1 } : { rows: [], rowCount: 0 };
|
|
76
|
+
}
|
|
77
|
+
if (q.startsWith('INSERT INTO builder_achievements')) {
|
|
78
|
+
if (!unlocked.some((u) => String(u.builder_id) === String(params[0]) && u.achievement_id === params[1])) {
|
|
79
|
+
unlocked.push({ builder_id: String(params[0]), achievement_id: params[1], task: params[2] });
|
|
80
|
+
}
|
|
81
|
+
return { rows: [], rowCount: 1 };
|
|
82
|
+
}
|
|
83
|
+
if (q.startsWith('INSERT INTO credit_log')) {
|
|
84
|
+
// the writers' INSERT ... SELECT ... WHERE NOT EXISTS (per builder+reason, or task+reason)
|
|
85
|
+
const [builderId, taskId, delta, reason, description] = params;
|
|
86
|
+
const exists = q.includes('WHERE task_id = $2 AND reason = $4')
|
|
87
|
+
? ledger.some((r) => String(r.task_id) === String(taskId) && r.reason === reason)
|
|
88
|
+
: ledger.some((r) => String(r.builder_id) === String(builderId) && r.reason === reason);
|
|
89
|
+
if (exists) return { rows: [], rowCount: 0 };
|
|
90
|
+
ledger.push({ id: nextId++, builder_id: String(builderId), task_id: taskId, delta, reason, description: description || '' });
|
|
91
|
+
return { rows: [], rowCount: 1 };
|
|
92
|
+
}
|
|
93
|
+
if (q.startsWith('SELECT achievement_id FROM builder_achievements WHERE builder_id = $1 AND achievement_id = ANY($2::text[])')) {
|
|
94
|
+
if (failReconcile) throw new Error('reconcile blew up');
|
|
95
|
+
return { rows: unlocked.filter((u) => u.builder_id === String(params[0]) && params[1].includes(u.achievement_id)).map((u) => ({ achievement_id: u.achievement_id })) };
|
|
96
|
+
}
|
|
97
|
+
if (q.startsWith('SELECT bool_or(reason LIKE $2) AS ignited')) {
|
|
98
|
+
// the craft facts, computed the way the statement says: one row of aggregates
|
|
99
|
+
const [b, ship, full, board, page] = params;
|
|
100
|
+
const byIdea = new Map();
|
|
101
|
+
for (const r of rowsFor(b, ship)) {
|
|
102
|
+
const [, idea, task] = r.reason.split(':');
|
|
103
|
+
if (!byIdea.has(idea)) byIdea.set(idea, new Set());
|
|
104
|
+
byIdea.get(idea).add(task);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
rows: [{
|
|
108
|
+
ignited: rowsFor(b, ship).length > 0 ? true : null,
|
|
109
|
+
full_idea: rowsFor(b, full).length > 0 ? true : null,
|
|
110
|
+
ratified: rowsFor(b, board).length > 0 ? true : null,
|
|
111
|
+
pages: rowsFor(b, page).length,
|
|
112
|
+
best_thought_tasks: Math.max(0, ...[...byIdea.values()].map((s) => s.size)),
|
|
113
|
+
}],
|
|
114
|
+
rowCount: 1,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (q.startsWith('SELECT DISTINCT builder_id FROM credit_log')) {
|
|
118
|
+
const pats = params.slice(0, 4).map(likeRe);
|
|
119
|
+
const ids = [...new Set(ledger.filter((r) => r.delta > 0 && pats.some((re) => re.test(r.reason))).map((r) => r.builder_id))].sort();
|
|
120
|
+
return { rows: ids.map((builder_id) => ({ builder_id })) };
|
|
121
|
+
}
|
|
122
|
+
if (q.startsWith('SELECT COUNT(*)::int AS n FROM credit_log')) return { rows: [{ n: 0 }], rowCount: 1 };
|
|
123
|
+
if (q.startsWith('SELECT total_credits FROM builders')) return { rows: [{ total_credits: 0 }], rowCount: 1 };
|
|
124
|
+
// a page-tweak task read by awardArtistPageCredit
|
|
125
|
+
if (q.includes('FROM tasks t') && q.includes('artist_id')) {
|
|
126
|
+
return { rows: [{ source: 'page-tweak', status: 'shipped', credits_reward: 30, kind: 'feature', artist_id: '2001' }], rowCount: 1 };
|
|
127
|
+
}
|
|
128
|
+
return { rows: [], rowCount: 0 };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
return client;
|
|
132
|
+
}
|
|
133
|
+
const pay = (client, builderId, reason, delta = 5, task = null) => client.ledger.push({ id: client.ledger.length + 1000, builder_id: String(builderId), task_id: task, delta, reason, description: '' });
|
|
134
|
+
const shelf = (client, builderId) => client.unlocked.filter((u) => u.builder_id === String(builderId)).map((u) => u.achievement_id).sort();
|
|
135
|
+
const craftShelf = (client, builderId) => shelf(client, builderId).filter((id) => CRAFT.includes(id));
|
|
136
|
+
|
|
137
|
+
// ---- 1. the catalog -------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
await test('the migration seeds seven craft achievements, paying no bonus, and renames the art-pipeline badge', () => {
|
|
140
|
+
assert.deepEqual([...CATALOG.keys()].sort(), [...CRAFT].sort());
|
|
141
|
+
for (const a of CATALOG.values()) assert.equal(a.credit_bonus, 0, `${a.id}: recognition only, no minted credits on backfill`);
|
|
142
|
+
assert.match(MIGRATION, /UPDATE achievements\s+SET name = 'Art Pipeline Engineer'[\s\S]*icon = '🛠️'\s+WHERE id = 'art-pipeline-shipper'/);
|
|
143
|
+
assert.equal(CATALOG.get('first-page-approved').icon, '🎨', 'the palette is the artist\'s now');
|
|
144
|
+
const ids = achievements.STATE_EVALUATORS ? achievements.STATE_EVALUATORS.map((e) => e.id) : null;
|
|
145
|
+
if (ids) for (const id of CRAFT) assert.ok(ids.includes(id), `${id} has an evaluator`);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('the migration\'s LIKE literals are economy\'s own reason prefixes, escaped', () => {
|
|
149
|
+
const lit = (p) => p.replace(/_/g, '\\_') + '%';
|
|
150
|
+
for (const p of [credits.IDEA_STREAM_REASON_PREFIXES.task_ship, credits.IDEA_STREAM_REASON_PREFIXES.full_pass,
|
|
151
|
+
credits.IDEA_STREAM_REASON_PREFIXES.governance_pass, credits.ARTIST_PAGE_REASON_PREFIX]) {
|
|
152
|
+
assert.ok(MIGRATION.includes(`LIKE '${lit(p)}'`), `the backfill spells ${p}`);
|
|
153
|
+
assert.ok(read('modules/economy/achievements.js').includes(lit(p).replace(/\\/g, '\\\\')), `the evaluator spells ${p}`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---- 2. the reconcile -----------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
await test('an ideator earns each idea badge at its threshold, exactly once', async () => {
|
|
160
|
+
seams._reset();
|
|
161
|
+
const c = fakeLedger();
|
|
162
|
+
const IDE = '2002';
|
|
163
|
+
await achievements.reconcileBuilderAchievements(c, IDE);
|
|
164
|
+
assert.deepEqual(craftShelf(c, IDE), [], 'nothing for a builder the ledger has paid nothing');
|
|
165
|
+
pay(c, IDE, 'idea.credit.task_ship:7:101');
|
|
166
|
+
pay(c, IDE, 'idea.credit.full_pass:7');
|
|
167
|
+
pay(c, IDE, 'idea.credit.governance_pass:7');
|
|
168
|
+
for (let t = 1; t <= 9; t++) pay(c, IDE, `idea.credit.task_ship:8:${200 + t}`);
|
|
169
|
+
await achievements.reconcileBuilderAchievements(c, IDE);
|
|
170
|
+
assert.deepEqual(craftShelf(c, IDE), ['first-board-ratification', 'first-full-idea', 'first-thought-ignited'],
|
|
171
|
+
'nine tasks under one thought (plus one under another) is not ten from one');
|
|
172
|
+
pay(c, IDE, 'idea.credit.task_ship:8:210');
|
|
173
|
+
pay(c, IDE, 'idea.credit.task_ship:8:210', 3); // a second payee row for the same task is not a second task
|
|
174
|
+
await achievements.reconcileBuilderAchievements(c, IDE);
|
|
175
|
+
await achievements.reconcileBuilderAchievements(c, IDE);
|
|
176
|
+
assert.deepEqual(craftShelf(c, IDE), ['first-board-ratification', 'first-full-idea', 'first-thought-ignited', 'thought-built-ten']);
|
|
177
|
+
assert.equal(c.unlocked.length, new Set(c.unlocked.map((u) => u.achievement_id)).size, 'each exactly once');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await test('an artist earns First Page, Ten Pages and A Whole Surface, exactly once', async () => {
|
|
181
|
+
seams._reset();
|
|
182
|
+
let surfaces = [{ key: 'landing', tweaked: 5, total: 6 }];
|
|
183
|
+
seams.registerProvider('copy-desk.artistStats', async () => ({ surfaces }));
|
|
184
|
+
const c = fakeLedger();
|
|
185
|
+
const ART = '2001';
|
|
186
|
+
pay(c, ART, 'tweak.page_approved:301', 30, 301);
|
|
187
|
+
await achievements.reconcileBuilderAchievements(c, ART);
|
|
188
|
+
assert.deepEqual(craftShelf(c, ART), ['first-page-approved']);
|
|
189
|
+
for (let t = 2; t <= 9; t++) pay(c, ART, `tweak.page_approved:${300 + t}`, 30, 300 + t);
|
|
190
|
+
await achievements.reconcileBuilderAchievements(c, ART);
|
|
191
|
+
assert.deepEqual(craftShelf(c, ART), ['first-page-approved'], 'nine pages is not ten');
|
|
192
|
+
pay(c, ART, 'tweak.page_approved:310', 30, 310);
|
|
193
|
+
surfaces = [{ key: 'landing', tweaked: 6, total: 6 }];
|
|
194
|
+
await achievements.reconcileBuilderAchievements(c, ART);
|
|
195
|
+
await achievements.reconcileBuilderAchievements(c, ART);
|
|
196
|
+
assert.deepEqual(craftShelf(c, ART), ['first-page-approved', 'ten-pages-approved', 'whole-surface-tweaked']);
|
|
197
|
+
assert.equal(c.unlocked.length, 3, 'each exactly once');
|
|
198
|
+
seams._reset();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await test('A Whole Surface without the studio\'s port is "could not tell", never a badge', async () => {
|
|
202
|
+
seams._reset();
|
|
203
|
+
const c = fakeLedger();
|
|
204
|
+
pay(c, '2001', 'tweak.page_approved:301', 30, 301);
|
|
205
|
+
await achievements.reconcileBuilderAchievements(c, '2001');
|
|
206
|
+
assert.ok(!shelf(c, '2001').includes('whole-surface-tweaked'));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ---- 3. the writers -------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
await test('a Full Idea payment puts First Full Idea on the ideator\'s shelf, in the same call', async () => {
|
|
212
|
+
seams._reset();
|
|
213
|
+
const c = fakeLedger();
|
|
214
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 7, authorId: '2002', completenessScore: 90, config: { ideatorCredit: true } });
|
|
215
|
+
assert.ok(r.credited > 0, 'paid');
|
|
216
|
+
assert.deepEqual(craftShelf(c, '2002'), ['first-full-idea']);
|
|
217
|
+
assert.ok(c.sql.includes('SAVEPOINT ach_craft_credit'), 'under a savepoint, so a gamification error cannot fail the payment');
|
|
218
|
+
const again = await credits.awardIdeaFullPassCredit(c, { ideaId: 7, authorId: '2002', completenessScore: 90, config: { ideatorCredit: true } });
|
|
219
|
+
assert.equal(again.credited, 0, 'the second call pays nothing');
|
|
220
|
+
assert.equal(c.unlocked.length, 1, 'and unlocks nothing more');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await test('an approved page puts First Page Approved on the artist\'s shelf', async () => {
|
|
224
|
+
seams._reset();
|
|
225
|
+
const c = fakeLedger();
|
|
226
|
+
const r = await credits.awardArtistPageCredit(c, 301);
|
|
227
|
+
assert.equal(r.reason, 'paid');
|
|
228
|
+
assert.deepEqual(craftShelf(c, '2001'), ['first-page-approved']);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await test('a reconcile that throws never fails the payment', async () => {
|
|
232
|
+
seams._reset();
|
|
233
|
+
const c = fakeLedger({ failReconcile: true });
|
|
234
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 9, authorId: '2002', completenessScore: 90, config: { ideatorCredit: true } });
|
|
235
|
+
assert.ok(r.credited > 0, 'still paid');
|
|
236
|
+
assert.equal(c.ledger.length, 1);
|
|
237
|
+
assert.ok(c.sql.includes('ROLLBACK TO SAVEPOINT ach_craft_credit'), 'rolled back to its own savepoint only');
|
|
238
|
+
// on a POOL (no transaction) SAVEPOINT itself fails; the reconcile still runs
|
|
239
|
+
const pool = fakeLedger();
|
|
240
|
+
const q = pool.query.bind(pool);
|
|
241
|
+
pool.query = (text, params) => (/^SAVEPOINT/.test(text) ? Promise.reject(new Error('SAVEPOINT can only be used in transaction blocks')) : q(text, params));
|
|
242
|
+
await achievements.reconcileAfterCraftCredit(pool, '2002');
|
|
243
|
+
pay(pool, '2002', 'idea.credit.full_pass:11');
|
|
244
|
+
const got = await achievements.reconcileAfterCraftCredit(pool, '2002');
|
|
245
|
+
assert.deepEqual(got.map((a) => a.id), ['first-full-idea'], 'a pool payer still reaches the shelf');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await test('a payment runs only the craft rules, in two reads, and asks the studio only about an artist', async () => {
|
|
249
|
+
seams._reset();
|
|
250
|
+
let portCalls = 0;
|
|
251
|
+
seams.registerProvider('copy-desk.artistStats', async () => { portCalls++; return { surfaces: [] }; });
|
|
252
|
+
const c = fakeLedger();
|
|
253
|
+
await credits.awardIdeaFullPassCredit(c, { ideaId: 7, authorId: '2002', completenessScore: 90, config: { ideatorCredit: true } });
|
|
254
|
+
const after = c.sql.slice(c.sql.findIndex((q) => q.startsWith('SAVEPOINT ach_craft_credit')));
|
|
255
|
+
assert.ok(!after.some((q) => /FROM claims|session_records|total_credits FROM builders|ship\.net_negative/.test(q)),
|
|
256
|
+
'no claim, streak, diff or credit-total check on an idea payment');
|
|
257
|
+
assert.equal(after.filter((q) => q.startsWith('SELECT achievement_id FROM builder_achievements')).length, 1, 'one read of what is held');
|
|
258
|
+
assert.equal(after.filter((q) => q.startsWith('SELECT bool_or(reason LIKE $2) AS ignited')).length, 1, 'one read of the craft facts');
|
|
259
|
+
assert.equal(portCalls, 0, 'an ideator with no approved page is never asked about surfaces');
|
|
260
|
+
await credits.awardArtistPageCredit(c, 301);
|
|
261
|
+
assert.equal(portCalls, 1, 'an artist with an approved page is');
|
|
262
|
+
seams._reset();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await test('a builder holding every craft badge costs one read and nothing more', async () => {
|
|
266
|
+
seams._reset();
|
|
267
|
+
const c = fakeLedger();
|
|
268
|
+
for (const id of CRAFT) c.unlocked.push({ builder_id: '2002', achievement_id: id });
|
|
269
|
+
const before = c.sql.length;
|
|
270
|
+
await achievements.reconcileAfterCraftCredit(c, '2002');
|
|
271
|
+
const sent = c.sql.slice(before).filter((q) => !/SAVEPOINT/.test(q));
|
|
272
|
+
assert.deepEqual(sent.map((q) => q.slice(0, 40)), ['SELECT achievement_id FROM builder_achie']);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ---- 4. the backfill ------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
await test('A Whole Surface is backfilled by the once-per-start sweep, for the artists the ledger has paid', async () => {
|
|
278
|
+
seams._reset();
|
|
279
|
+
let asked = [];
|
|
280
|
+
seams.registerProvider('copy-desk.artistStats', async (id) => { asked.push(String(id)); return { surfaces: [{ tweaked: 4, total: 4 }] }; });
|
|
281
|
+
const c = fakeLedger();
|
|
282
|
+
pay(c, '2001', 'tweak.page_approved:301', 30, 301);
|
|
283
|
+
pay(c, '2002', 'idea.credit.task_ship:7:101');
|
|
284
|
+
pay(c, '95', 'session.token_reward', 40); // an engineer the craft ledger never paid
|
|
285
|
+
const out = await achievements.backfillCraftAchievements({ client: c });
|
|
286
|
+
assert.equal(out.checked, 2, 'the artist and the ideator, not the engineer');
|
|
287
|
+
assert.ok(shelf(c, '2001').includes('whole-surface-tweaked'));
|
|
288
|
+
assert.ok(!shelf(c, '95').length, 'the engineer is untouched');
|
|
289
|
+
assert.deepEqual(asked, ['2001'], 'the studio is asked about the artist only');
|
|
290
|
+
const again = await achievements.backfillCraftAchievements({ client: c });
|
|
291
|
+
assert.equal(again.unlocked, 0, 'a second run unlocks nothing');
|
|
292
|
+
|
|
293
|
+
const reconciler = require('../modules/lifecycle/publish-reconciler.js');
|
|
294
|
+
const calls = [];
|
|
295
|
+
const reward = { backfillCraftAchievements: async () => { calls.push(1); return { checked: 1, unlocked: 1 }; } };
|
|
296
|
+
const quiet = { logFn: () => {}, errFn: () => {} };
|
|
297
|
+
await reconciler.runCraftAchievementBackfill({ reward, ...quiet, forceCraftBackfill: true });
|
|
298
|
+
await reconciler.runCraftAchievementBackfill({ reward, ...quiet });
|
|
299
|
+
assert.equal(calls.length, 1, 'once per process start, not on every tick');
|
|
300
|
+
const failing = await reconciler.runCraftAchievementBackfill({ reward: { backfillCraftAchievements: async () => { throw new Error('db down'); } }, ...quiet, forceCraftBackfill: true });
|
|
301
|
+
assert.equal(failing.skipped, 'error', 'a failure is logged and swallowed');
|
|
302
|
+
assert.match(read('modules/lifecycle/publish-reconciler.js'), /runCraftAchievementBackfill\(deps\)\.catch\(crashHandler\('craft achievement backfill'\)\)/, 'the tick runs it');
|
|
303
|
+
seams._reset();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await test('the backfill keys each badge on the same ledger fact and stamps the first qualifying credit, idempotently', () => {
|
|
307
|
+
const blocks = MIGRATION.split('INSERT INTO builder_achievements').slice(1);
|
|
308
|
+
const ids = blocks.map((b) => (b.match(/'([a-z0-9-]+)'/) || [])[1]);
|
|
309
|
+
assert.deepEqual(ids.sort(), CRAFT.filter((id) => id !== 'whole-surface-tweaked').sort(),
|
|
310
|
+
'six backfilled from the ledger; A Whole Surface needs the page inventory (a file), so the reconcile awards it');
|
|
311
|
+
for (const b of blocks) {
|
|
312
|
+
assert.match(b, /ON CONFLICT DO NOTHING/, 'a re-run converges');
|
|
313
|
+
assert.match(b, /delta > 0/, 'a zero or negative row is no moment');
|
|
314
|
+
}
|
|
315
|
+
assert.match(blocks.find((b) => b.includes("'ten-pages-approved'")), /WHERE nth = 10/, 'stamped at the tenth page');
|
|
316
|
+
assert.match(blocks.find((b) => b.includes("'thought-built-ten'")), /PARTITION BY builder_id, split_part\(reason, ':', 2\)/, 'counted per thought');
|
|
317
|
+
assert.match(MIGRATION, /diagnose-achievements\.js --apply/, 'the migration names how A Whole Surface is backfilled');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ---- 5. the pictures ------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
await test('the roster and the status page draw every craft badge with the migration\'s icon', () => {
|
|
323
|
+
const roster = read('modules/hall-ui/public/roster.js');
|
|
324
|
+
const status = read('modules/status-ui/public/status.js');
|
|
325
|
+
const unescape = (s) => s.replace(/\\u\{([0-9A-F]+)\}/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)));
|
|
326
|
+
for (const a of CATALOG.values()) {
|
|
327
|
+
const r = roster.match(new RegExp(`'${a.id}': '([^']+)'`));
|
|
328
|
+
const s = status.match(new RegExp(`'${a.id}': '([^']+)'`));
|
|
329
|
+
assert.ok(r && unescape(r[1]) === a.icon, `roster draws ${a.id} as ${a.icon}`);
|
|
330
|
+
assert.ok(s && s[1] === a.icon, `status draws ${a.id} as ${a.icon}`);
|
|
331
|
+
}
|
|
332
|
+
assert.ok(unescape(roster.match(/'art-pipeline-shipper': '([^']+)'/)[1]) === '🛠️');
|
|
333
|
+
assert.equal(status.match(/'art-pipeline-shipper': '([^']+)'/)[1], '🛠️');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
await test('a badge is named in words for a hover and a screen reader, never its id slug', () => {
|
|
337
|
+
const roster = read('modules/hall-ui/public/roster.js');
|
|
338
|
+
const fn = roster.slice(roster.indexOf('function achWords'), roster.indexOf('function achRowHtml'));
|
|
339
|
+
const achWords = new Function(`${fn}; return achWords;`)();
|
|
340
|
+
assert.equal(achWords('first-page-approved'), 'First page approved');
|
|
341
|
+
assert.equal(achWords('thought-built-ten'), 'Thought built ten');
|
|
342
|
+
assert.match(roster, /title="' \+ esc\(words\) \+ '" aria-label="' \+ esc\(words\) \+ '"/);
|
|
343
|
+
assert.doesNotMatch(roster, /title="' \+ esc\(id\)/);
|
|
344
|
+
const status = read('modules/status-ui/public/status.js');
|
|
345
|
+
assert.doesNotMatch(status, /span\.title = id;/);
|
|
346
|
+
assert.match(status, /span\.setAttribute\('aria-label', span\.title\)/);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
summary();
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// tests/founding_birth.mjs — A NEW PROJECT IS BORN INTO GENESIS, and a demo's genesis is
|
|
2
|
+
// expedited (task 1004506 / BV2.PS18, goal 1000121; spec step 5, D4, D8, D9).
|
|
3
|
+
//
|
|
4
|
+
// Done-when: a new real project and a new demo each open their hall already in genesis;
|
|
5
|
+
// the demo's decide stage needs no choices and its stages fit its time box; a
|
|
6
|
+
// pre-existing hall is unchanged. These tests RUN the instance half
|
|
7
|
+
// (modules/onboarding/founding-birth.js) over an in-memory project_settings store and
|
|
8
|
+
// fake lifecycle port: the birth hook runs once, is idempotent, never runs without the
|
|
9
|
+
// marker, and never after the project came alive. The hub half (the marker on the row
|
|
10
|
+
// and in web.env) is pinned in tests/provisioning_settings_env.mjs and
|
|
11
|
+
// tests/wizard_demo.mjs.
|
|
12
|
+
//
|
|
13
|
+
// Run: node --test tests/founding_birth.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const birth = require('../modules/onboarding/founding-birth.js');
|
|
21
|
+
const gh = require('../modules/onboarding/genesis-home.js');
|
|
22
|
+
const fm = require('../modules/onboarding/founding-mode.js');
|
|
23
|
+
|
|
24
|
+
const BORN = { born: true, demo: null };
|
|
25
|
+
const BORN_DEMO = { born: true, demo: { people: 3, hours_each: 4 } };
|
|
26
|
+
|
|
27
|
+
function world({ marker = BORN, settings = {}, withLifecycle = true } = {}) {
|
|
28
|
+
const store = new Map(Object.entries(settings));
|
|
29
|
+
const writes = [];
|
|
30
|
+
let seeds = 0;
|
|
31
|
+
const lifecycle = withLifecycle ? {
|
|
32
|
+
seedGenesis: async () => { seeds += 1; return { seeded: 5 }; },
|
|
33
|
+
genesisTasks: async () => ({}),
|
|
34
|
+
} : null;
|
|
35
|
+
const deps = () => ({
|
|
36
|
+
marker,
|
|
37
|
+
getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
|
|
38
|
+
setSetting: async (k, v) => { writes.push(k); store.set(k, String(v)); return { key: k, value: String(v) }; },
|
|
39
|
+
lifecycle, government: null, now: new Date('2026-10-01T09:00:00Z'),
|
|
40
|
+
});
|
|
41
|
+
return { store, writes, deps, seeds: () => seeds };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── the marker ───────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
test('the marker: born only with a readable birth stamp; a demo box only inside its limits', () => {
|
|
47
|
+
assert.deepEqual(birth.birthMarker(undefined), { born: false, demo: null });
|
|
48
|
+
assert.deepEqual(birth.birthMarker({}), { born: false, demo: null });
|
|
49
|
+
assert.deepEqual(birth.birthMarker({ birth: 'whenever' }), { born: false, demo: null });
|
|
50
|
+
assert.deepEqual(birth.birthMarker({ birth: '2026-10-01T00:00:00.000Z' }), { born: true, demo: null });
|
|
51
|
+
assert.deepEqual(birth.birthMarker({ birth: '2026-10-01T00:00:00.000Z', demoPeople: '3', demoHours: '4' }),
|
|
52
|
+
{ born: true, demo: { people: 3, hours_each: 4 } });
|
|
53
|
+
assert.deepEqual(birth.birthMarker({ birth: '2026-10-01T00:00:00.000Z', demoPeople: '99', demoHours: '4' }),
|
|
54
|
+
{ born: true, demo: null }, 'a malformed box still founds, with the ordinary plan');
|
|
55
|
+
assert.deepEqual(birth.birthMarker({ demoPeople: '3', demoHours: '4' }), { born: false, demo: null },
|
|
56
|
+
'a demo box without a birth is not a birth');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── the birth hook ───────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
test('no marker: nothing runs, and the database is never read (a pre-existing hall is unchanged)', async () => {
|
|
62
|
+
const r = await birth.bornFounding({
|
|
63
|
+
marker: { born: false, demo: null },
|
|
64
|
+
getSetting: async () => { throw new Error('read without a marker'); },
|
|
65
|
+
setSetting: async () => { throw new Error('write without a marker'); },
|
|
66
|
+
});
|
|
67
|
+
assert.deepEqual(r, { ran: false, reason: 'no_marker' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a new real project is born deciding: founding on, stage decide, no plan, nothing filed', async () => {
|
|
71
|
+
const w = world();
|
|
72
|
+
const r = await birth.bornFounding(w.deps());
|
|
73
|
+
assert.deepEqual(r, { ran: true, started: true, plan: false, demo: false });
|
|
74
|
+
const mode = await fm.foundingModeFor({ getSetting: w.deps().getSetting, government: null });
|
|
75
|
+
assert.equal(mode.active, true);
|
|
76
|
+
assert.equal(mode.stage, 'decide');
|
|
77
|
+
assert.equal(w.store.get(gh.PLAN_KEY), undefined, 'a real founder settles their own plan');
|
|
78
|
+
assert.equal(w.seeds(), 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('idempotent: a second run (a restart, a settings push) changes nothing', async () => {
|
|
82
|
+
const w = world();
|
|
83
|
+
await birth.bornFounding(w.deps());
|
|
84
|
+
const before = new Map(w.store);
|
|
85
|
+
w.writes.length = 0;
|
|
86
|
+
assert.deepEqual(await birth.bornFounding(w.deps()), { ran: false, reason: 'already_started' });
|
|
87
|
+
assert.deepEqual(w.writes, [], 'no write at all');
|
|
88
|
+
assert.deepEqual([...w.store], [...before]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('never after the project came alive: genesis happens once', async () => {
|
|
92
|
+
const w = world({ settings: {
|
|
93
|
+
[fm.KEYS.ACTIVE]: 'false', [fm.KEYS.STARTED_AT]: '2026-09-01T00:00:00.000Z', [fm.KEYS.ENDED_AT]: '2026-09-10T00:00:00.000Z',
|
|
94
|
+
} });
|
|
95
|
+
assert.deepEqual(await birth.bornFounding(w.deps()), { ran: false, reason: 'already_alive' });
|
|
96
|
+
assert.deepEqual(w.writes, []);
|
|
97
|
+
assert.equal(await fm.foundingModeFor({ getSetting: w.deps().getSetting, government: null }), null, 'the hall stays alive');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a founding already under way (started by hand, mid-stage) is left exactly where it is', async () => {
|
|
101
|
+
const w = world({ marker: BORN_DEMO, settings: {
|
|
102
|
+
[fm.KEYS.ACTIVE]: 'true', [fm.KEYS.STAGE]: 'strategize', [fm.KEYS.STARTED_AT]: '2026-09-28T00:00:00.000Z',
|
|
103
|
+
} });
|
|
104
|
+
assert.deepEqual(await birth.bornFounding(w.deps()), { ran: false, reason: 'already_started' });
|
|
105
|
+
assert.deepEqual(w.writes, []);
|
|
106
|
+
assert.equal(w.seeds(), 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a new demo is born past decide: the plan is settled from the demo, and its stages are filed', async () => {
|
|
110
|
+
const w = world({ marker: BORN_DEMO });
|
|
111
|
+
const r = await birth.bornFounding(w.deps());
|
|
112
|
+
assert.deepEqual(r, { ran: true, started: true, plan: true, demo: true });
|
|
113
|
+
assert.equal(w.seeds(), 1, 'the five stages filed as tasks, once');
|
|
114
|
+
const plan = JSON.parse(w.store.get(gh.PLAN_KEY));
|
|
115
|
+
assert.equal(plan.government, 'monarchy', 'Monarchy / BDFL: the founder\'s one yes closes each stage (D4)');
|
|
116
|
+
assert.equal(plan.passes, 1);
|
|
117
|
+
assert.equal(plan.advisors, 'none');
|
|
118
|
+
assert.equal(plan.expected_builders, 3, 'the demo\'s people');
|
|
119
|
+
assert.deepEqual(plan.demo, { people: 3, hours_each: 4 });
|
|
120
|
+
const mode = await fm.foundingModeFor({ getSetting: w.deps().getSetting, government: null });
|
|
121
|
+
assert.equal(mode.stage, 'scope', 'the decide stage needs no choices: it is already behind');
|
|
122
|
+
// Again: nothing more.
|
|
123
|
+
w.writes.length = 0;
|
|
124
|
+
assert.deepEqual(await birth.bornFounding(w.deps()), { ran: false, reason: 'already_started' });
|
|
125
|
+
assert.deepEqual(w.writes, []);
|
|
126
|
+
assert.equal(w.seeds(), 1, 'never filed twice');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a demo whose plan could not be saved yet (no board) asks for a retry, and the retry settles it', async () => {
|
|
130
|
+
birth.resetBirthForTests();
|
|
131
|
+
const store = new Map();
|
|
132
|
+
let seeds = 0;
|
|
133
|
+
const base = {
|
|
134
|
+
marker: BORN_DEMO,
|
|
135
|
+
getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
|
|
136
|
+
setSetting: async (k, v) => { store.set(k, String(v)); },
|
|
137
|
+
government: null,
|
|
138
|
+
};
|
|
139
|
+
const first = await birth.ensureBirth({ ...base, lifecycle: null });
|
|
140
|
+
assert.equal(first.retry, true);
|
|
141
|
+
assert.equal(first.reason, 'no_board');
|
|
142
|
+
assert.equal(store.get(fm.KEYS.STAGE) || 'decide', 'decide');
|
|
143
|
+
const second = await birth.ensureBirth({ ...base, lifecycle: { seedGenesis: async () => { seeds += 1; return { seeded: 5 }; } } });
|
|
144
|
+
assert.deepEqual(second, { ran: true, started: false, plan: true, demo: true }, 'the retry saves the plan without restarting founding');
|
|
145
|
+
assert.equal(seeds, 1);
|
|
146
|
+
assert.equal(store.get(fm.KEYS.STAGE), 'scope');
|
|
147
|
+
birth.resetBirthForTests();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('once per process: concurrent first reads share ONE run', async () => {
|
|
151
|
+
birth.resetBirthForTests();
|
|
152
|
+
const w = world({ marker: BORN_DEMO });
|
|
153
|
+
const results = await Promise.all([birth.ensureBirth(w.deps()), birth.ensureBirth(w.deps()), birth.ensureBirth(w.deps())]);
|
|
154
|
+
assert.equal(results[0], results[1]);
|
|
155
|
+
assert.equal(results[1], results[2]);
|
|
156
|
+
assert.equal(w.seeds(), 1);
|
|
157
|
+
assert.equal(w.writes.filter((k) => k === fm.KEYS.STARTED_AT).length, 1, 'started once');
|
|
158
|
+
assert.equal(w.writes.filter((k) => k === gh.PLAN_KEY).length, 1, 'planned once');
|
|
159
|
+
// A later read reuses the settled run; it does not touch the store again.
|
|
160
|
+
w.writes.length = 0;
|
|
161
|
+
await birth.ensureBirth(w.deps());
|
|
162
|
+
assert.deepEqual(w.writes, []);
|
|
163
|
+
birth.resetBirthForTests();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('a run that throws is forgotten, so a later read tries again; the read itself never fails', async () => {
|
|
167
|
+
birth.resetBirthForTests();
|
|
168
|
+
let fail = true;
|
|
169
|
+
const store = new Map();
|
|
170
|
+
const deps = {
|
|
171
|
+
marker: BORN,
|
|
172
|
+
getSetting: async (k) => { if (fail) throw new Error('db down'); return store.has(k) ? { value: store.get(k) } : null; },
|
|
173
|
+
setSetting: async (k, v) => { store.set(k, String(v)); },
|
|
174
|
+
};
|
|
175
|
+
assert.deepEqual(await birth.ensureBirth(deps), { ran: false, reason: 'error' });
|
|
176
|
+
fail = false;
|
|
177
|
+
assert.equal((await birth.ensureBirth(deps)).ran, true);
|
|
178
|
+
assert.equal(store.get(fm.KEYS.ACTIVE), 'true');
|
|
179
|
+
birth.resetBirthForTests();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── the wiring ───────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
test('the /me read (the port, no arguments) runs the birth first; a caller with its own deps does not', async () => {
|
|
185
|
+
const port = require('../modules/onboarding/port.js');
|
|
186
|
+
const orig = { ensure: birth.ensureBirth, read: fm.foundingModeFor };
|
|
187
|
+
const calls = [];
|
|
188
|
+
birth.ensureBirth = async () => { calls.push('birth'); };
|
|
189
|
+
fm.foundingModeFor = async (d) => { calls.push(d ? 'read:deps' : 'read'); return null; };
|
|
190
|
+
try {
|
|
191
|
+
await port.foundingModeFor();
|
|
192
|
+
assert.deepEqual(calls, ['birth', 'read'], 'the birth lands before the read, so the first hall paint is already founding');
|
|
193
|
+
calls.length = 0;
|
|
194
|
+
await port.foundingModeFor({ getSetting: async () => null });
|
|
195
|
+
assert.deepEqual(calls, ['read:deps']);
|
|
196
|
+
} finally {
|
|
197
|
+
birth.ensureBirth = orig.ensure;
|
|
198
|
+
fm.foundingModeFor = orig.read;
|
|
199
|
+
}
|
|
200
|
+
});
|
|
@@ -192,7 +192,8 @@ async function rosterRows(marks) {
|
|
|
192
192
|
makeLedger: (cfg) => { rowFn = cfg.rowHtml; return { render() {} }; },
|
|
193
193
|
},
|
|
194
194
|
});
|
|
195
|
-
|
|
195
|
+
// addEventListener/querySelectorAll: the role toggle wires its chips (task 1004437).
|
|
196
|
+
sandbox.document = { getElementById: () => ({ innerHTML: '', className: '', textContent: '', hidden: false, addEventListener() {}, querySelectorAll: () => [] }) };
|
|
196
197
|
if (!marks) delete sandbox.window.OTBFounding; // a page without the script at all
|
|
197
198
|
vm.runInContext(read('roster.js'), sandbox, { filename: 'roster.js' });
|
|
198
199
|
await new Promise((r) => setImmediate(r));
|
package/tests/founding_mode.mjs
CHANGED
|
@@ -278,7 +278,9 @@ test('/me carries founding_mode from the onboarding port, fail-open to null', ()
|
|
|
278
278
|
assert.match(ME, /onboarding\.foundingModeFor\(\)/);
|
|
279
279
|
const at = ME.indexOf('onboarding.foundingModeFor()');
|
|
280
280
|
assert.match(ME.slice(at, at + 200), /catch \(_\) \{ return null; \}/);
|
|
281
|
-
|
|
281
|
+
// The port delegates at call time; a no-argument read (me.js) first runs the guarded
|
|
282
|
+
// birth (task 1004506, tests/founding_birth.mjs).
|
|
283
|
+
assert.match(PORT, /foundingModeFor: async \(\.\.\.a\) => \{\s*if \(!a\.length\) await foundingBirth\.ensureBirth\(\);\s*return foundingMode\.foundingModeFor\(\.\.\.a\);/);
|
|
282
284
|
});
|
|
283
285
|
|
|
284
286
|
test('BOTH applyAccess callers pass it, or the index would never show the band', () => {
|