@bongos/core 1.20.81 → 1.20.83
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 +71 -46
- 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 +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +46 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +33 -26
- package/docs/copy-registry.json +164 -92
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +58 -43
- package/modules/economy/earnings.js +117 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/routes/earnings.js +47 -0
- package/modules/hall-ui/public/gate.css +49 -0
- package/modules/hall-ui/public/gate.html +5 -7
- package/modules/hall-ui/public/gate.js +215 -127
- package/modules/hall-ui/public/profile-charts.js +206 -104
- package/modules/hall-ui/public/profile.css +23 -0
- package/modules/hall-ui/public/profile.js +3 -3
- package/modules/hall-ui/public/profile.states.json +8 -4
- package/modules/hall-ui/records/profile-roles.md +21 -0
- package/modules/lifecycle/db-goals.js +6 -0
- package/modules/lifecycle/db-versions.js +3 -0
- package/modules/lifecycle/done-when.js +85 -41
- package/modules/lifecycle/goal-close-sweep.js +34 -0
- package/modules/provisioning/app-status.js +17 -4
- package/modules/public-landing/public/projects.html +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/hall-preview/server.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +28 -6
- package/tests/autonomy_builder_scope.mjs +1 -1
- package/tests/autonomy_fence_priority.mjs +5 -3
- package/tests/autonomy_runner_identity.mjs +1 -1
- package/tests/criterion_goal_attach.mjs +5 -1
- package/tests/goal_close_cancels_requests.mjs +82 -0
- package/tests/goal_closure_invariants.mjs +73 -2
- package/tests/hall_profile_world.mjs +57 -58
- package/tests/profile_earnings.mjs +237 -0
- package/tests/projects_hub_app_status.mjs +17 -0
- package/tests/provisioning_app_status.mjs +25 -1
|
@@ -21,12 +21,13 @@
|
|
|
21
21
|
// names; craftMix folds the tail past seven into "other" rather than
|
|
22
22
|
// minting an eighth hue; weeklyBuckets is a time axis (every week present,
|
|
23
23
|
// empty or not).
|
|
24
|
-
// * DRACHMAE COME FROM THE LEDGER (task 1003491)
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
24
|
+
// * DRACHMAE COME FROM THE LEDGER (task 1003491), and for EVERYONE (task
|
|
25
|
+
// 1004436). The earned line is GET /builders/:id/earnings — the ledger
|
|
26
|
+
// summed per day and kind, one read for your own profile and anyone's —
|
|
27
|
+
// cumulated by day, so it ends at the balance. Per-task credits_awarded is
|
|
28
|
+
// 0 on a cost-plus instance and is never read (the owner's chart had
|
|
29
|
+
// flatlined since July on it). The earnings draw with no shipped task at
|
|
30
|
+
// all; the tests that prove it live in tests/profile_earnings.mjs.
|
|
30
31
|
// * NO NUMBER IS PICTURE-ONLY. Every time chart carries a table twin with a
|
|
31
32
|
// row per plotted day; the readout is written with textContent.
|
|
32
33
|
// * OWN-ONLY MEANS OWN-ONLY. /analytics/builder/:id is asked for only when
|
|
@@ -209,13 +210,13 @@ test('cumulativeByDay folds a day\'s ships into one point and accumulates, skipp
|
|
|
209
210
|
], 'newest-first ledger rows still accumulate oldest-first, by their own timestamp');
|
|
210
211
|
});
|
|
211
212
|
|
|
212
|
-
test('
|
|
213
|
+
test('the per-task award column is not read anywhere — nor the estimate that stood in for the ledger', () => {
|
|
213
214
|
const C = bootCharts();
|
|
214
|
-
assert.equal(C.estimateOf({ credits_awarded: 58, credits_reward: 60 }), 60, 'the estimate ignores what was awarded');
|
|
215
|
-
assert.equal(C.estimateOf({ credits_awarded: 0, credits_reward: 45 }), 45, 'a cost-plus 0 award does not zero the estimate');
|
|
216
|
-
assert.equal(C.estimateOf({}), 0);
|
|
217
|
-
assert.equal(C.estimateOf(null), 0);
|
|
218
215
|
assert.equal(C.creditsOf, undefined, 'the old ledger-or-estimate picker is gone');
|
|
216
|
+
// task 1004436: every signed-in viewer reads the earnings route, so the
|
|
217
|
+
// credits_reward estimate a refused ledger fell back to has nothing to cover.
|
|
218
|
+
assert.equal(C.estimateOf, undefined, 'the estimate fallback is gone too');
|
|
219
|
+
assert.ok(!chartsJs.includes('credits_reward'), 'profile-charts.js never sums an estimate');
|
|
219
220
|
// The whole reason for task 1003491: on a cost-plus instance every task's
|
|
220
221
|
// credits_awarded is 0, so a drachmae series summed from it flatlines the day
|
|
221
222
|
// per-task payout stops. The column must not be read by the charts at all.
|
|
@@ -269,10 +270,9 @@ test('clipToWindow (the lookback) enters the window at the running height, not a
|
|
|
269
270
|
|
|
270
271
|
// ---- the charts: what render asks for and draws ---------------------------------
|
|
271
272
|
|
|
272
|
-
// `
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
function bootRender({ rows, status = 200, analytics = null, ledger = [] }) {
|
|
273
|
+
// `earnings` is what GET /builders/:id/earnings answers: its `days` rows
|
|
274
|
+
// ({ day, kind, credits }), or 'failed' for a 500.
|
|
275
|
+
function bootRender({ rows, status = 200, analytics = null, earnings = [] }) {
|
|
276
276
|
const requests = [];
|
|
277
277
|
const mount = { innerHTML: '', querySelector: () => null };
|
|
278
278
|
const controls = { innerHTML: '', addEventListener() {}, querySelectorAll: () => [] };
|
|
@@ -285,10 +285,10 @@ function bootRender({ rows, status = 200, analytics = null, ledger = [] }) {
|
|
|
285
285
|
const offset = Number(new URL(`http://x${url}`).searchParams.get('offset')) || 0;
|
|
286
286
|
return { status: 200, ok: true, data: { total: rows.length, rows: rows.slice(offset, offset + 100) } };
|
|
287
287
|
}
|
|
288
|
-
if (url.includes('/
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
return { status: 200, ok: true, data: {
|
|
288
|
+
if (url.includes('/earnings')) {
|
|
289
|
+
if (status !== 200) return { status, ok: false, data: null };
|
|
290
|
+
if (earnings === 'failed') return { status: 500, ok: false, data: null };
|
|
291
|
+
return { status: 200, ok: true, data: { days: earnings } };
|
|
292
292
|
}
|
|
293
293
|
if (url.includes('/analytics/')) return analytics ? { status: 200, ok: true, data: analytics } : { status: 403, ok: false, data: null };
|
|
294
294
|
return { status: 404, ok: false, data: null };
|
|
@@ -324,13 +324,13 @@ function bootRender({ rows, status = 200, analytics = null, ledger = [] }) {
|
|
|
324
324
|
}
|
|
325
325
|
|
|
326
326
|
const ship = (d, extra = {}) => ({ shipped_at: `${d}T10:00:00Z`, discipline: 'engineer', credits_awarded: 10, credits_reward: 12, ...extra });
|
|
327
|
-
//
|
|
328
|
-
const pay = (d,
|
|
327
|
+
// an earnings row: what the ledger paid on one day, for one kind of credit
|
|
328
|
+
const pay = (d, credits, kind = 'session') => ({ day: d, kind, credits });
|
|
329
329
|
|
|
330
330
|
test('render draws three public figures with a table twin per time chart, and no own-only one for another builder', async () => {
|
|
331
331
|
const b = bootRender({
|
|
332
332
|
rows: [ship('2026-08-01'), ship('2026-08-01'), ship('2026-08-20', { discipline: 'ui' })],
|
|
333
|
-
|
|
333
|
+
earnings: [pay('2026-08-01', 20), pay('2026-08-20', 30)],
|
|
334
334
|
});
|
|
335
335
|
await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
|
|
336
336
|
const out = b.mount.innerHTML;
|
|
@@ -349,8 +349,8 @@ test('render asks for the pace chart on the viewer\'s own profile only', async (
|
|
|
349
349
|
const own = bootRender({ rows: [ship('2026-08-01')], analytics });
|
|
350
350
|
await own.C.render({ api: own.api, API: '/api/bongos', builderId: 95, selfId: '95', signedIn: true, now: NOW });
|
|
351
351
|
assert.ok(own.requests.some((u) => u.includes('/analytics/builder/95')), 'own profile: the analytics read');
|
|
352
|
-
assert.ok(own.requests.some((u) => u.includes('/
|
|
353
|
-
assert.ok(!own.requests.some((u) => u.includes('/
|
|
352
|
+
assert.ok(own.requests.some((u) => u.includes('/builders/95/earnings')), 'own profile: the same earnings read as anyone\'s');
|
|
353
|
+
assert.ok(!own.requests.some((u) => u.includes('/credits')), 'never the itemised ledger');
|
|
354
354
|
assert.ok(own.mount.innerHTML.includes('id="chart-rate"'), 'and the pace chart draws');
|
|
355
355
|
assert.ok(own.mount.innerHTML.includes('only you see this one'), 'saying plainly that it is private');
|
|
356
356
|
});
|
|
@@ -365,10 +365,10 @@ test('signed out, the section says why and asks the server for nothing', async (
|
|
|
365
365
|
assert.match(gone.mount.innerHTML, /visible to signed-in builders/, 'a 401 mid-way reads the same');
|
|
366
366
|
});
|
|
367
367
|
|
|
368
|
-
test('an empty record is one honest line, not three empty cards', async () => {
|
|
368
|
+
test('an empty record AND no earnings is one honest line, not three empty cards', async () => {
|
|
369
369
|
const b = bootRender({ rows: [] });
|
|
370
370
|
await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 95, signedIn: true, now: NOW });
|
|
371
|
-
assert.match(b.mount.innerHTML, /
|
|
371
|
+
assert.match(b.mount.innerHTML, /Nothing earned or shipped yet/);
|
|
372
372
|
assert.ok(!b.mount.innerHTML.includes('<figure'), 'no figure drew');
|
|
373
373
|
assert.ok(!b.requests.some((u) => u.includes('/analytics/')), 'and nothing else was asked for');
|
|
374
374
|
});
|
|
@@ -404,47 +404,41 @@ test('a 30-day window drops the older ships from the craft mix and clips the axi
|
|
|
404
404
|
assert.equal(C.craftMix(within).length, 1, 'only the recent craft survives a 30-day window');
|
|
405
405
|
});
|
|
406
406
|
|
|
407
|
-
test('the drachmae line is the
|
|
407
|
+
test('the drachmae line is the earnings by day, cumulated, ending at the balance', async () => {
|
|
408
408
|
const rows = [ship('2026-08-01'), ship('2026-08-20')]; // per-task awards would sum to 20
|
|
409
|
-
|
|
410
|
-
const
|
|
409
|
+
// two kinds on one day are two rows; they fold into one point
|
|
410
|
+
const earnings = [pay('2026-07-05', 30), pay('2026-08-20', 39), pay('2026-08-20', 19, 'task'), pay('2026-09-01', 12)]; // balance 100
|
|
411
|
+
const b = bootRender({ rows, earnings });
|
|
411
412
|
await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 95, signedIn: true, now: NOW });
|
|
412
|
-
assert.ok(b.requests.some((u) => u.
|
|
413
|
-
assert.ok(!b.requests.some((u) => u.includes('/builders/95/credits')), 'not the roster route');
|
|
413
|
+
assert.ok(b.requests.some((u) => u.endsWith('/api/bongos/builders/95/earnings')), 'one earnings read, unpaged');
|
|
414
414
|
const out = b.mount.innerHTML;
|
|
415
415
|
const fig = out.slice(out.indexOf('id="chart-drachmae"'), out.indexOf('id="chart-mix"'));
|
|
416
416
|
assert.ok(fig.includes('>100 drachmae</text>'), 'the endpoint is the ledger balance, not the 20 of per-task awards');
|
|
417
417
|
assert.ok(fig.includes('<td>2026-07-05</td><td class="ledger__num">30 drachmae</td>'), 'a paid day with no ship is still a point');
|
|
418
|
-
assert.ok(fig.includes('<td>2026-08-20</td><td class="ledger__num">88 drachmae</td>'), 'two
|
|
419
|
-
assert.match(fig, /100
|
|
418
|
+
assert.ok(fig.includes('<td>2026-08-20</td><td class="ledger__num">88 drachmae</td>'), 'two kinds on one day fold into one point');
|
|
419
|
+
assert.match(fig, /100 drachmae in all/);
|
|
420
420
|
assert.doesNotMatch(fig, /estimated/);
|
|
421
421
|
});
|
|
422
422
|
|
|
423
|
-
test('another builder\'s
|
|
424
|
-
const
|
|
425
|
-
const b = bootRender({ rows: [ship('2026-08-01')], ledger });
|
|
423
|
+
test('another builder\'s earnings come from the same route, never the itemised ledger', async () => {
|
|
424
|
+
const b = bootRender({ rows: [ship('2026-08-01')], earnings: [pay('2026-08-01', 450)] });
|
|
426
425
|
await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
|
|
427
|
-
|
|
428
|
-
assert.
|
|
429
|
-
assert.ok(
|
|
430
|
-
assert.ok(b.mount.innerHTML.includes('>450 drachmae</text>'), 'and every row counted once');
|
|
426
|
+
assert.ok(b.requests.some((u) => u.includes('/builders/95/earnings')), 'the earnings read');
|
|
427
|
+
assert.ok(!b.requests.some((u) => u.includes('/credits')), 'no ledger read, so no viewer is refused and handed an estimate');
|
|
428
|
+
assert.ok(b.mount.innerHTML.includes('>450 drachmae</text>'));
|
|
431
429
|
});
|
|
432
430
|
|
|
433
|
-
test('
|
|
434
|
-
|
|
435
|
-
const rows = [ship('2026-08-01', { credits_awarded: 0, credits_reward: 45 }), ship('2026-08-20', { credits_awarded: 0, credits_reward: 55 })];
|
|
436
|
-
const b = bootRender({ rows, ledger: 'denied' });
|
|
431
|
+
test('an earnings read that fails says so in its figure; the works charts still draw', async () => {
|
|
432
|
+
const b = bootRender({ rows: [ship('2026-08-01')], earnings: 'failed' });
|
|
437
433
|
await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
|
|
438
434
|
const out = b.mount.innerHTML;
|
|
439
|
-
|
|
440
|
-
assert.ok(
|
|
441
|
-
assert.
|
|
442
|
-
assert.ok(!out.includes('id="chart-rate"'), 'the pace chart stays own-only');
|
|
443
|
-
assert.ok(!out.includes('Could not load'), 'a 403 on the ledger is a fallback, not a failure');
|
|
435
|
+
assert.match(out, /Could not load the earnings/);
|
|
436
|
+
assert.ok(out.includes('id="chart-works"') && out.includes('id="chart-mix"'), 'the record is not taken down with it');
|
|
437
|
+
assert.doesNotMatch(out, />0 drachmae</, 'never a zero it did not read');
|
|
444
438
|
});
|
|
445
439
|
|
|
446
|
-
test('
|
|
447
|
-
const b = bootRender({ rows: [ship('2026-08-01')],
|
|
440
|
+
test('earnings with nothing in them, beside a shipped record, draw one honest line, not a crash', async () => {
|
|
441
|
+
const b = bootRender({ rows: [ship('2026-08-01')], earnings: [] });
|
|
448
442
|
await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 95, signedIn: true, now: NOW });
|
|
449
443
|
const out = b.mount.innerHTML;
|
|
450
444
|
const fig = out.slice(out.indexOf('id="chart-drachmae"'), out.indexOf('id="chart-mix"'));
|
|
@@ -452,14 +446,19 @@ test('a readable ledger with nothing in it draws one honest line, not a crash',
|
|
|
452
446
|
assert.ok(out.includes('id="chart-works"') && out.includes('id="chart-mix"'), 'the other charts are untouched');
|
|
453
447
|
});
|
|
454
448
|
|
|
455
|
-
test('the harness carries
|
|
456
|
-
const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'me__credits.json'), 'utf8'));
|
|
457
|
-
assert.ok(Array.isArray(fx.rows) && fx.rows.length > 1, 'rows to draw');
|
|
458
|
-
assert.equal(fx.page.total, fx.rows.length, 'total pinned to the rows here, or the pager would loop on the same page');
|
|
459
|
-
assert.ok(fx.rows.every((r) => Number.isFinite(r.delta) && Number.isFinite(Date.parse(r.recorded_at))), 'every row is a delta at a time');
|
|
460
|
-
assert.ok(new Set(fx.rows.map((r) => r.recorded_at.slice(0, 10))).size > 1, 'across more than one day, so the line is not flat');
|
|
449
|
+
test('the harness carries earnings fixtures the chart can climb on, each summing to its own total', () => {
|
|
461
450
|
const server = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'server.js'), 'utf8');
|
|
462
|
-
assert.ok(server.includes("[/^builders\\/\\d+\\/
|
|
451
|
+
assert.ok(server.includes("[/^builders\\/\\d+\\/earnings$/, 'builders__earnings']"), 'any id falls back to the generic earnings');
|
|
452
|
+
for (const name of ['builders__earnings', 'builders__2001__earnings', 'builders__2002__earnings']) {
|
|
453
|
+
const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', `${name}.json`), 'utf8'));
|
|
454
|
+
assert.ok(Array.isArray(fx.days) && new Set(fx.days.map((d) => d.day)).size > 1, `${name}: across more than one day, so the line is not flat`);
|
|
455
|
+
assert.ok(fx.days.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d.day) && Number.isFinite(d.credits)), `${name}: every row is credits on a day`);
|
|
456
|
+
const sum = fx.days.reduce((s, d) => s + d.credits, 0);
|
|
457
|
+
assert.equal(sum, fx.total, `${name}: the days add up to the total`);
|
|
458
|
+
for (const [kind, n] of Object.entries(fx.totals)) {
|
|
459
|
+
assert.equal(fx.days.filter((d) => d.kind === kind).reduce((s, d) => s + d.credits, 0), n, `${name}: ${kind} adds up`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
463
462
|
});
|
|
464
463
|
|
|
465
464
|
test('the readout is written with textContent — the hover layer never builds markup from data', () => {
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// tests/profile_earnings.mjs — everyone gets an earnings chart, labelled by kind
|
|
2
|
+
// of credit (task 1004436 / WA6.RP06, goal 1000095, criterion
|
|
3
|
+
// wa6-roles-read-true-in-public).
|
|
4
|
+
//
|
|
5
|
+
// The done-when: "an artist and an ideator fixture with credits and zero shipped
|
|
6
|
+
// tasks both get a labelled earnings chart". Run for real, end to end:
|
|
7
|
+
//
|
|
8
|
+
// 1. THE KINDS. Which kind each ledger reason is, as the read's own SQL says it,
|
|
9
|
+
// and the one kind the reason cannot tell apart (a descent hop shares stream
|
|
10
|
+
// 1's key) held against the WRITER's own description text.
|
|
11
|
+
// 2. THE FOLD. Every kind named, zero days dropped, the parts summing to the
|
|
12
|
+
// whole.
|
|
13
|
+
// 3. THE ROUTE. Signed in only, a bad id refused, an unknown builder a 404, and
|
|
14
|
+
// the read scoped to that one builder.
|
|
15
|
+
// 4. THE PAGE. The REAL profile-charts.js on the REAL kit, fed the harness's
|
|
16
|
+
// artist and ideator fixtures (credits, no shipped task): a figure, its
|
|
17
|
+
// lede, and "where it came from" in the mock's words.
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import vm from 'node:vm';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { makeRunner } from './helpers.mjs';
|
|
26
|
+
|
|
27
|
+
process.env.NODE_ENV = 'test';
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
30
|
+
const { test, summary } = makeRunner();
|
|
31
|
+
|
|
32
|
+
const earnings = require('../modules/economy/earnings.js');
|
|
33
|
+
const credits = require('../modules/economy/credits.js');
|
|
34
|
+
const fixture = (name) => JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures', `${name}.json`), 'utf8'));
|
|
35
|
+
|
|
36
|
+
// ---- 1. the kinds -------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
await test('every kind the SQL can name is a kind the page has words for', () => {
|
|
39
|
+
const named = [...earnings.EARNINGS_SQL.matchAll(/THEN '([a-z-]+)'/g)].map((m) => m[1]);
|
|
40
|
+
named.push('other'); // the ELSE
|
|
41
|
+
assert.deepEqual([...new Set(named)].sort(), [...earnings.KINDS].sort(), 'the SQL and KINDS name the same set');
|
|
42
|
+
const page = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-charts.js'), 'utf8');
|
|
43
|
+
for (const k of earnings.KINDS) assert.ok(page.includes(`${k.includes('-') ? `'${k}'` : k}: '`), `the page has words for ${k}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await test('a descent hop is told from a direct payout by the WRITER\'s own words', () => {
|
|
47
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
|
|
48
|
+
// the descent writer and the direct writer, by their own describe() lines
|
|
49
|
+
const descent = src.match(/describe: \(p\) => `Task #\$\{taskId\} shipped \$\{depth\} hop[^\n]*/);
|
|
50
|
+
const direct = src.match(/describe: \(p\) => `Task #\$\{taskId\} shipped under idea[^\n]*/);
|
|
51
|
+
assert.ok(descent && direct, 'both writers are where this test reads them');
|
|
52
|
+
assert.ok(descent[0].includes(earnings.DESCENT_MARK.trim()), 'the descent writer says DESCENT_MARK');
|
|
53
|
+
assert.ok(!direct[0].includes(earnings.DESCENT_MARK.trim()), 'and the direct one does not, or every idea payout would read as descent');
|
|
54
|
+
// the descent case is checked BEFORE the plain stream-1 case, or it never matches
|
|
55
|
+
const sql = earnings.EARNINGS_SQL;
|
|
56
|
+
assert.ok(sql.indexOf("'idea-descent'") < sql.indexOf("'idea-ship'"), 'descent first');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await test('the LIKE patterns are the economy\'s own prefixes, escaped', async () => {
|
|
60
|
+
let seen = null;
|
|
61
|
+
await earnings.readEarnings('7', { client: { query: async (sql, params) => { seen = { sql, params }; return { rows: [] }; } } });
|
|
62
|
+
assert.equal(seen.params[0], '7', 'scoped to the one builder');
|
|
63
|
+
assert.equal(seen.params[1], credits.ARTIST_PAGE_REASON_PREFIX.replace(/_/g, '\\_') + '%');
|
|
64
|
+
assert.equal(seen.params[2], credits.IDEA_STREAM_REASON_PREFIXES.task_ship.replace(/_/g, '\\_') + '%');
|
|
65
|
+
assert.equal(seen.params[3], credits.IDEA_STREAM_REASON_PREFIXES.full_pass.replace(/_/g, '\\_') + '%');
|
|
66
|
+
assert.equal(seen.params[4], credits.IDEA_STREAM_REASON_PREFIXES.governance_pass.replace(/_/g, '\\_') + '%');
|
|
67
|
+
assert.equal(seen.params[5], `%${earnings.DESCENT_MARK}%`);
|
|
68
|
+
assert.match(seen.sql, /WHERE builder_id = \$1\b/, 'never the whole ledger');
|
|
69
|
+
assert.doesNotMatch(seen.sql.split('FROM')[0].replace(/COALESCE\(description[^)]*\)/, ''), /description|task_id|session_id/, 'no itemisation leaves the read');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---- 2. the fold --------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
await test('the fold names every kind, drops zero days, sums to the whole and sorts by day', () => {
|
|
75
|
+
const out = earnings.foldEarnings([
|
|
76
|
+
{ day: '2026-09-02', kind: 'page', credits: 30 },
|
|
77
|
+
{ day: '2026-09-01', kind: 'idea-ship', credits: 12.4 },
|
|
78
|
+
{ day: '2026-09-01', kind: 'session', credits: 0 },
|
|
79
|
+
{ day: '2026-09-03', kind: 'something-new', credits: 5 },
|
|
80
|
+
]);
|
|
81
|
+
assert.deepEqual(Object.keys(out.totals).sort(), [...earnings.KINDS].sort(), 'every kind, zero where nothing was earned');
|
|
82
|
+
assert.equal(out.totals.page, 30);
|
|
83
|
+
assert.equal(out.totals['idea-ship'], 12);
|
|
84
|
+
assert.equal(out.totals.other, 5, 'a kind the table does not know is still counted');
|
|
85
|
+
assert.equal(out.total, 47);
|
|
86
|
+
assert.deepEqual(out.days.map((d) => d.day), ['2026-09-01', '2026-09-02', '2026-09-03'], 'the zero day is gone');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---- 3. the route -------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
await test('the route: signed in only, a bad id refused, an unknown builder a 404', async () => {
|
|
92
|
+
const api = require('../src/module-api.js');
|
|
93
|
+
// The read itself is proven above through its `client` seam, against its own
|
|
94
|
+
// SQL; here it is swapped before the route loads (the route takes it at
|
|
95
|
+
// require time), so the test never opens a database pool.
|
|
96
|
+
const realRead = earnings.readEarnings;
|
|
97
|
+
const realGet = api.getBuilderById;
|
|
98
|
+
const realRequire = api.requireBuilder;
|
|
99
|
+
const asked = [];
|
|
100
|
+
earnings.readEarnings = async (id) => { asked.push([id]); return earnings.foldEarnings([{ day: '2026-09-01', kind: 'page', credits: 30 }]); };
|
|
101
|
+
api.getBuilderById = async (id) => (String(id) === '2001' ? { id: 2001 } : null);
|
|
102
|
+
let signedIn = true;
|
|
103
|
+
api.requireBuilder = (req, res, next) => (signedIn ? next() : res.status(401).json({ error: 'unauthorized' }));
|
|
104
|
+
const express = require('express');
|
|
105
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
106
|
+
const app = express();
|
|
107
|
+
app.use(attachFail);
|
|
108
|
+
app.use(require('../modules/economy/routes/earnings.js')());
|
|
109
|
+
const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
|
|
110
|
+
const get = async (p) => { const r = await fetch(`http://127.0.0.1:${server.address().port}${p}`); return { status: r.status, body: await r.json() }; };
|
|
111
|
+
try {
|
|
112
|
+
const ok = await get('/builders/2001/earnings');
|
|
113
|
+
assert.equal(ok.status, 200);
|
|
114
|
+
assert.equal(ok.body.total, 30);
|
|
115
|
+
assert.deepEqual(ok.body.days, [{ day: '2026-09-01', kind: 'page', credits: 30 }]);
|
|
116
|
+
assert.equal(String(asked.at(-1)[0]), '2001', 'the read is that builder\'s rows');
|
|
117
|
+
assert.equal((await get('/builders/abc/earnings')).status, 400);
|
|
118
|
+
assert.equal((await get('/builders/9999/earnings')).status, 404);
|
|
119
|
+
signedIn = false;
|
|
120
|
+
assert.equal((await get('/builders/2001/earnings')).status, 401, 'owner decision (a): earnings need a sign-in');
|
|
121
|
+
} finally {
|
|
122
|
+
earnings.readEarnings = realRead;
|
|
123
|
+
api.getBuilderById = realGet;
|
|
124
|
+
api.requireBuilder = realRequire;
|
|
125
|
+
server.closeAllConnections();
|
|
126
|
+
await new Promise((r) => server.close(r));
|
|
127
|
+
// Let the closed sockets' handles finish closing before the runner's
|
|
128
|
+
// process.exit, or Windows' libuv aborts on a handle still mid-close.
|
|
129
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await test('the economy module mounts the route', () => {
|
|
134
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/economy/module.json'), 'utf8'));
|
|
135
|
+
assert.ok(manifest.contributes.routes.includes('earnings'));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ---- 4. the page --------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
141
|
+
const NOW = Date.parse('2026-10-01T12:00:00Z');
|
|
142
|
+
|
|
143
|
+
function bootCharts(route) {
|
|
144
|
+
const requests = [];
|
|
145
|
+
const mount = { innerHTML: '', querySelector: () => null };
|
|
146
|
+
const controls = { innerHTML: '', addEventListener() {}, querySelectorAll: () => [] };
|
|
147
|
+
const api = {
|
|
148
|
+
request: async (method, url) => {
|
|
149
|
+
requests.push(url);
|
|
150
|
+
const hit = route(url);
|
|
151
|
+
return hit ? { status: 200, ok: true, data: hit } : { status: 404, ok: false, data: null };
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
const sandbox = {
|
|
155
|
+
window: {
|
|
156
|
+
__BRANDING__: { currency: { label: 'credits' } },
|
|
157
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
158
|
+
OTB: {
|
|
159
|
+
roleAxisLabel: () => 'Role',
|
|
160
|
+
roleLabel: (d) => d,
|
|
161
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
162
|
+
$: (sel) => (sel === '#charts-grid' ? mount : sel === '#charts-controls' ? controls : null),
|
|
163
|
+
fmtNum: (n) => String(n ?? 0),
|
|
164
|
+
fmtDate: (iso) => String(iso ?? '').slice(0, 10),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
console, Date, Map, Set, Math, Number, String, Array, Object, Infinity, URLSearchParams, Promise,
|
|
168
|
+
};
|
|
169
|
+
sandbox.globalThis = sandbox;
|
|
170
|
+
vm.createContext(sandbox);
|
|
171
|
+
for (const f of ['hall-kit.js', 'profile-charts.js']) {
|
|
172
|
+
vm.runInContext(fs.readFileSync(path.join(HALL, f), 'utf8'), sandbox, { filename: f });
|
|
173
|
+
}
|
|
174
|
+
return { C: sandbox.window.OTBProfileCharts, api, requests, mount };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// the harness's own fixtures: an artist and an ideator who never shipped a task
|
|
178
|
+
const PEOPLE = [
|
|
179
|
+
{
|
|
180
|
+
who: 'artist', id: 2001, name: 'Builder 7',
|
|
181
|
+
lede: 'Builder 7 has never shipped a task. Every credit here came from approved pages and ideas.',
|
|
182
|
+
kinds: [['Pages approved', '370'], ['Ideas that became shipped work', '42']],
|
|
183
|
+
end: '412 credits',
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
who: 'ideator', id: 2002, name: 'Builder 9',
|
|
187
|
+
lede: 'Builder 9 has never shipped a task. Every credit here came from ideas.',
|
|
188
|
+
kinds: [['Ideas that became shipped work', '96'], ['Work split from their ideas', '38'], ['Ratified by the board', '30'], ['Full Ideas passed', '22']],
|
|
189
|
+
end: '186 credits',
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
for (const P of PEOPLE) {
|
|
194
|
+
await test(`the ${P.who} fixture, with credits and no shipped task, gets a labelled earnings chart`, async () => {
|
|
195
|
+
const activity = fixture(`builders__${P.id}__activity`);
|
|
196
|
+
const earned = fixture(`builders__${P.id}__earnings`);
|
|
197
|
+
assert.equal(activity.rows.length, 0, 'the fixture really has no shipped task');
|
|
198
|
+
const b = bootCharts((url) => (url.includes('/activity') ? activity : url.includes('/earnings') ? earned : null));
|
|
199
|
+
await b.C.render({ api: b.api, API: '/api/bongos', builderId: P.id, selfId: 1, signedIn: true, name: P.name, now: NOW });
|
|
200
|
+
const out = b.mount.innerHTML;
|
|
201
|
+
assert.ok(out.includes('id="chart-drachmae"'), 'the earnings figure drew');
|
|
202
|
+
assert.ok(out.includes('Earnings over time'));
|
|
203
|
+
assert.ok(out.includes(P.lede), 'the lede says who never shipped, and where the credits came from');
|
|
204
|
+
assert.ok(out.includes(`>${P.end}</text>`), 'the line ends at the balance');
|
|
205
|
+
assert.ok(out.includes('where it came from'));
|
|
206
|
+
// "where it came from", in order, with its numbers
|
|
207
|
+
const rows = [...out.matchAll(/<span class="kit-bars__label">([^<]+)<\/span>[\s\S]*?<span class="kit-bars__val">([^<]+)<\/span>/g)].map((m) => [m[1], m[2]]);
|
|
208
|
+
assert.deepEqual(rows, P.kinds, 'every kind that earned, largest first, in the mock\'s words');
|
|
209
|
+
assert.ok(out.includes('No works shipped yet'), 'the works charts say "none yet" on their own');
|
|
210
|
+
assert.ok(!out.includes('id="chart-works"'), 'and draw no empty card');
|
|
211
|
+
assert.ok(!b.requests.some((u) => u.includes('/credits')), 'the itemised ledger is never read');
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await test('the kinds follow the lookback window; the line still ends at the all-time total', () => {
|
|
216
|
+
const b = bootCharts(() => null);
|
|
217
|
+
const days = [{ day: '2026-06-01', kind: 'page', credits: 100 }, { day: '2026-09-25', kind: 'idea-ship', credits: 5 }];
|
|
218
|
+
assert.deepEqual(JSON.parse(JSON.stringify(b.C.earningsKinds(days))).map((k) => k.kind), ['page', 'idea-ship']);
|
|
219
|
+
assert.deepEqual(JSON.parse(JSON.stringify(b.C.earningsKinds(days.slice(1)))).map((k) => k.kind), ['idea-ship'], 'a 30-day window drops June');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await test('the lede: your own profile says "you"; someone who ships gets the plain sentence', () => {
|
|
223
|
+
const { C } = bootCharts(() => null);
|
|
224
|
+
const kinds = [{ kind: 'idea-ship', credits: 5 }];
|
|
225
|
+
assert.equal(C.earningsLede({ isOwn: true, shipped: false, kinds }), 'You have never shipped a task. Every credit here came from ideas.');
|
|
226
|
+
assert.equal(C.earningsLede({ name: 'Mara', isOwn: false, shipped: true, kinds }), 'Every credit Mara has earned, by the kind of work that earned it.');
|
|
227
|
+
assert.equal(C.earningsLede({ isOwn: true, shipped: true, kinds }), 'Every credit you have earned, by the kind of work that earned it.');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await test('signed out, nothing is asked for and the line says why', async () => {
|
|
231
|
+
const b = bootCharts(() => ({}));
|
|
232
|
+
await b.C.render({ api: b.api, API: '/api/bongos', builderId: 2002, selfId: null, signedIn: false, now: NOW });
|
|
233
|
+
assert.equal(b.requests.length, 0);
|
|
234
|
+
assert.match(b.mount.innerHTML, /Earnings and shipped work are visible to signed-in builders/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
summary();
|
|
@@ -117,6 +117,23 @@ test('building and creating say where it is, with the stage marked', () => {
|
|
|
117
117
|
assert.match(w.html({ app: { ...BUILDING.app, mode: 'adopt' } }), /Linked\. Waiting for your app to answer/);
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
// Task 1004502: past the time box the card stops saying "building" and sends the owner to
|
|
121
|
+
// Render's deploy history, while the watch keeps asking (the app may still come up).
|
|
122
|
+
test('a quiet build: no "building", the way to Render\'s deploy history, and the watch keeps going', () => {
|
|
123
|
+
const w = load();
|
|
124
|
+
const QUIET = { ok: true, app: { ...BUILDING.app, stage: 'quiet', quiet: true,
|
|
125
|
+
links: { ...LINKS, deploys: 'https://dashboard.render.com/web/srv-aaaaaaaa1111/events' } }, env_vars: [] };
|
|
126
|
+
const html = w.html(QUIET);
|
|
127
|
+
assert.doesNotMatch(html, /Render is building your app/);
|
|
128
|
+
assert.doesNotMatch(html, /appStages/, 'no progress rail claims a stage');
|
|
129
|
+
assert.match(html, /<p>Your app hasn’t answered for over 20 minutes\. Render may have stopped the deploy — its deploy history says why\.<\/p>/);
|
|
130
|
+
assert.match(html, /<a href="https:\/\/dashboard\.render\.com\/web\/srv-aaaaaaaa1111\/events" target="_blank" rel="noopener noreferrer">See its deploys on Render<\/a>/);
|
|
131
|
+
assert.match(html, /If it starts answering, this changes to Live by itself\./);
|
|
132
|
+
assert.equal(w.appTerminal(QUIET), false, 'the watch keeps asking');
|
|
133
|
+
const adopt = w.html({ ...QUIET, app: { ...QUIET.app, mode: 'adopt' } });
|
|
134
|
+
assert.match(adopt, /Your app hasn’t answered for over 20 minutes/);
|
|
135
|
+
});
|
|
136
|
+
|
|
120
137
|
test('a failure: the sentence, a Retry, and no promise of a retry by itself', () => {
|
|
121
138
|
const w = load();
|
|
122
139
|
const v = w.view(FAILED);
|
|
@@ -48,7 +48,8 @@ await ta('each runner state maps to the stage the owner sees', () => {
|
|
|
48
48
|
await ta('a live app carries its address and the two Render links', () => {
|
|
49
49
|
const s = appStatus({ ...HOST, state: 'live' });
|
|
50
50
|
assert.equal(s.url, 'https://demo.onrender.com');
|
|
51
|
-
assert.deepEqual(s.links, { service: 'https://dashboard.render.com/web/srv-aaaaaaaa1111', env: 'https://dashboard.render.com/web/srv-aaaaaaaa1111/env'
|
|
51
|
+
assert.deepEqual(s.links, { service: 'https://dashboard.render.com/web/srv-aaaaaaaa1111', env: 'https://dashboard.render.com/web/srv-aaaaaaaa1111/env',
|
|
52
|
+
deploys: 'https://dashboard.render.com/web/srv-aaaaaaaa1111/events' });
|
|
52
53
|
assert.equal(s.error, null);
|
|
53
54
|
assert.equal(s.retry, null);
|
|
54
55
|
});
|
|
@@ -75,6 +76,29 @@ await ta('a run still holding the lent key reads as waiting, not as something to
|
|
|
75
76
|
assert.equal(s.retry, null);
|
|
76
77
|
});
|
|
77
78
|
|
|
79
|
+
// Task 1004502: Render can give up on a deploy (project 43 timed out in 15 minutes) and the
|
|
80
|
+
// address check alone can never learn it, so a build that has not answered in QUIET_AFTER_MS
|
|
81
|
+
// stops claiming to be building and sends the owner to Render's own deploy history.
|
|
82
|
+
await ta('a build with no answer past the time box reads "quiet", not "building" (task 1004502)', () => {
|
|
83
|
+
const { QUIET_AFTER_MS } = require('../modules/provisioning/app-status.js');
|
|
84
|
+
assert.equal(QUIET_AFTER_MS, 20 * 60_000);
|
|
85
|
+
const t0 = Date.parse('2026-10-01T10:00:00Z');
|
|
86
|
+
const at = (state, ms) => appStatus({ ...HOST, state, updatedAt: new Date(t0).toISOString() }, { now: t0 + ms });
|
|
87
|
+
assert.equal(at('deploying', QUIET_AFTER_MS - 1000).quiet, false, 'still inside the time box');
|
|
88
|
+
assert.equal(at('deploying', QUIET_AFTER_MS - 1000).stage, 'building');
|
|
89
|
+
const q = at('deploying', QUIET_AFTER_MS + 1000);
|
|
90
|
+
assert.equal(q.quiet, true);
|
|
91
|
+
assert.equal(q.stage, 'quiet', 'it no longer reads as building');
|
|
92
|
+
assert.equal(q.links.deploys, 'https://dashboard.render.com/web/srv-aaaaaaaa1111/events');
|
|
93
|
+
assert.equal(q.error, null, 'not a failure: the platform cannot see Render\'s verdict');
|
|
94
|
+
assert.equal(q.retry, null);
|
|
95
|
+
assert.equal(at('linked', QUIET_AFTER_MS + 1000).stage, 'quiet', 'an adopted app waits the same way');
|
|
96
|
+
assert.equal(at('live', QUIET_AFTER_MS * 10).stage, 'live', 'a live app is never quiet');
|
|
97
|
+
assert.equal(at('creating', QUIET_AFTER_MS * 10).quiet, false, 'only a build is time-boxed');
|
|
98
|
+
assert.equal(appStatus({ ...HOST, state: 'deploying' }, { now: t0 }).quiet, false, 'no start time → no claim');
|
|
99
|
+
assert.equal(appStatus({ ...HOST, state: 'deploying', updatedAt: 'nonsense' }, { now: t0 }).quiet, false);
|
|
100
|
+
});
|
|
101
|
+
|
|
78
102
|
console.log('\nGET /provisioning/instances/:id/render:');
|
|
79
103
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
80
104
|
const envManifest = require('../modules/provisioning/env-manifest.js');
|