@bongos/core 1.20.74 → 1.20.76
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 +68 -48
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +79 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +482 -409
- package/docs/copy-registry.json +1230 -560
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +9 -1
- package/docs/page-readings.json +1189 -765
- package/modules/hall-ui/public/profile-roles.js +170 -0
- package/modules/hall-ui/public/profile.css +42 -5
- package/modules/hall-ui/public/profile.html +12 -5
- package/modules/hall-ui/public/profile.js +66 -27
- package/modules/hall-ui/public/profile.states.json +39 -1
- package/modules/hall-ui/records/profile-roles.md +32 -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/module-api.js +1 -1
- package/tests/founding_builders.mjs +2 -1
- package/tests/helpers.mjs +4 -0
- package/tests/profile_role_headline.mjs +201 -0
- package/tests/profile_ui_cross_project.mjs +3 -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
|
@@ -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
|
+
});
|