@bongos/core 1.20.75 → 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.
@@ -0,0 +1,201 @@
1
+ // tests/profile_role_headline.mjs — the profile's header and "By the numbers"
2
+ // lead with each person's own craft (task 1004434 / WA6.RP04, goal 1000095,
3
+ // criterion wa6-roles-read-true-in-public).
4
+ //
5
+ // The done-when: "engineer, artist and ideator fixtures each render their own
6
+ // headline, matching the mock". Three layers, each run for real:
7
+ //
8
+ // 1. The words. profile-roles.js (pure) over the three harness fixtures the
9
+ // renders were taken from: each craft's tally, tiles and subtitle.
10
+ // 2. The page. The REAL profile.js in a vm, with the REAL hall kit, over the
11
+ // same fixtures: what lands in the header, the numbers and the subtitle.
12
+ // 3. The whole path. The REAL server reader (src/bongos/role-stats.js) with
13
+ // the three role ports stubbed, through JSON, into the REAL page, so a field
14
+ // the server renames or drops cannot leave this suite green on a fixture
15
+ // that still carries it.
16
+
17
+ import { strict as assert } from 'node:assert';
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
+ import { makeRunner } from './helpers.mjs';
24
+
25
+ process.env.NODE_ENV = 'test';
26
+ const require = createRequire(import.meta.url);
27
+ const ROOT = fileURLToPath(new URL('..', import.meta.url));
28
+ const FIX = (n) => JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures', n + '.json'), 'utf8'));
29
+
30
+ const { headline } = require('../modules/hall-ui/public/profile-roles.js');
31
+ const OTBKit = require('../modules/hall-ui/public/hall-kit.js');
32
+ const ROLES_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-roles.js'), 'utf8');
33
+ const PAGE_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
34
+
35
+ const ENGINEER = FIX('builders__profile');
36
+ const ARTIST = FIX('builders__2001__profile');
37
+ const IDEATOR = FIX('builders__2002__profile');
38
+ const OPTS = { fmt: (n) => Number(n).toLocaleString('en-US'), currency: 'credits', rank: 'Contributor' };
39
+ const words = (items) => items.map((t) => `${t.n} ${t.w}`);
40
+
41
+ const { test, summary } = makeRunner();
42
+
43
+ // ---- 1. the words --------------------------------------------------------
44
+
45
+ await test('an engineer leads with works shipped, and keeps karma', () => {
46
+ const v = headline(ENGINEER, OPTS);
47
+ assert.deepEqual(words(v.tally), ['4,630 credits', '24 works shipped', '1 karma']);
48
+ assert.deepEqual(words(v.tiles).slice(0, 4), ['24 works shipped', '4,630 credits', '1 karma', '2 achievements']);
49
+ assert.equal(v.sub, 'Builder 4 — Contributor · 24 works shipped');
50
+ assert.deepEqual(v.surfaces, [], 'the per-surface rows are the artist\'s');
51
+ });
52
+
53
+ await test('an artist who never shipped a task leads with pages approved and the per-surface share', () => {
54
+ const v = headline(ARTIST, OPTS);
55
+ assert.deepEqual(words(v.tally), ['412 credits', '9 pages approved', '+38 this week']);
56
+ assert.deepEqual(words(v.tiles), ['9 pages approved', '270 credits from pages', '+38 this week', '4 thoughts filed', '6 karma']);
57
+ assert.equal(v.tiles[0].f, '3 surfaces');
58
+ assert.equal(v.sub, 'Builder 7 — Contributor · 9 pages approved');
59
+ assert.ok(!v.sub.includes('works shipped'), 'the subtitle no longer says works shipped for an artist');
60
+ assert.deepEqual(v.surfaces.map((s) => s.line), ['6 of 12 tweaked', '2 of 19 tweaked', '1 of 6 tweaked']);
61
+ assert.ok(!words(v.tiles).some((w) => /works shipped/.test(w)), 'no engineer figure on an artist\'s numbers');
62
+ assert.equal(words(v.tiles).at(-1), '6 karma', 'karma ends every non-engineer set (the owner, 2026-10-01)');
63
+ });
64
+
65
+ await test('an ideator leads with thoughts, size and what is still alive', () => {
66
+ const v = headline(IDEATOR, OPTS);
67
+ assert.deepEqual(words(v.tally), ['186 credits', '31 thoughts filed', '7 ignited']);
68
+ assert.deepEqual(words(v.tiles), ['31 thoughts filed', '7 ignited', '3 Full Ideas', '186~ total size', '72% still alive', '3 karma']);
69
+ assert.equal(v.tiles[2].f, '1 ratified by the board');
70
+ assert.match(v.tiles[3].title, /Includes 24 credits of historical estimate/, 'the ~ says what it means');
71
+ assert.equal(v.sub, 'Builder 9 — Contributor · 31 thoughts filed');
72
+ });
73
+
74
+ await test('several crafts each show, main first, in the server\'s order', () => {
75
+ assert.deepEqual(headline(ARTIST, OPTS).crafts.map((c) => c.label), ['Artist, main role', 'Ideator']);
76
+ assert.deepEqual(headline(ENGINEER, OPTS).crafts.map((c) => c.label), ['Engineer, main role', 'Ideator']);
77
+ // The other craft adds its one headline tile after the main craft's set.
78
+ assert.equal(words(headline(ENGINEER, OPTS).tiles).at(-1), '9 thoughts filed');
79
+ const reordered = { ...ARTIST, roles: { ...ARTIST.roles, main: 'ideator', shown: ['ideator', 'artist'] } };
80
+ const v = headline(reordered, OPTS);
81
+ assert.deepEqual(v.crafts.map((c) => c.label), ['Ideator, main role', 'Artist']);
82
+ assert.equal(words(v.tiles).at(-2), '9 pages approved', 'the other craft\'s tile, just before karma');
83
+ assert.deepEqual(v.surfaces, [], 'the per-surface rows follow the MAIN craft, not any craft');
84
+ });
85
+
86
+ await test('a number the instance could not read is a dash, never a zero', () => {
87
+ const noDesk = { ...ARTIST, roles: { ...ARTIST.roles, artist: { pages_approved: null, credits: null, this_week: null, surfaces_touched: null, surfaces: [], unavailable: true } } };
88
+ const v = headline(noDesk, OPTS);
89
+ assert.deepEqual(words(v.tally).slice(1), ['— pages approved', '— this week']);
90
+ assert.equal(v.tiles[0].f, '', 'no "0 surfaces" under an unknown count');
91
+ const noWork = { ...IDEATOR, roles: { ...IDEATOR.roles, ideator: { ...IDEATOR.roles.ideator, alive: { share: null, has_work: false, attributable: null } } } };
92
+ assert.deepEqual(headline(noWork, OPTS).tiles.at(-2), { n: '—', w: 'still alive', f: 'nothing has come of them yet' });
93
+ const untraced = { ...IDEATOR, roles: { ...IDEATOR.roles, ideator: { ...IDEATOR.roles.ideator, alive: { share: null, has_work: true, attributable: false } } } };
94
+ assert.equal(headline(untraced, OPTS).tiles.at(-2).f, 'not measurable yet');
95
+ });
96
+
97
+ await test('a response from before the roles block reads as the engineer it always was', () => {
98
+ const { roles, ...old } = ENGINEER;
99
+ const v = headline(old, OPTS);
100
+ assert.deepEqual(v.crafts.map((c) => c.label), ['Engineer, main role']);
101
+ assert.equal(v.sub, 'Builder 4 — Contributor · 24 works shipped');
102
+ });
103
+
104
+ // ---- 2. the page ---------------------------------------------------------
105
+
106
+ async function boot(profileData) {
107
+ const els = new Map();
108
+ const makeEl = () => ({
109
+ hidden: false, textContent: '', _html: '', dataset: {}, style: {},
110
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
111
+ insertAdjacentHTML() {}, addEventListener() {}, setAttribute() {}, querySelector: () => null, querySelectorAll: () => [],
112
+ classList: { add() {}, remove() {}, toggle() {} },
113
+ });
114
+ const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
115
+ const escapeHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
116
+ const json = JSON.parse(JSON.stringify(profileData)); // what the wire carries
117
+ const api = {
118
+ async request(_m, url) {
119
+ if (url === '/api/bongos/me') return { status: 401, ok: false, data: null };
120
+ if (/\/builders\/[^/]+\/profile$/.test(url)) return { status: 200, ok: true, data: json };
121
+ return { status: 404, ok: false, data: null };
122
+ },
123
+ };
124
+ const windowObj = {
125
+ __BRANDING__: { currency: { label: 'credits' } },
126
+ __MODULES__: { responsibilities: { engineer: 'E.', artist: 'A.', ideator: 'I.' } },
127
+ BongosClient: { createClient: () => api },
128
+ // The real kit draws the surface bars; the section rail needs a real DOM and
129
+ // is not what this suite is about, so it is left out (profile.js skips it).
130
+ OTBKit: { ...OTBKit, makeSectionRail: undefined },
131
+ OTB: {
132
+ $, escapeHtml,
133
+ fmtNum: (n) => Number(n || 0).toLocaleString('en-US'),
134
+ fmtDate: (d) => String(d).slice(0, 10),
135
+ rankLabel: () => 'Contributor',
136
+ roleAxisLabel: () => 'Role', roleLabel: (d) => d, roleIsUnset: (d) => !d, toast() {},
137
+ },
138
+ };
139
+ const sandbox = {
140
+ window: windowObj, location: { search: '?id=' + profileData.builder.id, hash: '' }, URLSearchParams, console,
141
+ document: { querySelector: () => null, querySelectorAll: () => [], addEventListener() {} },
142
+ addEventListener() {}, history: { replaceState() {} },
143
+ };
144
+ sandbox.globalThis = sandbox;
145
+ vm.createContext(sandbox);
146
+ vm.runInContext(ROLES_SRC, sandbox, { filename: 'profile-roles.js' });
147
+ vm.runInContext(PAGE_SRC, sandbox, { filename: 'profile.js' });
148
+ for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r));
149
+ return { head: $('#profile-head').innerHTML, nums: $('#numbers-body').innerHTML, sub: $('#profile-sub').textContent };
150
+ }
151
+ const text = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
152
+
153
+ await test('the real page draws the artist\'s tally, chips, tiles and surface rows', async () => {
154
+ const p = await boot(ARTIST);
155
+ assert.equal(p.sub, 'Builder 7 — Contributor · 9 pages approved');
156
+ assert.match(text(p.head), /Artist, main role Ideator/);
157
+ assert.match(text(p.head), /412 credits 9 pages approved \+38 this week/);
158
+ assert.match(p.head, /Answerable for:/, 'the responsibility line stays');
159
+ assert.match(text(p.nums), /^9 pages approved 3 surfaces 270 credits from pages \+38 this week 4 thoughts filed 1 ignited/);
160
+ assert.equal((p.nums.match(/kit-bars__row/g) || []).length, 3, 'one kit bar row per surface');
161
+ assert.match(text(p.nums), /Landing 6 of 12 tweaked/);
162
+ assert.ok(!/works shipped/i.test(p.sub + p.head + p.nums), 'nothing on an artist\'s header says works shipped');
163
+ });
164
+
165
+ await test('the real page draws the ideator\'s and the engineer\'s own headlines', async () => {
166
+ const i = await boot(IDEATOR);
167
+ assert.match(text(i.head), /186 credits 31 thoughts filed 7 ignited/);
168
+ assert.match(text(i.nums), /186~ total size/);
169
+ assert.match(text(i.nums), /72% still alive/);
170
+ assert.ok(!i.nums.includes('kit-bars'), 'no surface rows for an ideator');
171
+ const e = await boot({ ...ENGINEER, recent_ships: [] }); // the works ledger is not this suite's
172
+ assert.equal(e.sub, 'Builder 4 — Contributor · 24 works shipped');
173
+ assert.match(text(e.head), /4,630 credits 24 works shipped 1 karma/);
174
+ });
175
+
176
+ // ---- 3. the whole path ----------------------------------------------------
177
+
178
+ await test('the server\'s roles block, through JSON, renders the artist\'s headline', async () => {
179
+ const seams = require('../src/module-seams.js');
180
+ const { readRoleStats } = require('../src/bongos/role-stats.js');
181
+ seams._reset();
182
+ seams.registerProvider('reward', {
183
+ craftForPreference: (d) => ({ engineer: 'engineer', artist: 'artist', ui: 'artist', ideator: 'ideator' }[d] || null),
184
+ async creditsByCraft(ids) { return new Map([[String(ids[0]), { engineer: 0, artist: 370, ideator: 42, other: 0, total: 412 }]]); },
185
+ });
186
+ seams.registerProvider('copy-desk.artistStats', async () => ARTIST.roles.artist);
187
+ seams.registerProvider('ideas.ideatorStats', async () => ARTIST.roles.ideator);
188
+ const roles = await readRoleStats('2001', {
189
+ shippedCount: 0,
190
+ getRoleChoice: async () => ({ main_discipline: 'artist', preferred_disciplines: ['artist', 'ideator'] }),
191
+ });
192
+ seams._reset();
193
+ const { roles: _fixture, ...rest } = ARTIST;
194
+ const p = await boot({ ...rest, roles });
195
+ assert.equal(p.sub, 'Builder 7 — Contributor · 9 pages approved');
196
+ assert.match(text(p.head), /Artist, main role Ideator/);
197
+ assert.match(text(p.head), /412 credits 9 pages approved \+38 this week/);
198
+ assert.equal((p.nums.match(/kit-bars__row/g) || []).length, 3);
199
+ });
200
+
201
+ summary();
@@ -29,6 +29,7 @@ import { fileURLToPath } from 'node:url';
29
29
 
