@bongos/core 1.20.73 → 1.20.75
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 +66 -46
- 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 +8 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0359-nobody-sets-a-module-score-by-hand-metic-can-ask-for-a-re-check.md +40 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +185 -3
- package/docs/api-reference.md +5 -3
- package/docs/architecture.md +1 -1
- package/docs/copy-inventory.md +447 -373
- package/docs/copy-registry.json +1157 -468
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +6 -1
- package/docs/page-readings.json +1044 -650
- package/modules/government/catalog.js +3 -0
- package/modules/government/migrations/government_021_module_assessment_rerun.sql +36 -0
- package/modules/provisioning/routes/provisioning.js +35 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/projects.html +589 -63
- package/modules/public-landing/public/projects.probes.json +8 -5
- package/modules/public-landing/public/projects.states.json +19 -14
- package/modules/ui-design/kit/lib.js +4 -2
- package/modules/ui-design/kit/serve.js +5 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/modules.js +27 -0
- package/src/module-api.js +1 -1
- package/tests/module_store_reassess.mjs +120 -0
- package/tests/projects_hub.mjs +9 -5
- package/tests/projects_hub_app_status.mjs +1 -1
- package/tests/projects_hub_app_step.mjs +10 -10
- package/tests/projects_hub_look.mjs +7 -7
- package/tests/projects_hub_module_picker.mjs +9 -9
- package/tests/projects_hub_pre_uat.mjs +2 -1
- package/tests/wizard_demo.mjs +1 -1
- package/tests/wizard_draft_resume.mjs +1 -1
- package/tests/wizard_front_door.mjs +11 -7
- package/tests/wizard_intent_resume.mjs +9 -9
- package/tests/wizard_physics.mjs +264 -0
|
@@ -106,17 +106,17 @@ test('every colour the previews paint is its pack\'s, and the night shares are t
|
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
test('the step sits between App and Review: look 10, review 11, done 12, draft v7, a beat on the rail', () => {
|
|
109
|
-
assert.match(HUB, /
|
|
110
|
-
assert.match(HUB, /var DRAFT_KEY = 'cb-create-draft-
|
|
111
|
-
assert.match(HUB, /<li data-step="
|
|
109
|
+
assert.match(HUB, /10: 'panel-app', 11: 'panel-look', 12: 'panel-review', 13: 'panel-done' \};/);
|
|
110
|
+
assert.match(HUB, /var DRAFT_KEY = 'cb-create-draft-v8';/);
|
|
111
|
+
assert.match(HUB, /<li data-step="11"><span class="snum">9<\/span><span class="slbl">Look<\/span><\/li>\r?\n\s*<li data-step="12"><span class="snum">10<\/span><span class="slbl">Create<\/span><\/li>/);
|
|
112
112
|
const panel = (HUB.match(/<section class="panel" id="panel-look"[\s\S]*?<\/section>/) || [''])[0];
|
|
113
113
|
assert.match(panel, /<h1 id="look-h">Give it a look<\/h1>/);
|
|
114
|
-
assert.match(panel, /<form data-next="
|
|
115
|
-
assert.match(panel, /data-back="
|
|
114
|
+
assert.match(panel, /<form data-next="12">/);
|
|
115
|
+
assert.match(panel, /data-back="10"/);
|
|
116
116
|
assert.match(panel, /<div class="lookPick" id="wizLook"><\/div>/);
|
|
117
117
|
assert.ok(HUB.indexOf('id="panel-app"') < HUB.indexOf('id="panel-look"') && HUB.indexOf('id="panel-look"') < HUB.indexOf('id="panel-review"'), 'in document order too');
|
|
118
|
-
assert.match(HUB, /if \(step ===
|
|
119
|
-
assert.match(fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8'), /#view-new \.steps\{display:grid;grid-template-columns:repeat\(
|
|
118
|
+
assert.match(HUB, /if \(step === 11\) renderLook\(\);\r?\n\s*if \(step === 12\) renderReview\(\);/);
|
|
119
|
+
assert.match(fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8'), /#view-new \.steps\{display:grid;grid-template-columns:repeat\(10,1fr\);/, 'a track per numbered step');
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
test('every kit walk past the App step passes the Look step, and the kit has a state on it', () => {
|
|
@@ -41,7 +41,7 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
|
41
41
|
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
|
|
42
42
|
|
|
43
43
|
const START = '/* ── the module picker (task 1002339, BV1.R20) ──';
|
|
44
|
-
const END = "$('skipModules').addEventListener('click', function () { show(
|
|
44
|
+
const END = "$('skipModules').addEventListener('click', function () { show(8, true); });";
|
|
45
45
|
const from = HUB.indexOf(START);
|
|
46
46
|
const to = HUB.indexOf(END);
|
|
47
47
|
assert.ok(from > -1, 'the picker block opens with its sentinel comment');
|
|
@@ -160,7 +160,7 @@ function boot({ state: seed = {}, fetchOk = true, payload = PAYLOAD } = {}) {
|
|
|
160
160
|
const els = new Map();
|
|
161
161
|
const $ = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
|
|
162
162
|
const form = makeEl('panel-modules-form');
|
|
163
|
-
const state = { step:
|
|
163
|
+
const state = { step: 7, projType: null, teamShape: null, modules: null, ...seed };
|
|
164
164
|
const shown = [];
|
|
165
165
|
let saves = 0;
|
|
166
166
|
let fetches = 0;
|
|
@@ -312,7 +312,7 @@ test('Skip walks past WITHOUT answering, so the server applies the bundle the pa
|
|
|
312
312
|
|
|
313
313
|
// Assert — skipping is the one path that must leave nothing said, because the
|
|
314
314
|
// preselect and the server's fallback are the same declaration (ADR 0237)
|
|
315
|
-
assert.deepEqual(h.shown, [
|
|
315
|
+
assert.deepEqual(h.shown, [8], 'it advances to the Home step (8 since task 1004418, 7 since 1004417)');
|
|
316
316
|
assert.equal(h.state.modules, null, 'and answers nothing');
|
|
317
317
|
assert.equal(h.forCreate(), undefined);
|
|
318
318
|
assert.deepEqual(h.picked(), ['npm-release', 'discord'], 'while the set they were shown is still the set they get');
|
|
@@ -377,7 +377,7 @@ test('a catalog that will not load degrades to a clause — it never blocks a pr
|
|
|
377
377
|
assert.match(h.coreNote(), /couldn’t load/, 'the panel says so in the owner\'s words');
|
|
378
378
|
assert.equal(h.state.modules, null, 'nothing is answered on their behalf');
|
|
379
379
|
h.skip();
|
|
380
|
-
assert.deepEqual(h.shown, [
|
|
380
|
+
assert.deepEqual(h.shown, [8], 'and the wizard still walks on — the server applies the bundle');
|
|
381
381
|
});
|
|
382
382
|
|
|
383
383
|
test('every rendered value goes through the page escaper', async () => {
|
|
@@ -415,7 +415,7 @@ test('the review names the set even when the picker was never opened (task 10023
|
|
|
415
415
|
// and the module NAMES are not known. "None — just the core" would be a false
|
|
416
416
|
// promise on the last screen before Create: the server is about to apply the
|
|
417
417
|
// type's bundle. The row says that instead, then repaints when the read lands.
|
|
418
|
-
const h = boot({ state: { projType: 'game', step:
|
|
418
|
+
const h = boot({ state: { projType: 'game', step: 12 } }); // 12 = the review since task 1004418 (11 since 1004421, 10 since 1004417, 8 since 1004351)
|
|
419
419
|
|
|
420
420
|
// Arrange + Act — the review asks for the value before the read can answer
|
|
421
421
|
const first = h.extrasRow();
|
|
@@ -430,14 +430,14 @@ test('the review names the set even when the picker was never opened (task 10023
|
|
|
430
430
|
});
|
|
431
431
|
|
|
432
432
|
test('the review reads an answered picker back, empty included', async () => {
|
|
433
|
-
const chosen = boot({ state: { projType: 'game', modules: ['agents'], step:
|
|
433
|
+
const chosen = boot({ state: { projType: 'game', modules: ['agents'], step: 8 } });
|
|
434
434
|
// before the catalog lands the row says the true thing without the names —
|
|
435
435
|
// never the raw module keys, which are jargon on an owner-facing screen
|
|
436
436
|
assert.equal(chosen.extrasRow(), 'The extras you picked');
|
|
437
437
|
await chosen.settle();
|
|
438
438
|
assert.equal(chosen.extrasRow(), 'Extra agents', 'and names them once it has');
|
|
439
439
|
|
|
440
|
-
const none = boot({ state: { projType: 'game', modules: [], step:
|
|
440
|
+
const none = boot({ state: { projType: 'game', modules: [], step: 8 } });
|
|
441
441
|
none.extrasRow();
|
|
442
442
|
await none.settle();
|
|
443
443
|
assert.equal(none.extrasRow(), 'None — just the core', 'a deliberate "none" is stated as one');
|
|
@@ -510,7 +510,7 @@ test('Skip after an answered About step still yields the set the panel showed',
|
|
|
510
510
|
|
|
511
511
|
h.skip();
|
|
512
512
|
|
|
513
|
-
assert.deepEqual(h.shown, [
|
|
513
|
+
assert.deepEqual(h.shown, [8]);
|
|
514
514
|
assert.equal(h.state.modules, null, 'skipping answers nothing, tailored or not');
|
|
515
515
|
assert.equal(h.forCreate(), undefined, 'so the create request still carries no modules key');
|
|
516
516
|
assert.deepEqual(h.picked(), ['discord'], 'while the set they were shown is still the set they get');
|
|
@@ -821,7 +821,7 @@ test('with nothing switched on the count says only what is shown', async () => {
|
|
|
821
821
|
});
|
|
822
822
|
|
|
823
823
|
test('the review names the hidden picks too — a filter never reaches the create request', async () => {
|
|
824
|
-
const h = boot({ state: { projType: 'game', step:
|
|
824
|
+
const h = boot({ state: { projType: 'game', step: 12 } }); // 12 = the review since task 1004418 (11 since 1004421, 10 since 1004417, 8 since 1004351)
|
|
825
825
|
await h.open();
|
|
826
826
|
h.click('agents');
|
|
827
827
|
assert.deepEqual(h.forCreate(), ['agents', 'npm-release', 'discord']);
|
|
@@ -552,7 +552,8 @@ test('the rail at the done panel keeps its marker on Create until the project an
|
|
|
552
552
|
'the done-panel poll repaints when the status moves');
|
|
553
553
|
// the first NUMBERED beat carries it — since task 1004417 the front door's two screens come
|
|
554
554
|
// before the rail, and the card statically wears wizFront so a no-script visitor sees no rail there
|
|
555
|
-
|
|
555
|
+
// (since task 1004418 that beat is planet physics, the first numbered step)
|
|
556
|
+
assert.match(HUB, new RegExp(`<li class="now" data-step="${STEP['panel-physics']}" aria-current="step"><span class="snum">1</span>`), 'the static rail starts with the same marker');
|
|
556
557
|
assert.match(HUB, /<section id="view-new" hidden>\s*<div class="wrap viewWrap">\s*<div class="card wizFront">/, 'and steps aside on the static first screen');
|
|
557
558
|
});
|
|
558
559
|
|
package/tests/wizard_demo.mjs
CHANGED
|
@@ -251,7 +251,7 @@ test('Name my demo planet carries the demo into the ordinary wizard: the idea, t
|
|
|
251
251
|
assert.deepEqual(JSON.parse(JSON.stringify(h.state.demo)), { people: 3, hours_each: 4, idea: 'A shared reading list.', invites: ['avery'] });
|
|
252
252
|
assert.equal(h.state.description, 'A shared reading list.', 'the idea is the description');
|
|
253
253
|
assert.equal(h.state.teamShape, 'small-team');
|
|
254
|
-
assert.deepEqual(h.shown, [
|
|
254
|
+
assert.deepEqual(h.shown, [4], 'on to the name step, past planet physics (task 1004418)');
|
|
255
255
|
});
|
|
256
256
|
|
|
257
257
|
test('the create sends the demo, the review names it, and the project page asks the question', () => {
|
|
@@ -92,7 +92,7 @@ test('a draft already at step 1 is unchanged, and an out-of-range step is not re
|
|
|
92
92
|
assert.equal(runStep({ authKnown: false, draftStep: 1 }).persisted.step, 1);
|
|
93
93
|
// 12 is the done panel (11 before task 1004421's Look step, 9 before task 1004417's front door, 8 before task 1004351), reached by its own branch
|
|
94
94
|
// above this slice; 0 is the gate. Neither is a resumable wizard position, so the placeholder is allowed to stand.
|
|
95
|
-
for (const bad of [0,
|
|
95
|
+
for (const bad of [0, 13, 99]) {
|
|
96
96
|
assert.equal(runStep({ authKnown: false, draftStep: bad }).persisted.step, 1, `step ${bad} is not resumed`);
|
|
97
97
|
}
|
|
98
98
|
});
|
|
@@ -42,18 +42,21 @@ test('the front door stands between sign-in and the name step: gate 0, terms 1,
|
|
|
42
42
|
assert.equal(MAP['panel-gate'], 0, 'sign-in stays where it is today, first');
|
|
43
43
|
assert.equal(MAP['panel-terms'], 1);
|
|
44
44
|
assert.equal(MAP['panel-fork'], 2);
|
|
45
|
-
|
|
45
|
+
// task 1004418: planet physics (questions 1 to 4) sits between the fork and the name step
|
|
46
|
+
assert.equal(MAP['panel-physics'], 3, 'the fork opens planet creation with the physics questions');
|
|
47
|
+
assert.equal(MAP['panel-name'], 4, 'and today\'s wizard continues unchanged after them');
|
|
46
48
|
assert.ok(HUB.indexOf('id="panel-gate"') < HUB.indexOf('id="panel-terms"')
|
|
47
49
|
&& HUB.indexOf('id="panel-terms"') < HUB.indexOf('id="panel-fork"')
|
|
48
|
-
&& HUB.indexOf('id="panel-fork"') < HUB.indexOf('id="panel-
|
|
50
|
+
&& HUB.indexOf('id="panel-fork"') < HUB.indexOf('id="panel-physics"')
|
|
51
|
+
&& HUB.indexOf('id="panel-physics"') < HUB.indexOf('id="panel-name"'), 'the markup reads in the same order');
|
|
49
52
|
assert.match(TERMS, /<form data-next="2">/, 'the terms go on to the fork');
|
|
50
|
-
assert.match(FORK, /<form data-next="3">/, 'the fork\'s primary door goes on to
|
|
53
|
+
assert.match(FORK, /<form data-next="3">/, 'the fork\'s primary door goes on to planet physics');
|
|
51
54
|
assert.match(FORK, /data-back="1"/, 'and the way back is the terms');
|
|
52
55
|
});
|
|
53
56
|
|
|
54
57
|
test('the rail is the numbered steps\' — it steps aside on both front-door screens, statically too', () => {
|
|
55
58
|
const beats = [...HUB.matchAll(/<li[^>]*data-step="(\d+)"/g)].map((m) => Number(m[1]));
|
|
56
|
-
assert.deepEqual(beats, [3, 4, 5, 6, 7, 8, 9, 10, 11], 'the rail numbers
|
|
59
|
+
assert.deepEqual(beats, [3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 'the rail numbers physics…create, never the front door');
|
|
57
60
|
assert.match(HUB, /<div class="card wizFront">/, 'a no-script visitor on the static first screen sees no rail');
|
|
58
61
|
assert.match(STYLE, /#view-new \.card\.wizFront \.steps\{display:none;\}/);
|
|
59
62
|
});
|
|
@@ -215,7 +218,7 @@ function runShow(step) {
|
|
|
215
218
|
root: { querySelector: (sel) => (sel === '.card' ? { classList: card } : { classList: split }) },
|
|
216
219
|
paintRail() {}, saveDraft() {}, authKnown: false, repoAccess: null,
|
|
217
220
|
renderModules() {}, syncHome() {}, ensureRepoAccess() {}, checkSlugAvailable() {}, renderAddrPanel() {},
|
|
218
|
-
syncAppStep() {}, syncRenderSetup() {}, renderReview() {},
|
|
221
|
+
syncAppStep() {}, syncRenderSetup() {}, renderReview() {}, renderLook() {}, paintPhysics() {},
|
|
219
222
|
};
|
|
220
223
|
sandbox.globalThis = sandbox;
|
|
221
224
|
vm.createContext(sandbox);
|
|
@@ -225,10 +228,11 @@ function runShow(step) {
|
|
|
225
228
|
|
|
226
229
|
test('show() takes the rail away on the two front-door screens only, and gives the fork the whole card', () => {
|
|
227
230
|
assert.ok(showSlice, 'show() was not found in projects.html');
|
|
228
|
-
const terms = runShow(1), fork = runShow(2),
|
|
231
|
+
const terms = runShow(1), fork = runShow(2), physics = runShow(3), name = runShow(4), done = runShow(MAP['panel-done']);
|
|
229
232
|
assert.deepEqual(terms.on, ['panel-terms']);
|
|
230
233
|
assert.ok(terms.card.has('wizFront') && !terms.split.has('wizFork'), 'terms: no rail, the bongo pair stays');
|
|
231
234
|
assert.ok(fork.card.has('wizFront') && fork.split.has('wizFork'), 'fork: no rail, and the seed takes the pair\'s place');
|
|
235
|
+
assert.ok(!physics.card.has('wizFront') && physics.split.has('wizFork'), 'physics (task 1004418): the rail is back, and the forming planet keeps the whole card');
|
|
232
236
|
assert.ok(!name.card.has('wizFront') && !name.split.has('wizFork'), 'the name step has its rail back');
|
|
233
237
|
assert.ok(done.split.has('wizDone') && !done.card.has('wizFront'), 'the done panel is untouched by the front door');
|
|
234
238
|
});
|
|
@@ -246,7 +250,7 @@ test('the create carries the tick, and is never filed without it', () => {
|
|
|
246
250
|
test('the tick is part of the draft and of the reset, and the draft key moved with the renumbering', () => {
|
|
247
251
|
assert.match(SCRIPT, /var state = \{ step: 0,[^}]*termsAck: false \};/, 'the initial state declares it');
|
|
248
252
|
assert.match(SCRIPT, /state = \{ step: 1,[^}]*termsAck: false \};/, 'and "start another project" clears it — each project records its own');
|
|
249
|
-
assert.match(SCRIPT, /var DRAFT_KEY = 'cb-create-draft-
|
|
253
|
+
assert.match(SCRIPT, /var DRAFT_KEY = 'cb-create-draft-v8';/, 'a draft saved under the old numbering is ignored, not replayed onto the wrong screen');
|
|
250
254
|
});
|
|
251
255
|
|
|
252
256
|
// ── the look-before-you-ship kit walks it ─────────────────────────────────────
|
|
@@ -58,8 +58,8 @@ test('both slices are still findable (a rename must fail loudly, not silently pa
|
|
|
58
58
|
assert.ok(offerSlice, 'paintResumeOffer() was not found in projects.html');
|
|
59
59
|
assert.match(enterSlice, /if \(focus && !resumeIntent\) \{/, 'the intent branch is part of the slice');
|
|
60
60
|
assert.match(enterSlice, /startProgress\(created\.id\);/, 'so is the resume branch it guards');
|
|
61
|
-
assert.match(enterSlice, /draft\.step >= 1 && draft\.step <=
|
|
62
|
-
'the STORED draft\'s step decides, not state.step — state.step is
|
|
61
|
+
assert.match(enterSlice, /draft\.step >= 1 && draft\.step <= 12/,
|
|
62
|
+
'the STORED draft\'s step decides, not state.step — state.step is 13 for the whole life of a finished standup');
|
|
63
63
|
assert.match(SCRIPT, /if \(created && created\.id\) \{ enterCreated\(created, !!focus\); return; \}/,
|
|
64
64
|
'enter() still hands the created record straight to it');
|
|
65
65
|
});
|
|
@@ -73,7 +73,7 @@ function runEnter({ focus, resumeIntent = false, draft = null, record }) {
|
|
|
73
73
|
const calls = { show: [], resets: 0, offer: [], done: 0, progress: [], polls: 0, focused: 0 };
|
|
74
74
|
// state.step is the DONE step for the whole life of a finished standup — the value
|
|
75
75
|
// a draft-blind reading would have trusted
|
|
76
|
-
const state = { step: draft ? draft.step :
|
|
76
|
+
const state = { step: draft ? draft.step : 13, name: 'Mercury', slug: 'mercury', domain: '', mode: 'greenfield', noAddress: false, modules: null };
|
|
77
77
|
const sandbox = {
|
|
78
78
|
record, focus, resumeIntent, state,
|
|
79
79
|
DRAFT_KEY: 'draft',
|
|
@@ -99,7 +99,7 @@ const REC = { id: 'inst-1', name: 'Acme', slug: 'acme', domain: 'acme.example',
|
|
|
99
99
|
|
|
100
100
|
test('a reload / Back / identity settle (focus false) still resumes the done panel, watch and all', () => {
|
|
101
101
|
const { calls, state } = runEnter({ focus: false, record: REC });
|
|
102
|
-
assert.deepEqual(calls.show, [[
|
|
102
|
+
assert.deepEqual(calls.show, [[13, false]], 'the done panel, and no focus steal on a non-deliberate arrival');
|
|
103
103
|
assert.equal(calls.resets, 0, 'nothing is reset — this is the same standup continuing');
|
|
104
104
|
assert.equal(calls.done, 1, 'the checklist is re-rendered');
|
|
105
105
|
assert.deepEqual(calls.progress, ['inst-1'], 'the progress watch is restarted from the record');
|
|
@@ -127,7 +127,7 @@ test('…and the standup stays one click away, named, on the resume link', () =>
|
|
|
127
127
|
|
|
128
128
|
test('the resume link gets the done panel back — the record was never cleared', () => {
|
|
129
129
|
const { calls, sandbox } = runEnter({ focus: true, resumeIntent: true, record: REC });
|
|
130
|
-
assert.deepEqual(calls.show, [[
|
|
130
|
+
assert.deepEqual(calls.show, [[13, true]], 'a deliberate resume moves focus to the done panel');
|
|
131
131
|
assert.deepEqual(calls.progress, ['inst-1'], 'the watch comes back with it');
|
|
132
132
|
assert.equal(sandbox.resumeIntent, false, 'the flag is ONE-SHOT — the next plain nav click must mean "new" again');
|
|
133
133
|
assert.deepEqual(calls.offer, [null], 'and the offer comes down behind it');
|
|
@@ -148,7 +148,7 @@ test('the done panel\'s own saved step is NOT a second draft — it must not blo
|
|
|
148
148
|
// Caught in a render, not in a unit test: show(12) calls saveDraft(), so DRAFT_KEY is
|
|
149
149
|
// non-empty again the moment a create lands. Reading "is there a draft?" left the
|
|
150
150
|
// finished project's name in the fresh form, under a line offering to resume it.
|
|
151
|
-
const { calls, state } = runEnter({ focus: true, record: REC, draft: { step:
|
|
151
|
+
const { calls, state } = runEnter({ focus: true, record: REC, draft: { step: 13, name: 'Mercury' } });
|
|
152
152
|
assert.equal(calls.resets, 1, 'the done step is bookkeeping, not a project being typed');
|
|
153
153
|
assert.deepEqual(calls.show, [[1, false]], 'out-of-range steps land on step 1, the front door');
|
|
154
154
|
assert.equal(state.name, '', 'and the finished project\'s name does not survive into it');
|
|
@@ -159,7 +159,7 @@ test('a draft on the app step, the Look step or the review is live too — the l
|
|
|
159
159
|
// The app step moved the review from 7 to 8, the front door (task 1004417) to 10 and the
|
|
160
160
|
// Look step (task 1004421) to 11. A guard left behind would reset a second project
|
|
161
161
|
// sitting on its review, silently wiping it.
|
|
162
|
-
for (const step of [
|
|
162
|
+
for (const step of [10, 11, 12]) {
|
|
163
163
|
const { calls, state } = runEnter({ focus: true, record: REC, draft: { step } });
|
|
164
164
|
assert.equal(calls.resets, 0, `step ${step} is a live draft`);
|
|
165
165
|
assert.deepEqual(calls.show, [[step, true]]);
|
|
@@ -175,8 +175,8 @@ test('a draft on the front door is live too — a founder part-way through it ke
|
|
|
175
175
|
}
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
test('a draft whose step is missing or junk resets too — only 1-
|
|
179
|
-
for (const draft of [{ step: 0 }, { step:
|
|
178
|
+
test('a draft whose step is missing or junk resets too — only 1-12 preserves anything', () => {
|
|
179
|
+
for (const draft of [{ step: 0 }, { step: 13 }, { name: 'half-typed' }]) {
|
|
180
180
|
const { calls } = runEnter({ focus: true, record: REC, draft });
|
|
181
181
|
assert.equal(calls.resets, 1, `step ${JSON.stringify(draft.step)} is not a wizard step`);
|
|
182
182
|
assert.deepEqual(calls.show, [[1, false]]);
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// tests/wizard_physics.mjs — planet physics, questions 1 to 4, in the create wizard
|
|
2
|
+
// (task 1004418 / BV2.PS07, goal 1000121).
|
|
3
|
+
//
|
|
4
|
+
// Done-when: the four questions and their branches work end to end (state incl. the
|
|
5
|
+
// existing-project branch and the stop screen; geography; ages with the guardian
|
|
6
|
+
// tiers; entity, intent when new and legal type when it exists), the planet states
|
|
7
|
+
// match the canvas, answers save through the create's `detail`, and the options the
|
|
8
|
+
// soft-limit table hides or greys show their reason (spec D6).
|
|
9
|
+
//
|
|
10
|
+
// Three layers, all RUN: the server's pre-create check over a mounted router; the
|
|
11
|
+
// page's vocabularies against planet-physics.js (a page cannot require a module, so
|
|
12
|
+
// its copies are held equal here); and the page's own physics controller sliced out
|
|
13
|
+
// of projects.html and driven in a vm under a DOM stub. The kit walk
|
|
14
|
+
// (projects.probes.json) is the rendered half.
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { test, after } from 'node:test';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import vm from 'node:vm';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
27
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
28
|
+
const physics = require('../modules/provisioning/planet-physics.js');
|
|
29
|
+
const { STOP_MESSAGE } = require('../modules/provisioning/screening.js');
|
|
30
|
+
|
|
31
|
+
// ── the server: POST /provisioning/physics-check ─────────────────────────────
|
|
32
|
+
const api = require('../src/module-api.js');
|
|
33
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
34
|
+
let writes = 0;
|
|
35
|
+
provisioning.createInstance = async () => { writes += 1; return {}; };
|
|
36
|
+
let builderId = 7;
|
|
37
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: builderId, github_login: 'owner' }; next(); };
|
|
38
|
+
const express = require('express');
|
|
39
|
+
const app = express();
|
|
40
|
+
app.use(express.json());
|
|
41
|
+
app.use((_req, res, next) => {
|
|
42
|
+
res.fail = (code, opts, details) => {
|
|
43
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
44
|
+
res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
|
|
45
|
+
};
|
|
46
|
+
next();
|
|
47
|
+
});
|
|
48
|
+
app.use('/api/bongos', require('../modules/provisioning/routes/provisioning.js')());
|
|
49
|
+
const server = app.listen(0);
|
|
50
|
+
after(() => server.close());
|
|
51
|
+
const check = async (body) => {
|
|
52
|
+
await new Promise((r) => (server.listening ? r() : server.once('listening', r)));
|
|
53
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/bongos/provisioning/physics-check`, {
|
|
54
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
55
|
+
});
|
|
56
|
+
return { status: res.status, body: await res.json() };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
test('the check answers the soft-limit table for the answers so far, and creates nothing', async () => {
|
|
60
|
+
const r = await check({ description: 'A co-op farming game.', detail: { project_state: 'new' } });
|
|
61
|
+
assert.equal(r.status, 200);
|
|
62
|
+
assert.equal(r.body.stop, false);
|
|
63
|
+
assert.equal(r.body.soft_limits.enforced, false, 'advice, and says so');
|
|
64
|
+
const hidden = r.body.soft_limits.limits.filter((l) => l.effect === 'hide').map((l) => l.option).sort();
|
|
65
|
+
assert.deepEqual(hidden, ['existing_work', 'main_work'], 'a new planet has nothing built to describe');
|
|
66
|
+
assert.equal(writes, 0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a description the screen stops answers the stop screen in the create\'s own words', async () => {
|
|
70
|
+
const r = await check({ description: 'We will build ransomware for sale.', detail: {} });
|
|
71
|
+
assert.equal(r.status, 200);
|
|
72
|
+
assert.equal(r.body.stop, true);
|
|
73
|
+
assert.equal(r.body.message, STOP_MESSAGE);
|
|
74
|
+
assert.equal(writes, 0, 'nothing exists to undo');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('an answer off the vocabulary, or a field the check does not read, is refused', async () => {
|
|
78
|
+
assert.equal((await check({ detail: { project_state: 'ancient' } })).status, 400);
|
|
79
|
+
assert.equal((await check({ detail: { home_country: 'US', home_region: 'CA-ON' } })).status, 400, 'a region outside its country');
|
|
80
|
+
assert.equal((await check({ slug: 'sneaky' })).status, 400, 'the body is validated strictly');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a builder who loops the check is slowed down, and another builder is not', async () => {
|
|
84
|
+
builderId = 901;
|
|
85
|
+
try {
|
|
86
|
+
for (let i = 0; i < 60; i += 1) assert.equal((await check({ detail: {} })).status, 200, `call ${i + 1} is within the minute`);
|
|
87
|
+
const over = await check({ detail: {} });
|
|
88
|
+
assert.equal(over.status, 429);
|
|
89
|
+
assert.equal(over.body.error, 'rate_limited');
|
|
90
|
+
builderId = 902;
|
|
91
|
+
assert.equal((await check({ detail: {} })).status, 200, 'the bucket is per builder');
|
|
92
|
+
} finally { builderId = 7; }
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── the page's vocabularies are the server's ─────────────────────────────────
|
|
96
|
+
const SCRIPT = HUB.slice(HUB.indexOf('/* ── planet physics, questions 1 to 4 (task 1004418, BV2.PS07) ──'),
|
|
97
|
+
HUB.indexOf(' /* per-panel validation on Next */'));
|
|
98
|
+
|
|
99
|
+
test('the page carries the server\'s countries, regions and entity forms, exactly', () => {
|
|
100
|
+
assert.ok(SCRIPT.length > 1000, 'the physics controller was found');
|
|
101
|
+
const box = {};
|
|
102
|
+
vm.createContext(box);
|
|
103
|
+
vm.runInContext(SCRIPT.slice(0, SCRIPT.indexOf(' var PHYS_LAST')).replace(/\bvar /g, 'this.'), box);
|
|
104
|
+
const plain = (v) => JSON.parse(JSON.stringify(v)); // values born in the vm
|
|
105
|
+
assert.deepEqual(plain(box.PHYS_COUNTRIES), [...physics.COUNTRY_CODES]);
|
|
106
|
+
assert.deepEqual(Object.keys(box.PHYS_REGIONS).sort(), [...physics.REGION_CODES].sort());
|
|
107
|
+
assert.deepEqual(plain(box.PHYS_ENTITY_FORMS), [...physics.ENTITY_FORMS]);
|
|
108
|
+
for (const k of Object.keys(box.PHYS_MATERIAL)) {
|
|
109
|
+
assert.notEqual(physics.canonicalAnswer(physics.PLANET_PHYSICS_QUESTIONS.entity, k), undefined, `${k} is an entity answer`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('every choice the questions offer is an answer the server accepts', () => {
|
|
114
|
+
const panel = HUB.slice(HUB.indexOf('id="panel-physics"'), HUB.indexOf('<!-- step 4 · name'));
|
|
115
|
+
const seen = [...panel.matchAll(/data-phys="([a-z_]+)" data-val="([^"]+)"/g)];
|
|
116
|
+
assert.ok(seen.length >= 20, 'the four questions\' choices');
|
|
117
|
+
for (const [, key, raw] of seen) {
|
|
118
|
+
const value = raw === 'true' ? true : raw === 'false' ? false : raw;
|
|
119
|
+
assert.notEqual(physics.canonicalAnswer(physics.PLANET_PHYSICS_QUESTIONS[key], value), undefined, `${key}=${raw}`);
|
|
120
|
+
}
|
|
121
|
+
for (const [, work] of panel.matchAll(/data-work="([^"]+)"/g)) {
|
|
122
|
+
assert.ok(physics.PLANET_PHYSICS_QUESTIONS.existing_work, 'existing_work is a question');
|
|
123
|
+
assert.notEqual(physics.canonicalAnswer(physics.PLANET_PHYSICS_QUESTIONS.existing_work, [work]), undefined, work);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('the age tiers are described, never claimed as enforced (spec D6)', () => {
|
|
128
|
+
const ages = HUB.slice(HUB.indexOf('id="physAges"'), HUB.indexOf('<div class="physQ" data-q="4"'));
|
|
129
|
+
assert.match(ages, /13 to 15<\/dt><dd>Non-monetary tracks only/);
|
|
130
|
+
assert.match(ages, /the platform does not check ages for you yet/);
|
|
131
|
+
assert.doesNotMatch(ages, /apply automatically|enforced/i);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── the controller, RUN ──────────────────────────────────────────────────────
|
|
135
|
+
class El {
|
|
136
|
+
constructor(id) {
|
|
137
|
+
this.id = id; this.hidden = false; this.disabled = false; this.value = ''; this.textContent = ''; this.className = '';
|
|
138
|
+
this.attrs = {}; this.listeners = {}; this.options = [{}]; this.children = [];
|
|
139
|
+
const self = this;
|
|
140
|
+
this.classList = { toggle(c, on) { self.attrs['class:' + c] = !!on; } };
|
|
141
|
+
}
|
|
142
|
+
setAttribute(k, v) { this.attrs[k] = String(v); }
|
|
143
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
|
144
|
+
removeAttribute(k) { delete this.attrs[k]; }
|
|
145
|
+
addEventListener(t, fn) { this.listeners[t] = fn; }
|
|
146
|
+
appendChild(c) { this.options.push(c); return c; }
|
|
147
|
+
removeChild() { this.options.pop(); }
|
|
148
|
+
get lastChild() { return this.options[this.options.length - 1]; }
|
|
149
|
+
focus() {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function boot({ checkAnswer = { stop: false, soft_limits: { limits: [] } } } = {}) {
|
|
153
|
+
const els = {};
|
|
154
|
+
const $ = (id) => (els[id] = els[id] || new El(id));
|
|
155
|
+
const shown = [];
|
|
156
|
+
const fetched = [];
|
|
157
|
+
const state = { description: '', teamShape: null, physics: null };
|
|
158
|
+
const sandbox = {
|
|
159
|
+
$, state, API: '/api/bongos', document: { createElement: () => new El('opt') },
|
|
160
|
+
root: { querySelectorAll: () => [], querySelector: () => null },
|
|
161
|
+
saveDraft() {}, show(n) { shown.push(n); }, Intl,
|
|
162
|
+
fetch: async (url, opts) => { fetched.push({ url, body: opts && opts.body && JSON.parse(opts.body) }); return { ok: true, json: async () => (url.includes('physics-check') ? checkAnswer : []) }; },
|
|
163
|
+
};
|
|
164
|
+
vm.createContext(sandbox);
|
|
165
|
+
vm.runInContext(SCRIPT, sandbox, { filename: 'projects.html#physics' });
|
|
166
|
+
return { sandbox, state, $, shown, fetched, run: (code) => vm.runInContext(code, sandbox) };
|
|
167
|
+
}
|
|
168
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
169
|
+
|
|
170
|
+
test('Continue walks the questions and moves the wizard on only after the last', async () => {
|
|
171
|
+
const w = boot();
|
|
172
|
+
const a = () => w.state.physics.answers;
|
|
173
|
+
assert.equal(w.run('physAdvance()'), false, 'the step never says yes itself');
|
|
174
|
+
assert.equal(w.state.physics.q, 1, 'from the start screen to question 1');
|
|
175
|
+
w.run('physAdvance()');
|
|
176
|
+
assert.equal(w.state.physics.q, 1, 'question 1 unanswered: it stays');
|
|
177
|
+
assert.match(w.$('h-phys').textContent, /new or existing/);
|
|
178
|
+
a().project_state = 'existing';
|
|
179
|
+
w.run('physAdvance()');
|
|
180
|
+
assert.match(w.$('h-phys').textContent, /what already exists/i, 'an existing planet must say what exists');
|
|
181
|
+
a().existing_work = ['code', 'digital-files']; a().main_work = 'code';
|
|
182
|
+
w.state.description = 'A co-op farming game.';
|
|
183
|
+
w.run('physAdvance()');
|
|
184
|
+
await tick(); await tick();
|
|
185
|
+
assert.equal(w.state.physics.q, 2, 'the screen passed it, on to geography');
|
|
186
|
+
assert.deepEqual(w.fetched.at(-1).body, { description: 'A co-op farming game.', detail: { project_state: 'existing', existing_work: ['code', 'digital-files'], main_work: 'code' } });
|
|
187
|
+
w.run('physAdvance()');
|
|
188
|
+
assert.equal(w.state.physics.q, 2, 'no country: it stays');
|
|
189
|
+
a().home_country = 'US'; a().home_region = 'US-CA'; a().builder_geography = 'us-eu';
|
|
190
|
+
w.run('physAdvance()');
|
|
191
|
+
assert.equal(w.state.physics.q, 3);
|
|
192
|
+
a().minors_on_board = true;
|
|
193
|
+
w.run('physAdvance()');
|
|
194
|
+
assert.equal(w.state.physics.q, 4);
|
|
195
|
+
w.run('physAdvance()');
|
|
196
|
+
assert.deepEqual(w.shown, [], 'no entity: still here');
|
|
197
|
+
a().entity = 'for-profit';
|
|
198
|
+
w.run('physAdvance()');
|
|
199
|
+
assert.deepEqual(w.shown, [4], 'and only now on to the name step');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a stopped description lands on the stop screen with the server\'s words', async () => {
|
|
203
|
+
const w = boot({ checkAnswer: { stop: true, message: STOP_MESSAGE, soft_limits: { limits: [] } } });
|
|
204
|
+
w.run('physAdvance()');
|
|
205
|
+
w.state.physics.answers.project_state = 'new';
|
|
206
|
+
w.state.description = 'ransomware';
|
|
207
|
+
w.run('physAdvance()');
|
|
208
|
+
await tick(); await tick();
|
|
209
|
+
assert.equal(w.state.physics.q, 'stop');
|
|
210
|
+
assert.equal(w.$('physStopMsg').textContent, STOP_MESSAGE);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('the create sends the answers as detail: existing work only for an existing planet, the team shape beside them', () => {
|
|
214
|
+
const w = boot();
|
|
215
|
+
w.run('phys()');
|
|
216
|
+
Object.assign(w.state.physics.answers, { project_state: 'new', existing_work: ['code'], main_work: 'code', home_country: 'NL', builder_geography: 'global', minors_on_board: false, entity: 'intent-solo' });
|
|
217
|
+
w.state.teamShape = 'solo';
|
|
218
|
+
assert.deepEqual(JSON.parse(JSON.stringify(w.run('createDetail()'))), {
|
|
219
|
+
project_state: 'new', home_country: 'NL', builder_geography: 'global', minors_on_board: false, entity: 'intent-solo', team_shape: 'solo',
|
|
220
|
+
});
|
|
221
|
+
w.state.physics.answers = {}; w.state.teamShape = null;
|
|
222
|
+
assert.equal(w.run('createDetail()'), undefined, 'nothing answered: the detail is omitted, as before');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('the planet forms from the answers: a seed, a formed planet, the protective ring, the core material', () => {
|
|
226
|
+
const w = boot();
|
|
227
|
+
w.run('phys()');
|
|
228
|
+
const src = () => w.$('physPlanet').getAttribute('src');
|
|
229
|
+
w.state.physics.q = 1; w.state.physics.answers.project_state = 'new';
|
|
230
|
+
w.run('physPaintStage()');
|
|
231
|
+
assert.equal(src(), '/assets/world/r2-chrome-blob-one-cut.webp', 'a seed');
|
|
232
|
+
w.state.physics.answers.project_state = 'existing';
|
|
233
|
+
w.run('physPaintStage()');
|
|
234
|
+
assert.equal(src(), '/assets/world/r2-chrome-blobs-set-cut.webp', 'terrain already formed');
|
|
235
|
+
assert.equal(w.$('physGuard').hidden, true);
|
|
236
|
+
w.state.physics.q = 3; w.state.physics.answers.minors_on_board = true;
|
|
237
|
+
w.run('physPaintStage()');
|
|
238
|
+
assert.equal(w.$('physGuard').hidden, false, 'the protective ring');
|
|
239
|
+
w.state.physics.q = 4;
|
|
240
|
+
for (const [entity, plate] of [['intent-solo', 'r3-frost-blob-cut'], ['for-profit', 'r3-gold-blob-cut'], ['nonprofit', 'r3-obsidian-blob-cut']]) {
|
|
241
|
+
w.state.physics.answers.entity = entity;
|
|
242
|
+
w.run('physPaintStage()');
|
|
243
|
+
assert.equal(src(), `/assets/world/${plate}.webp`, entity);
|
|
244
|
+
}
|
|
245
|
+
for (const plate of ['r3-frost-blob-cut', 'r3-gold-blob-cut', 'r3-obsidian-blob-cut', 'r2-chrome-blob-one-cut', 'r2-chrome-blobs-set-cut']) {
|
|
246
|
+
assert.ok(fs.existsSync(path.join(LANDING, 'assets', 'world', `${plate}.webp`)), `${plate} is a world plate`);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('a limit the table puts on an option shows its reason beside it, and a grey one is disabled', () => {
|
|
251
|
+
const w = boot();
|
|
252
|
+
const why = new El('why');
|
|
253
|
+
const btn = new El('btn');
|
|
254
|
+
w.sandbox.root.querySelector = (sel) => (sel.includes('data-why="existing_work"') ? why : null);
|
|
255
|
+
w.sandbox.root.querySelectorAll = (sel) => (sel.includes('data-phys="entity"][data-val="intent-solo"') ? [btn] : []);
|
|
256
|
+
w.run(`physLimits = [
|
|
257
|
+
{ option: 'existing_work', effect: 'hide', reason: 'Your planet is new, so there is nothing built yet to describe.' },
|
|
258
|
+
{ option: 'entity:intent-solo', effect: 'grey', reason: 'Because.' }
|
|
259
|
+
]; physApplyLimits();`);
|
|
260
|
+
assert.equal(why.hidden, false);
|
|
261
|
+
assert.match(why.textContent, /nothing built yet/);
|
|
262
|
+
assert.equal(btn.disabled, true);
|
|
263
|
+
assert.equal(btn.title, 'Because.');
|
|
264
|
+
});
|