@bongos/core 1.20.76 → 1.20.77
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 +30 -25
- package/docs/copy-inventory.md +512 -447
- package/docs/copy-registry.json +1144 -548
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +7 -1
- package/docs/page-readings.json +1059 -946
- package/modules/provisioning/planet-physics.js +8 -0
- package/modules/provisioning/soft-limits.js +9 -0
- package/modules/public-landing/public/projects.html +579 -37
- package/modules/public-landing/public/projects.probes.json +6 -3
- package/modules/public-landing/public/projects.states.json +20 -14
- package/modules/ui-design/kit/serve.js +26 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/projects_hub_app_step.mjs +4 -1
- package/tests/provisioning_planet_physics.mjs +24 -2
- package/tests/wizard_physics.mjs +11 -4
- package/tests/wizard_physics_more.mjs +470 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// tests/wizard_physics_more.mjs — planet physics, questions 5 to 7, the summary, the
|
|
2
|
+
// voice split and the pre-filled technical steps (task 1004419 / BV2.PS11, goal 1000121).
|
|
3
|
+
//
|
|
4
|
+
// Done-when: a new owner walks from question 1 to "create my planet" with the technical
|
|
5
|
+
// steps pre-filled. So the walk below is RUN end to end — the page's own physics
|
|
6
|
+
// controller sliced out of projects.html into a vm under a DOM stub (the
|
|
7
|
+
// tests/wizard_physics.mjs idiom), its pre-create check answered by the REAL soft-limit
|
|
8
|
+
// table (modules/provisioning/soft-limits.js), never a hand-written verdict. The rest
|
|
9
|
+
// pins each rule on its own: rewards hidden when solo, the framing's suite and its
|
|
10
|
+
// reasons, the voice chosen from the entity (spec D5), the summary's change links, what
|
|
11
|
+
// the create sends, which technical step is skipped, and the forming planet.
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import vm from 'node:vm';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
24
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
25
|
+
const physics = require('../modules/provisioning/planet-physics.js');
|
|
26
|
+
const { SOFT_LIMIT_RULES, softLimitsFor } = require('../modules/provisioning/soft-limits.js');
|
|
27
|
+
|
|
28
|
+
const START = '/* ── planet physics, questions 1 to 7 (tasks 1004418 and 1004419, BV2.PS07 + PS11) ──';
|
|
29
|
+
const SCRIPT = HUB.slice(HUB.indexOf(START), HUB.indexOf(' /* per-panel validation on Next */'));
|
|
30
|
+
const PANEL = HUB.slice(HUB.indexOf('<section class="panel" id="panel-physics"'), HUB.indexOf('<!-- step 4 · name'));
|
|
31
|
+
const plain = (v) => JSON.parse(JSON.stringify(v)); // values born in the vm
|
|
32
|
+
|
|
33
|
+
// ── a small DOM: the panel's own elements, matched by attribute ──────────────
|
|
34
|
+
class El {
|
|
35
|
+
constructor(tag, attrs = {}) {
|
|
36
|
+
this.tagName = tag; this.attrs = { ...attrs }; this.hidden = 'hidden' in attrs; this.disabled = false;
|
|
37
|
+
this.value = attrs.value || ''; this.textContent = ''; this.title = ''; this.className = attrs.class || '';
|
|
38
|
+
this.listeners = {}; this.children = []; this.options = [{}];
|
|
39
|
+
const self = this;
|
|
40
|
+
this.classes = new Set(this.className.split(/\s+/).filter(Boolean));
|
|
41
|
+
this.classList = { toggle(c, on) { if (on) self.classes.add(c); else self.classes.delete(c); }, contains: (c) => self.classes.has(c), add: (c) => self.classes.add(c) };
|
|
42
|
+
}
|
|
43
|
+
setAttribute(k, v) { this.attrs[k] = String(v); }
|
|
44
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
|
45
|
+
removeAttribute(k) { delete this.attrs[k]; }
|
|
46
|
+
addEventListener(t, fn) { this.listeners[t] = fn; }
|
|
47
|
+
click() { this.listeners.click && this.listeners.click({ target: this }); }
|
|
48
|
+
appendChild(c) { this.children.push(c); if (this.tagName === 'select') this.options.push(c); return c; }
|
|
49
|
+
removeChild(c) { const i = this.children.indexOf(c); if (i !== -1) this.children.splice(i, 1); if (this.tagName === 'select') this.options.pop(); }
|
|
50
|
+
get firstChild() { return this.children[0] || null; }
|
|
51
|
+
get lastChild() { return this.tagName === 'select' ? this.options[this.options.length - 1] : this.children[this.children.length - 1]; }
|
|
52
|
+
focus() {}
|
|
53
|
+
text() { return this.textContent + this.children.map((c) => c.text()).join(''); }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseAttrs(s) {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const m of s.matchAll(/([a-zA-Z-]+)(?:="([^"]*)")?/g)) out[m[1]] = m[2] === undefined ? '' : m[2];
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function buildDom() {
|
|
62
|
+
const all = [];
|
|
63
|
+
const byId = {};
|
|
64
|
+
for (const m of PANEL.matchAll(/<(button|div|p|ul|fieldset|span|li|img|input|select|output|dd|legend|h1|ol|dl|form|label)\b([^>]*)>/g)) {
|
|
65
|
+
const el = new El(m[1], parseAttrs(m[2]));
|
|
66
|
+
all.push(el);
|
|
67
|
+
if (el.attrs.id) byId[el.attrs.id] = el;
|
|
68
|
+
}
|
|
69
|
+
// the stage's moons and ghosts are the children of their wrappers, in markup order
|
|
70
|
+
const kids = (id) => {
|
|
71
|
+
const at = PANEL.indexOf(`id="${id}"`);
|
|
72
|
+
const chunk = PANEL.slice(at, PANEL.indexOf('</span>', at));
|
|
73
|
+
const n = (chunk.match(/<img /g) || []).length;
|
|
74
|
+
return all.filter((e) => e.tagName === 'img' && e.attrs.src && e.attrs.src.includes('bead')).slice(id === 'physGhosts' ? 0 : 4, id === 'physGhosts' ? n : 4 + n);
|
|
75
|
+
};
|
|
76
|
+
const lists = { '#physMoons img': kids('physMoons'), '#physGhosts img': kids('physGhosts') };
|
|
77
|
+
lists['#physMoons img, #physGhosts img'] = [...lists['#physMoons img'], ...lists['#physGhosts img']];
|
|
78
|
+
const matches = (el, compound) => {
|
|
79
|
+
if (compound.startsWith('.')) return el.classes.has(compound.slice(1));
|
|
80
|
+
for (const m of compound.matchAll(/\[([a-z-]+)(?:="([^"]*)")?\]/g)) {
|
|
81
|
+
if (!(m[1] in el.attrs)) return false;
|
|
82
|
+
if (m[2] !== undefined && el.attrs[m[1]] !== m[2]) return false;
|
|
83
|
+
}
|
|
84
|
+
return /\[/.test(compound);
|
|
85
|
+
};
|
|
86
|
+
const root = {
|
|
87
|
+
querySelectorAll(sel) {
|
|
88
|
+
if (lists[sel]) return lists[sel];
|
|
89
|
+
if (sel === '#physRail li') return all.filter((e) => e.tagName === 'li' && 'data-q' in e.attrs);
|
|
90
|
+
if (sel === '#physStage .physBelt') return all.filter((e) => e.classes.has('physBelt'));
|
|
91
|
+
if (sel === '#panel-physics .physQ') return all.filter((e) => e.classes.has('physQ'));
|
|
92
|
+
const last = sel.trim().split(/\s+/).pop();
|
|
93
|
+
return all.filter((e) => matches(e, last));
|
|
94
|
+
},
|
|
95
|
+
querySelector(sel) { return this.querySelectorAll(sel)[0] || null; },
|
|
96
|
+
};
|
|
97
|
+
return { all, byId, root };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// the pre-create check, answered by the real table
|
|
101
|
+
function boot({ check = (body) => ({ stop: false, soft_limits: softLimitsFor(body.detail) }), draft = {} } = {}) {
|
|
102
|
+
const dom = buildDom();
|
|
103
|
+
const els = { ...dom.byId };
|
|
104
|
+
const $ = (id) => (els[id] = els[id] || new El('div', { id }));
|
|
105
|
+
const shown = [];
|
|
106
|
+
const fetched = [];
|
|
107
|
+
const state = Object.assign({ description: '', teamShape: null, physics: null, projType: null, mode: 'greenfield', appPlatform: 'later', demo: null }, draft);
|
|
108
|
+
const sandbox = {
|
|
109
|
+
$, state, API: '/api/bongos', root: dom.root, Intl,
|
|
110
|
+
document: { createElement: (t) => new El(t) },
|
|
111
|
+
saveDraft() {}, show(n) { shown.push(n); },
|
|
112
|
+
fetch: async (url, opts) => {
|
|
113
|
+
const body = opts && opts.body ? JSON.parse(opts.body) : null;
|
|
114
|
+
fetched.push({ url, body });
|
|
115
|
+
return { ok: true, json: async () => (url.includes('physics-check') ? check(body) : []) };
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
vm.createContext(sandbox);
|
|
119
|
+
vm.runInContext(SCRIPT, sandbox, { filename: 'projects.html#physics' });
|
|
120
|
+
const q = (sel) => dom.root.querySelectorAll(sel);
|
|
121
|
+
return {
|
|
122
|
+
state, $, shown, fetched, q, dom,
|
|
123
|
+
run: (code) => vm.runInContext(code, sandbox),
|
|
124
|
+
a: () => state.physics.answers,
|
|
125
|
+
pick: (key, val) => q(`[data-phys="${key}"][data-val="${val}"]`)[0].click(),
|
|
126
|
+
next: async () => { sandbox.$('physNext'); vm.runInContext('physAdvance()', sandbox); await tick(); await tick(); },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
130
|
+
// questions 1 to 4, answered (the Kestrel sample of the canvas, a new planet)
|
|
131
|
+
const FOUR = { project_state: 'new', home_country: 'US', home_region: 'US-CA', builder_geography: 'us-eu', minors_on_board: false, entity: 'intent-solo' };
|
|
132
|
+
function at(q, answers = FOUR, opts = {}) {
|
|
133
|
+
const w = boot(opts);
|
|
134
|
+
w.run('phys()');
|
|
135
|
+
Object.assign(w.state.physics.answers, answers);
|
|
136
|
+
w.run(`physGo(${JSON.stringify(q)})`);
|
|
137
|
+
return w;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── the copies are the server's ──────────────────────────────────────────────
|
|
141
|
+
test('the page carries the server\'s rewards, framings and services, in order, and says every one', () => {
|
|
142
|
+
const box = {};
|
|
143
|
+
vm.createContext(box);
|
|
144
|
+
vm.runInContext(SCRIPT.slice(0, SCRIPT.indexOf(' var PHYS_LAST')).replace(/\bvar /g, 'this.'), box);
|
|
145
|
+
const Q = physics.PLANET_PHYSICS_QUESTIONS;
|
|
146
|
+
assert.deepEqual(plain(box.PHYS_REWARDS), [...Q.reward_intents.values]);
|
|
147
|
+
assert.deepEqual(plain(box.PHYS_FRAMINGS), [...Q.framing.values]);
|
|
148
|
+
assert.deepEqual(plain(box.PHYS_SERVICES), [...Q.services.values]);
|
|
149
|
+
assert.deepEqual(Object.keys(box.PHYS_ORBIT), [...Q.framing.values]);
|
|
150
|
+
for (const key of ['project_state', 'builder_geography', 'entity', 'reward_intents', 'framing', 'services']) {
|
|
151
|
+
assert.deepEqual(Object.keys(box.PHYS_SAY[key]).sort(), [...Q[key].values].sort(), `the summary can say every ${key}`);
|
|
152
|
+
}
|
|
153
|
+
for (const org of box.PHYS_ORGS) assert.ok(physics.ENTITY_FORMS.includes(org), `${org} is a formed entity`);
|
|
154
|
+
for (const [f, list] of Object.entries(box.PHYS_SUITE_START)) {
|
|
155
|
+
assert.ok(['hybrid', 'local'].includes(f));
|
|
156
|
+
assert.notEqual(physics.canonicalAnswer(Q.services, plain(list)), undefined, `${f}'s starting suite is a services answer`);
|
|
157
|
+
}
|
|
158
|
+
const TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure'];
|
|
159
|
+
for (const [entity, type] of Object.entries(box.PHYS_TYPE)) {
|
|
160
|
+
assert.ok(Q.entity.values.includes(entity)); assert.ok(TYPES.includes(type), `${type} is a project type`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('the kit stub\'s physics hide rules are the table\'s own, word for word', () => {
|
|
165
|
+
const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
|
|
166
|
+
const block = stub.slice(stub.indexOf('const STUB_PHYSICS_HIDES'), stub.indexOf('function readBody'));
|
|
167
|
+
const ids = [...block.matchAll(/id: '([a-z-]+)'/g)].map((m) => m[1]);
|
|
168
|
+
assert.equal(ids.length, 3);
|
|
169
|
+
for (const id of ids) {
|
|
170
|
+
const rule = SOFT_LIMIT_RULES.find((r) => r.id === id);
|
|
171
|
+
assert.ok(rule, `${id} is a real rule`);
|
|
172
|
+
assert.equal(rule.effect, 'hide');
|
|
173
|
+
assert.ok(block.includes(JSON.stringify(rule.reason).slice(1, -1)) || block.includes(rule.reason), `${id}'s reason is copied exactly`);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── question 5 ───────────────────────────────────────────────────────────────
|
|
178
|
+
test('question 5: one or many, a start count, and a guessed growth the founder corrects', async () => {
|
|
179
|
+
const w = at(5);
|
|
180
|
+
await w.next();
|
|
181
|
+
assert.equal(w.state.physics.q, 5, 'unanswered: it stays');
|
|
182
|
+
assert.match(w.$('h-phys').textContent, /one or many/);
|
|
183
|
+
w.q('[data-phys-solo="many"]')[0].click();
|
|
184
|
+
assert.equal(w.a().builders_at_start, 2);
|
|
185
|
+
assert.equal(w.a().builders_expected, 5, 'the guess: about 2.6 times the start');
|
|
186
|
+
assert.equal(w.$('physTeam').hidden, false);
|
|
187
|
+
w.q('[data-phys-count="1"]')[0].click();
|
|
188
|
+
assert.equal(w.a().builders_at_start, 3);
|
|
189
|
+
assert.equal(w.a().builders_expected, 8, 'the guess follows the start');
|
|
190
|
+
assert.deepEqual([w.$('physM1').textContent, w.$('physM2').textContent, w.$('physM3').textContent], ['3', '5', '8'], 'the canvas\'s three months');
|
|
191
|
+
assert.match(w.$('physGuessLine').textContent, /about 8 builders by month three/);
|
|
192
|
+
w.$('physExpected').value = '12';
|
|
193
|
+
w.$('physExpected').listeners.input();
|
|
194
|
+
assert.equal(w.a().builders_expected, 12, 'the founder corrects it');
|
|
195
|
+
w.q('[data-phys-count="1"]')[0].click();
|
|
196
|
+
assert.equal(w.a().builders_expected, 12, 'and once corrected the guess stays theirs');
|
|
197
|
+
assert.equal(w.q('[data-phys-solo="many"]')[0].getAttribute('aria-pressed'), 'true');
|
|
198
|
+
await w.next();
|
|
199
|
+
assert.equal(w.state.physics.q, 6, 'a team goes on to rewards');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('rewards are hidden when solo: the table says so, the walk skips question 6, the create sends none', async () => {
|
|
203
|
+
const w = at(5, { ...FOUR, reward_intents: ['cash'] });
|
|
204
|
+
w.q('[data-phys-solo="one"]')[0].click();
|
|
205
|
+
assert.equal(w.a().builders_at_start, 1);
|
|
206
|
+
assert.equal(w.a().builders_expected, 1);
|
|
207
|
+
assert.equal(w.$('physTeam').hidden, true);
|
|
208
|
+
await w.next();
|
|
209
|
+
assert.equal(w.state.physics.q, 7, 'straight to framing');
|
|
210
|
+
assert.ok(w.fetched.at(-1).body.detail.builders_at_start === 1, 'the check was asked with the answer');
|
|
211
|
+
const rail6 = w.q('#physRail li').find((li) => li.attrs['data-q'] === '6');
|
|
212
|
+
assert.ok(rail6.classList.contains('skipped'), 'the rail strikes it');
|
|
213
|
+
assert.equal(w.run('createDetail()').reward_intents, undefined, 'nothing answered for a question never asked');
|
|
214
|
+
w.$('physBack').listeners.click();
|
|
215
|
+
assert.equal(w.state.physics.q, 5, 'Back steps over it too');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('rewards: a greyed option is disabled and leaves the list, and each reason is said once', async () => {
|
|
219
|
+
const w = at(6, { ...FOUR, minors_on_board: true, builders_at_start: 3, builders_expected: 8 });
|
|
220
|
+
await w.run('physCheck()').then((res) => { w.run(`physLimits = ${JSON.stringify(res.soft_limits.limits)}`); });
|
|
221
|
+
w.pick('reward_intents', 'non-monetary');
|
|
222
|
+
w.pick('reward_intents', 'future-payouts');
|
|
223
|
+
assert.deepEqual(plain(w.a().reward_intents), ['non-monetary', 'future-payouts']);
|
|
224
|
+
const equity = w.q('[data-phys="reward_intents"][data-val="equity"]')[0];
|
|
225
|
+
assert.equal(equity.disabled, true, 'equity is off until you form a company');
|
|
226
|
+
assert.match(equity.title, /until you form a company/);
|
|
227
|
+
const list = w.q('[data-why-list="reward_intents"]')[0];
|
|
228
|
+
assert.equal(list.hidden, false);
|
|
229
|
+
const lines = list.children.map((li) => li.textContent);
|
|
230
|
+
assert.ok(lines.some((l) => /^Equity or shares: Equity is off until you form a company/.test(l)), lines.join('\n'));
|
|
231
|
+
assert.equal(lines.filter((l) => /13 to 15/.test(l)).length, 1, 'the minors reason names its three options in one line');
|
|
232
|
+
assert.ok(lines.some((l) => /^Cash for work, Equity or shares, Future profits or payouts: Builders aged 13 to 15/.test(l)));
|
|
233
|
+
assert.ok(lines.some((l) => /^Cash for work: Cash payouts to builders in the EU/.test(l)), 'a note is said too');
|
|
234
|
+
w.pick('reward_intents', 'future-payouts');
|
|
235
|
+
assert.deepEqual(plain(w.a().reward_intents), ['non-monetary'], 'a second click takes one away');
|
|
236
|
+
await w.next();
|
|
237
|
+
assert.equal(w.state.physics.q, 7);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ── question 7 ───────────────────────────────────────────────────────────────
|
|
241
|
+
test('framing: hybrid and local open the suite pre-picked; managed and open source hide it with the table\'s reason', async () => {
|
|
242
|
+
const w = at(7, { ...FOUR, builders_at_start: 3, builders_expected: 8, reward_intents: ['non-monetary'] });
|
|
243
|
+
await w.next();
|
|
244
|
+
assert.equal(w.state.physics.q, 7, 'no framing: it stays');
|
|
245
|
+
w.pick('framing', 'hybrid');
|
|
246
|
+
await tick(); await tick();
|
|
247
|
+
assert.deepEqual(plain(w.a().services), ['hosting', 'starter-kit', 'marketplace-buy', 'guild-access'], 'the canvas\'s hybrid suite');
|
|
248
|
+
assert.equal(w.$('physSuite').hidden, false);
|
|
249
|
+
assert.equal(w.$('physSuiteLbl').textContent, 'Your hybrid suite');
|
|
250
|
+
const labor = w.q('[data-phys="services"][data-val="labor-payment"]')[0];
|
|
251
|
+
assert.equal(labor.disabled, true, 'no cash for work, no pay to run');
|
|
252
|
+
assert.equal(w.q('[data-phys="services"][data-val="public-marketing"]')[0].disabled, true, 'on request');
|
|
253
|
+
w.pick('services', 'marketplace-sell');
|
|
254
|
+
assert.ok(w.a().services.includes('marketplace-sell'));
|
|
255
|
+
w.pick('framing', 'local');
|
|
256
|
+
await tick(); await tick();
|
|
257
|
+
assert.ok(w.a().services.includes('marketplace-sell'), 'a suite the founder touched is theirs: not re-seeded');
|
|
258
|
+
w.pick('framing', 'managed');
|
|
259
|
+
await tick(); await tick();
|
|
260
|
+
assert.equal(w.$('physSuite').hidden, true);
|
|
261
|
+
const why = w.q('[data-why="services"]')[0];
|
|
262
|
+
assert.equal(why.hidden, false);
|
|
263
|
+
assert.equal(why.textContent, SOFT_LIMIT_RULES.find((r) => r.id === 'managed-includes-every-service').reason);
|
|
264
|
+
assert.equal(w.run('createDetail()').services, undefined, 'all managed sends no suite');
|
|
265
|
+
assert.equal(w.$('physStage').getAttribute('data-orbit'), 'near');
|
|
266
|
+
await w.next();
|
|
267
|
+
assert.equal(w.state.physics.q, 'sum');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('a company already formed hides company creation from the suite', async () => {
|
|
271
|
+
const w = at(7, { ...FOUR, entity: 'for-profit', builders_at_start: 3, builders_expected: 8, reward_intents: ['equity'] });
|
|
272
|
+
w.pick('framing', 'hybrid');
|
|
273
|
+
await tick(); await tick();
|
|
274
|
+
assert.equal(w.q('[data-phys="services"][data-val="company-creation"]')[0].hidden, true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ── the voice (spec D5) ──────────────────────────────────────────────────────
|
|
278
|
+
test('the voice is chosen from the entity: professional for an organization, the planet\'s for everyone else', () => {
|
|
279
|
+
for (const [entity, voice] of [['for-profit', 'professional'], ['nonprofit', 'professional'], ['research', 'professional'], ['corporate-division', 'professional'],
|
|
280
|
+
['intent-company', 'planet'], ['intent-solo', 'planet'], ['social-group', 'planet'], ['university-project', 'planet'], ['private-individual', 'planet'], ['single-owner-llc', 'planet']]) {
|
|
281
|
+
const w = at(5, { ...FOUR, entity });
|
|
282
|
+
assert.equal(w.run('physVoice()'), voice, entity);
|
|
283
|
+
assert.equal(w.run('createDetail()').voice, voice, `${entity} stores the voice it reads`);
|
|
284
|
+
const pro = w.q('[data-voice="pro"]'), planet = w.q('[data-voice="planet"]');
|
|
285
|
+
assert.ok(pro.length >= 10 && planet.length === pro.length, 'both variants are baked, pair by pair');
|
|
286
|
+
assert.ok(pro.every((e) => e.hidden === (voice !== 'professional')), `${entity}: the professional copy shows only for an organization`);
|
|
287
|
+
assert.ok(planet.every((e) => e.hidden === (voice === 'professional')), `${entity}: the planet copy otherwise`);
|
|
288
|
+
}
|
|
289
|
+
const pro = at(5, { ...FOUR, entity: 'for-profit', builders_at_start: 12, builders_expected: 30 });
|
|
290
|
+
assert.match(pro.$('physGuessLine').textContent, /we estimate 30 contributors by month three/);
|
|
291
|
+
assert.equal(pro.$('physCapLbl').textContent, 'project profile');
|
|
292
|
+
assert.match(pro.$('physCoords').textContent, /team 12 → 30/);
|
|
293
|
+
const planet = at(5, { ...FOUR, builders_at_start: 3, builders_expected: 8 });
|
|
294
|
+
assert.match(planet.$('physCallout').textContent, /moons · 3 builders now · the dashed orbit holds about 8/);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('the voice variants are worded apart: the professional copy carries no planet words', () => {
|
|
298
|
+
const pro = [...PANEL.matchAll(/<span data-voice="pro" hidden>([^<]*)/g)].map((m) => m[1]);
|
|
299
|
+
assert.ok(pro.length >= 10);
|
|
300
|
+
for (const t of pro) assert.doesNotMatch(t, /planet|moon|orbit/i, t);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ── the summary ──────────────────────────────────────────────────────────────
|
|
304
|
+
const SEVEN = { ...FOUR, builders_at_start: 3, builders_expected: 8, reward_intents: ['non-monetary', 'future-payouts'], framing: 'hybrid', services: ['hosting', 'starter-kit'] };
|
|
305
|
+
test('the summary lists every answer with a change link back to its question', async () => {
|
|
306
|
+
const w = at('sum', SEVEN);
|
|
307
|
+
w.state.description = 'A field guide app for birdwatchers';
|
|
308
|
+
w.run('paintPhysics()');
|
|
309
|
+
const dl = w.$('physSummary');
|
|
310
|
+
const dts = dl.children.filter((c) => c.tagName === 'dt').map((c) => c.textContent);
|
|
311
|
+
assert.deepEqual(dts, ['State', 'Geography', 'Under 18', 'You are', 'Builders', 'Rewards', 'Framing']);
|
|
312
|
+
const dds = dl.children.filter((c) => c.tagName === 'dd');
|
|
313
|
+
const said = dds.map((dd) => dd.children[0].text());
|
|
314
|
+
assert.match(said[0], /^NewA field guide app for birdwatchers$/);
|
|
315
|
+
assert.match(said[1], /^United States \(California\)Builders in the US and EU$/);
|
|
316
|
+
assert.match(said[4], /^3 nowAbout 8 by month three$/);
|
|
317
|
+
assert.match(said[5], /^Non-monetary, Future profits or payoutsIntent only$/);
|
|
318
|
+
assert.match(said[6], /^HybridHosting, Starting module kit$/);
|
|
319
|
+
const gotos = dds.map((dd) => dd.children[1]);
|
|
320
|
+
assert.deepEqual(gotos.map((b) => b.getAttribute('data-phys-goto')), ['1', '2', '3', '4', '5', '6', '7']);
|
|
321
|
+
assert.ok(gotos.every((b) => b.textContent === 'change' && b.type === 'button'));
|
|
322
|
+
assert.equal(w.$('physNext').textContent, 'Create my planet →');
|
|
323
|
+
assert.ok(w.q('#physRail li').every((li) => li.classList.contains('done')), 'the rail reads complete');
|
|
324
|
+
gotos[4].listeners.click();
|
|
325
|
+
assert.equal(w.state.physics.q, 5, 'change goes back to that question');
|
|
326
|
+
await w.next();
|
|
327
|
+
assert.equal(w.state.physics.q, 'sum', 'and Continue returns to the summary, the rest already answered');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('the summary\'s Create my planet pre-fills the technical steps and moves on to the name', async () => {
|
|
331
|
+
const w = at('sum', { ...SEVEN, project_state: 'existing', existing_work: ['code'], main_work: 'code', entity: 'for-profit' });
|
|
332
|
+
w.run('paintPhysics()');
|
|
333
|
+
assert.equal(w.$('physNext').textContent, 'Continue to setup →', 'the professional voice');
|
|
334
|
+
assert.equal(w.run('physAdvance()'), false);
|
|
335
|
+
assert.deepEqual(w.shown, [4], 'on to the name step');
|
|
336
|
+
assert.equal(w.state.projType, 'business', 'a for-profit company is a business on the map');
|
|
337
|
+
assert.equal(w.state.mode, 'adopt', 'a planet that already has code adopts its repo');
|
|
338
|
+
assert.equal(w.state.teamShape, null, 'a team of three is not guessed into a shape');
|
|
339
|
+
w.run('physPaintPrefillNotes()');
|
|
340
|
+
assert.match(w.$('typePrefill').textContent, /Picked from your answer “For-profit company”/);
|
|
341
|
+
assert.match(w.$('homePrefill').textContent, /Adopt is picked because your project already has code/);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ── pre-fill and skip, rule by rule ──────────────────────────────────────────
|
|
345
|
+
test('a pre-fill never overwrites what the founder chose, and follows a changed answer', () => {
|
|
346
|
+
const w = at('sum', { ...SEVEN, builders_at_start: 1, builders_expected: 1, entity: 'nonprofit' });
|
|
347
|
+
w.run('physPrefill()');
|
|
348
|
+
assert.equal(w.state.projType, 'non-profit');
|
|
349
|
+
assert.equal(w.state.teamShape, 'solo', 'one builder says solo');
|
|
350
|
+
assert.equal(w.state.mode, 'greenfield');
|
|
351
|
+
w.state.projType = 'game'; // the founder picks for themselves on the Type step
|
|
352
|
+
w.state.physics.answers.entity = 'research';
|
|
353
|
+
w.run('physPrefill()');
|
|
354
|
+
assert.equal(w.state.projType, 'game', 'their choice stands');
|
|
355
|
+
w.state.projType = 'non-profit'; // back to what we filled …
|
|
356
|
+
w.state.prefilled = { projType: 'non-profit' };
|
|
357
|
+
w.run('physPrefill()');
|
|
358
|
+
assert.equal(w.state.projType, 'research', '… and an untouched pre-fill follows the answer');
|
|
359
|
+
w.state.physics.answers.entity = 'intent-unsure';
|
|
360
|
+
w.run('physPrefill()');
|
|
361
|
+
assert.equal(w.state.projType, null, 'an answer that suggests nothing clears what we filled');
|
|
362
|
+
w.run('physPaintPrefillNotes()');
|
|
363
|
+
assert.equal(w.$('typePrefill').hidden, true);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('a planet on its own machines skips the app step; Next and Back step over it, and a demo never does', () => {
|
|
367
|
+
for (const [framing, skipped] of [['managed', false], ['hybrid', false], ['local', true], ['open-source', true]]) {
|
|
368
|
+
const w = at('sum', { ...SEVEN, framing });
|
|
369
|
+
assert.equal(w.run('physStepSkipped(10)'), skipped, framing);
|
|
370
|
+
assert.equal(w.run('physPastSkips(10, 1)'), skipped ? 11 : 10);
|
|
371
|
+
assert.equal(w.run('physPastSkips(10, -1)'), skipped ? 9 : 10);
|
|
372
|
+
for (const n of [4, 5, 6, 7, 8, 9, 11, 12]) assert.equal(w.run(`physStepSkipped(${n})`), false, `step ${n} always applies`);
|
|
373
|
+
}
|
|
374
|
+
const w = at('sum', { ...SEVEN, framing: 'open-source' }, { draft: { appPlatform: 'render' } });
|
|
375
|
+
w.run('physPrefill()');
|
|
376
|
+
assert.equal(w.state.appPlatform, 'later', 'a skipped step keeps its default answer');
|
|
377
|
+
w.state.demo = { people: 3, hours_each: 4 };
|
|
378
|
+
assert.equal(w.run('physStepSkipped(10)'), false, 'a demo is all managed');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// ── what the create sends ────────────────────────────────────────────────────
|
|
382
|
+
test('the create sends questions 5 to 7 and the voice as detail, in the vocabulary\'s order', () => {
|
|
383
|
+
const w = at('sum', { ...SEVEN, reward_intents: ['future-payouts', 'non-monetary'], services: ['starter-kit', 'hosting'] });
|
|
384
|
+
w.state.teamShape = 'small-team';
|
|
385
|
+
const d = plain(w.run('createDetail()'));
|
|
386
|
+
assert.deepEqual(d, {
|
|
387
|
+
project_state: 'new', home_country: 'US', home_region: 'US-CA', builder_geography: 'us-eu', minors_on_board: false, entity: 'intent-solo',
|
|
388
|
+
builders_at_start: 3, builders_expected: 8, framing: 'hybrid',
|
|
389
|
+
reward_intents: ['non-monetary', 'future-payouts'], services: ['hosting', 'starter-kit'], voice: 'planet', team_shape: 'small-team',
|
|
390
|
+
});
|
|
391
|
+
const { badProjectDetail } = require('../modules/provisioning/routes/body-validators.js');
|
|
392
|
+
assert.equal(badProjectDetail(d), null, 'every key and value is one the create route accepts');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ── the planet ───────────────────────────────────────────────────────────────
|
|
396
|
+
test('the planet gains moons, rings and its orbit around the platform\'s mark', () => {
|
|
397
|
+
const w = at(7, { ...SEVEN, entity: 'for-profit', builders_at_start: 4, builders_expected: 6, reward_intents: ['non-monetary', 'cash', 'future-payouts'], framing: 'local' });
|
|
398
|
+
const shown = (sel) => w.q(sel).filter((e) => !e.hidden).length;
|
|
399
|
+
assert.equal(shown('#physMoons img'), 4, 'a moon per builder now');
|
|
400
|
+
assert.equal(shown('#physGhosts img'), 2, 'faint ones for the expected growth');
|
|
401
|
+
assert.ok(w.q('#physMoons img').every((m) => m.getAttribute('src') === '/assets/world/r3-gold-bead-cut.webp'), 'beads of the core\'s material');
|
|
402
|
+
assert.equal(shown('#physStage .physBelt'), 3, 'a ring per reward being considered');
|
|
403
|
+
assert.equal(w.$('physStage').getAttribute('data-orbit'), 'far', 'local is a far orbit');
|
|
404
|
+
assert.equal(w.$('physSun').hidden, false);
|
|
405
|
+
assert.equal(w.$('physSun').getAttribute('src'), '/assets/world/r2-bongos-chrome-a-cut.webp', 'the platform\'s own mark');
|
|
406
|
+
w.state.physics.answers.framing = 'open-source';
|
|
407
|
+
w.run('physPaintStage()');
|
|
408
|
+
assert.equal(w.$('physStage').getAttribute('data-orbit'), 'own');
|
|
409
|
+
assert.equal(w.$('physSun').hidden, true, 'open source is a system of its own');
|
|
410
|
+
w.state.physics.answers.builders_at_start = 9; w.state.physics.answers.builders_expected = 40;
|
|
411
|
+
w.run('physPaintStage()');
|
|
412
|
+
assert.equal(shown('#physMoons img'), 6, 'six at most');
|
|
413
|
+
assert.equal(shown('#physGhosts img'), 4, 'four at most');
|
|
414
|
+
w.state.physics.q = 'sum';
|
|
415
|
+
w.run('physPaintStage()');
|
|
416
|
+
assert.equal(w.$('physPlanet').getAttribute('data-size'), '6', 'the full planet');
|
|
417
|
+
assert.equal(w.$('physCapN').textContent, 'complete');
|
|
418
|
+
for (const plate of ['r3-frost-bead-cut', 'r3-gold-bead-cut', 'r3-obsidian-bead-cut', 'r2-bongos-chrome-a-cut']) {
|
|
419
|
+
assert.ok(fs.existsSync(path.join(LANDING, 'assets', 'world', `${plate}.webp`)), `${plate} is a world plate`);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('the orbit and the moons move on CSS alone, and the reduced-motion switch stops them', () => {
|
|
424
|
+
const css = HUB.slice(HUB.indexOf('.physBelt{'), HUB.indexOf('/* under 520 the two doors'));
|
|
425
|
+
assert.match(css, /@media \(prefers-reduced-motion:no-preference\)\{\s*\.physMoons\{animation:physOrbit 160s/);
|
|
426
|
+
assert.doesNotMatch(css.replace(/@media \(prefers-reduced-motion:no-preference\)\{[\s\S]*?\n\}/, ''), /animation:/, 'no animation outside the no-preference block');
|
|
427
|
+
assert.doesNotMatch(css, /box-shadow/);
|
|
428
|
+
assert.doesNotMatch(SCRIPT, /\.animate\(|requestAnimationFrame|innerHTML/, 'no motion and no innerHTML in the controller');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// ── the done-when, end to end ────────────────────────────────────────────────
|
|
432
|
+
test('a new owner walks from question 1 to Create my planet, and the technical steps arrive pre-filled', async () => {
|
|
433
|
+
const w = boot();
|
|
434
|
+
await w.next(); // the start screen
|
|
435
|
+
assert.equal(w.state.physics.q, 1);
|
|
436
|
+
w.pick('project_state', 'existing');
|
|
437
|
+
w.state.physics.answers.existing_work = ['code']; w.state.physics.answers.main_work = 'code';
|
|
438
|
+
w.state.description = 'An inventory tool our team started last year.';
|
|
439
|
+
await w.next();
|
|
440
|
+
assert.equal(w.state.physics.q, 2);
|
|
441
|
+
Object.assign(w.state.physics.answers, { home_country: 'US', home_region: 'US-NY' });
|
|
442
|
+
w.pick('builder_geography', 'global');
|
|
443
|
+
await w.next();
|
|
444
|
+
w.pick('minors_on_board', 'false');
|
|
445
|
+
await w.next();
|
|
446
|
+
w.pick('entity', 'for-profit');
|
|
447
|
+
await w.next();
|
|
448
|
+
assert.equal(w.state.physics.q, 5);
|
|
449
|
+
w.q('[data-phys-solo="many"]')[0].click();
|
|
450
|
+
await w.next();
|
|
451
|
+
assert.equal(w.state.physics.q, 6);
|
|
452
|
+
w.pick('reward_intents', 'equity');
|
|
453
|
+
w.pick('reward_intents', 'cash');
|
|
454
|
+
await w.next();
|
|
455
|
+
assert.equal(w.state.physics.q, 7);
|
|
456
|
+
w.pick('framing', 'open-source');
|
|
457
|
+
await tick(); await tick();
|
|
458
|
+
await w.next();
|
|
459
|
+
assert.equal(w.state.physics.q, 'sum');
|
|
460
|
+
assert.equal(w.$('physNext').textContent, 'Continue to setup →');
|
|
461
|
+
await w.next();
|
|
462
|
+
assert.deepEqual(w.shown, [4], 'the name step');
|
|
463
|
+
assert.deepEqual({ type: w.state.projType, mode: w.state.mode, app: w.state.appPlatform, skipApp: w.run('physStepSkipped(10)') },
|
|
464
|
+
{ type: 'business', mode: 'adopt', app: 'later', skipApp: true });
|
|
465
|
+
const d = plain(w.run('createDetail()'));
|
|
466
|
+
assert.equal(d.voice, 'professional');
|
|
467
|
+
assert.equal(d.framing, 'open-source');
|
|
468
|
+
assert.equal(d.services, undefined);
|
|
469
|
+
assert.deepEqual(d.reward_intents, ['cash', 'equity']);
|
|
470
|
+
});
|