30
30
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
31
  const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
32
+ const ROLES_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-roles.js'), 'utf8');
32
33
  const HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.html'), 'utf8');
33
34
 
34
35
  // ---- fixtures -------------------------------------------------------------
@@ -98,6 +99,8 @@ async function boot({ search = '', me, profile } = {}) {
98
99
  };
99
100
  sandbox.globalThis = sandbox;
100
101
  vm.createContext(sandbox);
102
+ // The header is drawn from profile-roles.js (task 1004434), loaded first as the page does.
103
+ vm.runInContext(ROLES_SRC, sandbox, { filename: 'profile-roles.js' });
101
104
  vm.runInContext(SRC, sandbox, { filename: 'profile.js' });
102
105
  // load() is fire-and-forget; let its await chain settle.
103
106
  for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r));
@@ -114,7 +114,10 @@ function load({ draft = {}, answers = {} } = {}) {
114
114
  const watches = [];
115
115
  const ctx = { root, state, $, document, Object, API: '/api/bongos', fetch, JSON, String, Number, encodeURIComponent, Array, saveDraft: () => saves.push(JSON.stringify(state)),
116
116
  // the shared status watch (task 1004353) lives outside this block; record that it is started
117
- watchApp: (id, box) => { watches.push({ id, box }); return () => {}; } };
117
+ watchApp: (id, box) => { watches.push({ id, box }); return () => {}; },
118
+ // the planet-physics step skip (task 1004419) lives outside this block too; the
119
+ // framing that skips this step is wizard_physics_more.mjs's to test
120
+ physStepSkipped: () => false };
118
121
  vm.createContext(ctx);
