@bongos/core 1.21.1 → 1.21.3
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 +56 -36
- package/docs/copy-inventory.md +50 -49
- package/docs/copy-registry.json +60 -51
- package/docs/module-api-changelog.md +5 -1
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +49 -41
- package/migrations/core_270_craft_achievements.sql +128 -0
- package/migrations/core_271_bongos_shipper_badge_name.sql +14 -0
- package/modules/economy/achievements.js +199 -4
- package/modules/economy/credits.js +16 -0
- package/modules/economy/reward.js +3 -0
- package/modules/hall-ui/public/builders.js +1 -1
- package/modules/hall-ui/public/hall-render.js +24 -2
- package/modules/hall-ui/public/roster.css +21 -0
- package/modules/hall-ui/public/roster.html +3 -0
- package/modules/hall-ui/public/roster.js +130 -31
- package/modules/hall-ui/public/roster.states.json +26 -1
- package/modules/hall-ui/records/builders-roster.md +20 -0
- package/modules/lifecycle/publish-reconciler.js +25 -0
- package/modules/status-ui/public/status.js +51 -18
- package/modules/status-ui/public/style.css +24 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/hall-preview/refresh-fixtures.js +2 -0
- package/src/bongos/db.js +13 -0
- package/src/bongos/role-stats.js +36 -2
- package/src/bongos/routes/public.js +13 -2
- package/src/module-api.js +1 -1
- package/tests/achievements_reconcile.mjs +13 -0
- package/tests/craft_achievements.mjs +388 -0
- package/tests/founding_builders.mjs +2 -1
- package/tests/hall_builders_page_pass.mjs +2 -1
- package/tests/leaderboard_roles.mjs +272 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// tests/leaderboard_roles.mjs — the leaderboard, the roster and the status page
|
|
2
|
+
// tell the roles apart (task 1004437 / WA6.RP07, goal 1000095, criterion
|
|
3
|
+
// wa6-roles-read-true-in-public).
|
|
4
|
+
//
|
|
5
|
+
// The done-when: "all three lists show roles per the mock and no browser-only
|
|
6
|
+
// artist shows a misleading 0". Run for real:
|
|
7
|
+
//
|
|
8
|
+
// 1. THE READ. readRolesForBuilders over a page of builders: the profile's own
|
|
9
|
+
// main/shown rules (chosen, else earned, else preferred), a craft shown only
|
|
10
|
+
// once it earned, two reads for the whole page and never one per row.
|
|
11
|
+
// 2. THE ROUTE. GET /public/leaderboard carries each row's roles and per-craft
|
|
12
|
+
// credits (the figure a role ranks by is in the row, ADR 0255), and a roles
|
|
13
|
+
// failure costs the roles, never the board.
|
|
14
|
+
// 3. THE ROSTER. The REAL roster.js over the harness's public fixture: a Roles
|
|
15
|
+
// column, the Everyone / Engineers / Artists / Ideators toggle ranking by
|
|
16
|
+
// the credits that craft earned, and a dash, not a 0, for a builder with no
|
|
17
|
+
// AI sessions.
|
|
18
|
+
// 4. THE STANDING CARD and 5. THE STATUS PAGE: the real functions, run.
|
|
19
|
+
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import vm from 'node:vm';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { makeRunner } from './helpers.mjs';
|
|
27
|
+
|
|
28
|
+
process.env.NODE_ENV = 'test';
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
31
|
+
const { test, summary } = makeRunner();
|
|
32
|
+
const seams = require('../src/module-seams.js');
|
|
33
|
+
const creditsByCraftLib = require('../modules/economy/credits-by-craft.js');
|
|
34
|
+
const FIX = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/public__leaderboard.json'), 'utf8'));
|
|
35
|
+
const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8').replace(/\r\n/g, '\n');
|
|
36
|
+
|
|
37
|
+
// economy's own fold over hand-written grouped rows: what the ledger statement returns
|
|
38
|
+
function rewardOver(grouped, calls) {
|
|
39
|
+
return {
|
|
40
|
+
craftForPreference: creditsByCraftLib.craftForPreference,
|
|
41
|
+
creditsByCraft: async (ids) => { calls.push(['credits', ids]); return creditsByCraftLib.foldCraftRows(ids, grouped); },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- 1. the read ------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
await test('a page of builders: chosen, earned and preferred main roles, and a craft shown only once it earned', async () => {
|
|
48
|
+
const { readRolesForBuilders } = require('../src/bongos/role-stats.js');
|
|
49
|
+
const calls = [];
|
|
50
|
+
seams._reset();
|
|
51
|
+
seams.registerProvider('reward', rewardOver([
|
|
52
|
+
{ builder_id: '1', discipline: 'engineer', amount: 900 },
|
|
53
|
+
{ builder_id: '1', discipline: 'ideator', amount: 20 },
|
|
54
|
+
{ builder_id: '2', discipline: 'artist', amount: 370 },
|
|
55
|
+
{ builder_id: '2', discipline: 'ideator', amount: 42 },
|
|
56
|
+
{ builder_id: '3', discipline: 'ideator', amount: 186 },
|
|
57
|
+
], calls));
|
|
58
|
+
const choices = new Map([
|
|
59
|
+
['2', { preferred_disciplines: ['artist', 'ideator'], main_discipline: 'artist' }],
|
|
60
|
+
['3', { preferred_disciplines: ['ideator', 'artist'], main_discipline: null }],
|
|
61
|
+
['4', { preferred_disciplines: ['ui'], main_discipline: null }],
|
|
62
|
+
]);
|
|
63
|
+
const out = await readRolesForBuilders(['1', '2', 3, '4', 'x', '2'], { getRoleChoices: async (ids) => { calls.push(['choices', ids]); return choices; } });
|
|
64
|
+
assert.deepEqual([...out.keys()], ['1', '2', '3', '4'], 'every valid id once, a bad one dropped');
|
|
65
|
+
const r = (id) => JSON.parse(JSON.stringify(out.get(id)));
|
|
66
|
+
assert.deepEqual([r('1').main, r('1').main_basis, r('1').shown], ['engineer', 'earned', ['engineer', 'ideator']]);
|
|
67
|
+
assert.deepEqual([r('2').main, r('2').main_basis, r('2').shown], ['artist', 'chosen', ['artist', 'ideator']]);
|
|
68
|
+
assert.deepEqual(r('3').shown, ['ideator'], 'a preferred craft that earned nothing does not show');
|
|
69
|
+
assert.deepEqual([r('4').main, r('4').main_basis, r('4').shown], ['artist', 'preferred', ['artist']], 'ui is the artist\'s craft, as on the profile');
|
|
70
|
+
assert.equal(r('2').credits_by_craft.artist, 370);
|
|
71
|
+
assert.equal(r('2').credits_by_craft.total, 412);
|
|
72
|
+
assert.deepEqual(calls.map((c) => c[0]).sort(), ['choices', 'credits'], 'two reads for the whole page, never one per row');
|
|
73
|
+
seams._reset();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---- 2. the route -----------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async function bootPublic({ rolesThrow = false } = {}) {
|
|
79
|
+
const db = require('../src/bongos/db.js');
|
|
80
|
+
const realChoices = db.getBuilderRoleChoices;
|
|
81
|
+
db.getBuilderRoleChoices = async () => {
|
|
82
|
+
if (rolesThrow) throw new Error('db down');
|
|
83
|
+
return new Map([['2001', { preferred_disciplines: ['artist'], main_discipline: 'artist' }]]);
|
|
84
|
+
};
|
|
85
|
+
seams._reset();
|
|
86
|
+
seams.registerProvider('reward', {
|
|
87
|
+
craftForPreference: creditsByCraftLib.craftForPreference,
|
|
88
|
+
getLeaderboardWithAchievements: async () => [
|
|
89
|
+
{ id: '1', github_login: 'eng', total_credits: 900, tokens_used: '5000' },
|
|
90
|
+
{ id: '2001', github_login: 'art', total_credits: 370, tokens_used: '0' },
|
|
91
|
+
],
|
|
92
|
+
creditsByCraft: async (ids) => creditsByCraftLib.foldCraftRows(ids, [
|
|
93
|
+
{ builder_id: '1', discipline: 'engineer', amount: 900 },
|
|
94
|
+
{ builder_id: '2001', discipline: 'artist', amount: 370 },
|
|
95
|
+
]),
|
|
96
|
+
});
|
|
97
|
+
const express = require('express');
|
|
98
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
99
|
+
const app = express();
|
|
100
|
+
app.use(attachFail);
|
|
101
|
+
app.use(require('../src/bongos/routes/public.js')());
|
|
102
|
+
const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
|
|
103
|
+
const get = async (p) => { const res = await fetch(`http://127.0.0.1:${server.address().port}${p}`); return { status: res.status, body: await res.json() }; };
|
|
104
|
+
const close = async () => {
|
|
105
|
+
db.getBuilderRoleChoices = realChoices;
|
|
106
|
+
seams._reset();
|
|
107
|
+
server.closeAllConnections();
|
|
108
|
+
await new Promise((r) => server.close(r));
|
|
109
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
110
|
+
};
|
|
111
|
+
return { get, close };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await test('GET /public/leaderboard: every row names its roles and the credits each craft earned', async () => {
|
|
115
|
+
const s = await bootPublic();
|
|
116
|
+
try {
|
|
117
|
+
const { status, body } = await s.get('/public/leaderboard');
|
|
118
|
+
assert.equal(status, 200);
|
|
119
|
+
const [eng, art] = body.leaderboard;
|
|
120
|
+
assert.deepEqual(eng.roles.shown, ['engineer']);
|
|
121
|
+
assert.deepEqual(art.roles.shown, ['artist']);
|
|
122
|
+
assert.equal(art.roles.main_basis, 'chosen');
|
|
123
|
+
assert.equal(art.roles.credits_by_craft.artist, 370, 'the figure an Artists toggle ranks by is in the row');
|
|
124
|
+
} finally { await s.close(); }
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await test('a roles failure costs the roles, never the board', async () => {
|
|
128
|
+
const s = await bootPublic({ rolesThrow: true });
|
|
129
|
+
try {
|
|
130
|
+
const { status, body } = await s.get('/public/leaderboard');
|
|
131
|
+
assert.equal(status, 200);
|
|
132
|
+
assert.equal(body.leaderboard.length, 2, 'the rows still go out');
|
|
133
|
+
assert.ok(body.leaderboard.every((r) => r.roles === undefined), 'and simply carry no roles');
|
|
134
|
+
} finally { await s.close(); }
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('the public list carries karma, so the roster can read it and keep its karma column', () => {
|
|
138
|
+
const sql = read('modules/economy/achievements.js');
|
|
139
|
+
const q = sql.slice(sql.indexOf('async function getLeaderboardWithAchievements'), sql.indexOf('async function getLeaderboardWithAchievements') + 1200);
|
|
140
|
+
assert.match(q, /\bb\.karma\b/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---- 3. the roster ----------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function bootRoster() {
|
|
146
|
+
const els = {};
|
|
147
|
+
const el = (id) => (els[id] = els[id] || {
|
|
148
|
+
id, innerHTML: '', textContent: '', className: '', hidden: true,
|
|
149
|
+
listeners: {}, addEventListener(t, f) { this.listeners[t] = f; },
|
|
150
|
+
querySelectorAll: () => [],
|
|
151
|
+
});
|
|
152
|
+
let ledgerOpts = null;
|
|
153
|
+
let rendered = [];
|
|
154
|
+
const kit = {
|
|
155
|
+
esc: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
156
|
+
makePager: () => ({ page: 1 }),
|
|
157
|
+
makeFilterBar: () => {},
|
|
158
|
+
makeLedger: (opts) => { ledgerOpts = opts; return { render: (rows) => { rendered = rows; } }; },
|
|
159
|
+
};
|
|
160
|
+
let resolveLoaded;
|
|
161
|
+
const loaded = new Promise((r) => { resolveLoaded = r; });
|
|
162
|
+
const sandbox = {
|
|
163
|
+
window: { OTBKit: kit, OTB: { fmtInt: (n) => String(Number(n) || 0), rankLabel: (r) => r } },
|
|
164
|
+
document: { getElementById: el },
|
|
165
|
+
fetch: async (url) => { sandbox.__url = url; return { ok: true, json: async () => FIX }; },
|
|
166
|
+
console, Promise, setTimeout,
|
|
167
|
+
};
|
|
168
|
+
sandbox.globalThis = sandbox;
|
|
169
|
+
vm.createContext(sandbox);
|
|
170
|
+
vm.runInContext(read('modules/hall-ui/public/roster.js'), sandbox, { filename: 'roster.js' });
|
|
171
|
+
setTimeout(resolveLoaded, 30);
|
|
172
|
+
return {
|
|
173
|
+
loaded, sandbox, els,
|
|
174
|
+
get opts() { return ledgerOpts; },
|
|
175
|
+
get rows() { return rendered; },
|
|
176
|
+
click(roleKey) {
|
|
177
|
+
els['roster-roles'].listeners.click({ target: { closest: () => ({ dataset: { role: roleKey } }) } });
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await test('the roster reads the public list and draws a Roles column, main role first', async () => {
|
|
183
|
+
const r = bootRoster();
|
|
184
|
+
await r.loaded;
|
|
185
|
+
assert.equal(r.sandbox.__url, '/api/bongos/public/leaderboard', 'the list that carries the roles');
|
|
186
|
+
const labels = JSON.parse(JSON.stringify(r.opts.columns.map((c) => (typeof c === 'string' ? c : c.label))));
|
|
187
|
+
assert.deepEqual(labels, ['Place', 'Builder', 'Roles', 'Rank', 'Credits', 'Karma', 'Tokens']);
|
|
188
|
+
const art = r.rows.find((b) => String(b.id) === '2001');
|
|
189
|
+
const html = r.opts.rowHtml(art);
|
|
190
|
+
assert.match(html, /<span class="lb-role lb-role--lead">Artist<\/span><span class="lb-role">Ideator<\/span>/);
|
|
191
|
+
assert.match(r.els['roster-roles'].innerHTML, /Everyone[\s\S]*Engineers[\s\S]*Artists[\s\S]*Ideators/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await test('a builder with no AI sessions reads a dash and a sentence, never a 0 of tokens', async () => {
|
|
195
|
+
const r = bootRoster();
|
|
196
|
+
await r.loaded;
|
|
197
|
+
const art = r.rows.find((b) => String(b.id) === '2001');
|
|
198
|
+
const cells = r.opts.rowHtml(art).split('</td>');
|
|
199
|
+
const tokensCell = cells[6];
|
|
200
|
+
assert.ok(!/>0</.test(tokensCell), 'no bare 0 in the tokens cell');
|
|
201
|
+
assert.match(tokensCell, /—/);
|
|
202
|
+
assert.match(tokensCell, /No AI sessions recorded/, 'the tooltip says why, instead of a tokens-per-credit figure');
|
|
203
|
+
const eng = r.rows.find((b) => Number(b.tokens_used) > 0);
|
|
204
|
+
assert.match(r.opts.rowHtml(eng), /tokens per credit/, 'the efficiency figure still shows where there are tokens');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await test('the Artists toggle shows only artists, ranked by the credits that craft earned, with its own places', async () => {
|
|
208
|
+
const r = bootRoster();
|
|
209
|
+
await r.loaded;
|
|
210
|
+
r.click('artist');
|
|
211
|
+
const ids = r.rows.map((b) => String(b.id));
|
|
212
|
+
const artists = FIX.leaderboard.filter((b) => b.roles.shown.includes('artist'));
|
|
213
|
+
assert.deepEqual(ids.sort(), artists.map((b) => String(b.id)).sort(), 'exactly the builders shown as artists');
|
|
214
|
+
const credits = r.rows.map((b) => b.roles.credits_by_craft.artist);
|
|
215
|
+
assert.deepEqual(credits, [...credits].sort((a, b) => b - a), 'ranked by artist credits, not total credits');
|
|
216
|
+
assert.equal(String(r.rows[0].id), '2001', 'the browser-only artist leads the artists');
|
|
217
|
+
const labels = r.opts.columns.map((c) => (typeof c === 'string' ? c : c.label));
|
|
218
|
+
assert.equal(labels[4], 'Credits as artist', 'the header names the craft');
|
|
219
|
+
assert.match(r.opts.rowHtml(r.rows[0]), /ledger__place">1</, 'the place is the place among artists');
|
|
220
|
+
assert.match(r.opts.rowHtml(r.rows[0]), /<td class="ledger__num">370<\/td>/, 'and the figure is the artist credits');
|
|
221
|
+
r.click('');
|
|
222
|
+
assert.equal(r.rows.length, FIX.leaderboard.length, 'Everyone brings every builder back');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ---- 4. the standing card ---------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
function extract(src, start, end) {
|
|
228
|
+
const a = src.indexOf(start);
|
|
229
|
+
const b = src.indexOf(end, a);
|
|
230
|
+
assert.ok(a !== -1 && b !== -1, `found ${start}`);
|
|
231
|
+
return src.slice(a, b);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await test('the standing card adds the place inside the builder\'s main role, only when it says something new', () => {
|
|
235
|
+
const src = read('modules/hall-ui/public/hall-render.js');
|
|
236
|
+
const code = extract(src, 'const lbState = { rows: [] };', '// task 1003459');
|
|
237
|
+
const sandbox = { rows: FIX.leaderboard };
|
|
238
|
+
vm.createContext(sandbox);
|
|
239
|
+
vm.runInContext(`${code}; lbState.rows = rows; this.place = leaderboardPlace; this.text = rolePlaceText;`, sandbox);
|
|
240
|
+
const art = sandbox.place('builder-7');
|
|
241
|
+
assert.equal(art.role.craft, 'artist');
|
|
242
|
+
assert.equal(sandbox.text(art), `#1 as an artist`);
|
|
243
|
+
const top = sandbox.place(FIX.leaderboard[0].github_login);
|
|
244
|
+
assert.equal(sandbox.text(top), '', 'the top engineer is #1 either way, so nothing is added');
|
|
245
|
+
assert.match(src, /setText\('stat-rank-sub', rollPlace \? \[`#\$\{rollPlace\.place\} on the leaderboard`, rolePlaceText\(rollPlace\)\]/);
|
|
246
|
+
assert.match(read('modules/hall-ui/public/builders.js'), /getJSON\(`\$\{API\}\/public\/leaderboard`\)/, 'the hall reads the list with the roles');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ---- 5. the status page -----------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
await test('the status leaderboard names each builder\'s roles, main one first, as text', () => {
|
|
252
|
+
const src = read('modules/status-ui/public/status.js');
|
|
253
|
+
const code = extract(src, 'const ROLE_WORD', 'async function loadLeaderboard');
|
|
254
|
+
const made = [];
|
|
255
|
+
const node = (tag) => {
|
|
256
|
+
const n = { tag, className: '', textContent: '', children: [], attrs: {}, appendChild(c) { this.children.push(c); }, setAttribute(k, v) { this.attrs[k] = v; } };
|
|
257
|
+
made.push(n);
|
|
258
|
+
return n;
|
|
259
|
+
};
|
|
260
|
+
const sandbox = { document: { createElement: node } };
|
|
261
|
+
vm.createContext(sandbox);
|
|
262
|
+
vm.runInContext(`${code}; this.renderRoles = renderRoles;`, sandbox);
|
|
263
|
+
const row = sandbox.renderRoles({ shown: ['artist', 'ideator', 'wizard'] });
|
|
264
|
+
assert.deepEqual(row.children.map((c) => [c.className, c.textContent]), [
|
|
265
|
+
['leader__role leader__role--lead', 'Artist'],
|
|
266
|
+
['leader__role', 'Ideator'],
|
|
267
|
+
], 'a word the page does not know is dropped, not printed');
|
|
268
|
+
assert.equal(sandbox.renderRoles(null), null, 'a row without roles draws nothing');
|
|
269
|
+
assert.match(src, /const roles = renderRoles\(b\.roles\);\s*if \(roles\) name\.appendChild\(roles\);/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
summary();
|