119
122
  vm.runInContext(ESC + '\n' + APPEL + '\n' + CONNECT + '\n' + BLOCK + '\nthis.api = { appPlatformChoice, syncAppStep, appRow, syncRenderSetup, validateRender, renderRequestBody, sendRenderSetup, planLabel };', ctx);
120
123
  const api = ctx.api;
@@ -24,7 +24,7 @@ import { createRequire } from 'node:module';
24
24
  import http from 'node:http';
25
25
  // ESM named imports off the CJS modules on purpose — knip cannot trace a
26
26
  // createRequire() import (the tests/provisioning_module_selection.mjs note).
27
- import { EU_COUNTRIES, COUNTRY_CODES, REGION_CODES, crossAnswerFault }
27
+ import { EU_COUNTRIES, COUNTRY_CODES, REGION_CODES, crossAnswerFault, canonicalAnswer as physicsCanonical }
28
28
  from '../modules/provisioning/planet-physics.js';
29
29
  import { SOFT_LIMIT_RULES, EFFECTS, softLimitsFor } from '../modules/provisioning/soft-limits.js';
30
30
  import { SCREENING_RULES, STOP_MESSAGE, screenProject } from '../modules/provisioning/screening.js';
@@ -164,7 +164,11 @@ await t('every rule carries a reason, a known effect, and names only real questi
164
164
  const q = provisioning.PROJECT_DETAIL_QUESTIONS[key];
165
165
  assert.ok(q, `${rule.id}: ${key} is a question`);
166
166
  const named = test.in || [test.has ?? test.lacks];
167
- for (const v of named) assert.ok(q.values.includes(v), `${rule.id}: ${key}=${v} is a real answer`);
167
+ // a count question has a range, not a list (task 1004419's solo rule)
168
+ for (const v of named) {
169
+ const real = q.kind === 'count' ? physicsCanonical(q, v) !== undefined : q.values.includes(v);
170
+ assert.ok(real, `${rule.id}: ${key}=${v} is a real answer`);
171
+ }
168
172
  }
169
173
  }
170
174
  });
@@ -221,6 +225,24 @@ await t('the table hides whole questions too: a new planet has no existing work,
221
225
  assert.ok(!opts({ framing: 'hybrid' }).includes('services'));
222
226
  });
223
227
 
228
+ await t('rewards are hidden for a founder building alone with nobody expected, and only then (task 1004419)', () => {
229
+ const hidden = (answers) => softLimitsFor(answers).limits.some((l) => l.option === 'reward_intents' && l.effect === 'hide');
230
+ assert.equal(hidden({ builders_at_start: 1, builders_expected: 1 }), true, 'solo');
231
+ assert.equal(hidden({ builders_at_start: 1, builders_expected: 4 }), false, 'alone now, company expected');
232
+ assert.equal(hidden({ builders_at_start: 3, builders_expected: 8 }), false, 'a team');
233
+ assert.equal(hidden({ builders_at_start: 1 }), false, 'the growth guess never answered is not "nobody"');
234
+ const reason = softLimitsFor({ builders_at_start: 1, builders_expected: 1 }).limits.find((l) => l.option === 'reward_intents').reason;
235
+ assert.match(reason, /building solo/);
236
+ });
237
+
238
+ await t('the voice is a stored detail: planet or professional, null to un-answer, anything else refused (spec D5)', () => {
239
+ assert.deepEqual(provisioning.PROJECT_DETAIL_VOCAB.voice, ['planet', 'professional']);
240
+ assert.equal(badProjectDetail({ voice: 'professional' }), null);
241
+ assert.equal(badProjectDetail({ voice: null }), null);
242
+ assert.match(badProjectDetail({ voice: 'pirate' }).message, /"voice" must be one of: planet, professional/);
243
+ assert.deepEqual(provisioning.normalizeProjectDetail({ voice: 'planet', entity: 'for-profit' }), { entity: 'for-profit', voice: 'planet' });
244
+ });
245
+
224
246
  await t('the soft limits read back on every owner read of the project, from the stored answers', () => {
225
247
  const view = provisioning.publicInstance({ id: 1, slug: 'kestrel', detail: provisioning.normalizeProjectDetail({ entity: 'nonprofit' }) });
226
248
  assert.equal(view.soft_limits.enforced, false);
@@ -93,7 +93,8 @@ test('a builder who loops the check is slowed down, and another builder is not',
93
93
  });
94
94
 
95
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) ──'),
96
+ // questions 5 to 7 joined the same region (task 1004419, tests/wizard_physics_more.mjs)
97
+ const SCRIPT = HUB.slice(HUB.indexOf('/* ── planet physics, questions 1 to 7 (tasks 1004418 and 1004419, BV2.PS07 + PS11) ──'),
97
98
  HUB.indexOf(' /* per-panel validation on Next */'));
98
99
 
99
100
  test('the page carries the server\'s countries, regions and entity forms, exactly', () => {
@@ -115,7 +116,9 @@ test('every choice the questions offer is an answer the server accepts', () => {
115
116
  const seen = [...panel.matchAll(/data-phys="([a-z_]+)" data-val="([^"]+)"/g)];
116
117
  assert.ok(seen.length >= 20, 'the four questions\' choices');
117
118
  for (const [, key, raw] of seen) {
118
- const value = raw === 'true' ? true : raw === 'false' ? false : raw;
119
+ const one = raw === 'true' ? true : raw === 'false' ? false : raw;
120
+ // a list question's choice is one member of the list it sends (task 1004419)
121
+ const value = physics.PLANET_PHYSICS_QUESTIONS[key] && physics.PLANET_PHYSICS_QUESTIONS[key].kind === 'many' ? [one] : one;
119
122
  assert.notEqual(physics.canonicalAnswer(physics.PLANET_PHYSICS_QUESTIONS[key], value), undefined, `${key}=${raw}`);
120
123
  }
121
124
  for (const [, work] of panel.matchAll(/data-work="([^"]+)"/g)) {
@@ -196,7 +199,11 @@ test('Continue walks the questions and moves the wizard on only after the last',
196
199
  assert.deepEqual(w.shown, [], 'no entity: still here');
197
200
  a().entity = 'for-profit';
198
201
  w.run('physAdvance()');
199
- assert.deepEqual(w.shown, [4], 'and only now on to the name step');
202
+ await tick(); await tick();
203
+ // task 1004419: questions 5 to 7 and the summary follow inside the same step
204
+ // (walked to the name step in tests/wizard_physics_more.mjs)
205
+ assert.equal(w.state.physics.q, 5, 'on to question 5');
206
+ assert.deepEqual(w.shown, [], 'and the wizard has not moved on');
200
207
  });
201
208
 
202
209
  test('a stopped description lands on the stop screen with the server\'s words', async () => {
@@ -216,7 +223,7 @@ test('the create sends the answers as detail: existing work only for an existing
216
223
  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
224
  w.state.teamShape = 'solo';
218
225
  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',
226
+ project_state: 'new', home_country: 'NL', builder_geography: 'global', minors_on_board: false, entity: 'intent-solo', voice: 'planet', team_shape: 'solo',
220
227
  });
221
228
  w.state.physics.answers = {}; w.state.teamShape = null;
222
229
  assert.equal(w.run('createDetail()'), undefined, 'nothing answered: the detail is omitted, as before');