@bongos/core 1.19.534 → 1.19.536
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 +74 -39
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +186 -3
- package/docs/api-reference.md +10 -2
- package/docs/copy-inventory.md +64 -43
- package/docs/copy-registry.json +293 -62
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
- package/modules/government/catalog.js +1 -0
- package/modules/government/migrations/government_010_project_settings_manage.sql +58 -0
- package/modules/hall-ui/public/hall-kit.js +93 -7
- package/modules/hall-ui/public/project-settings.css +90 -0
- package/modules/hall-ui/public/project-settings.html +68 -0
- package/modules/hall-ui/public/project-settings.js +249 -0
- package/modules/hall-ui/public/roadmap-timeline.js +105 -0
- package/modules/hall-ui/public/roadmap.html +7 -1
- package/modules/hall-ui/public/roadmap.js +16 -0
- package/modules/hall-ui/public/shell.js +3 -0
- package/modules/hall-ui/public/style.css +4 -2
- package/modules/hall-ui/public/watch.html +4 -0
- package/modules/hall-ui/public/watch.js +116 -0
- package/modules/lifecycle/goal-category.js +82 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/goals.js +16 -0
- package/modules/lifecycle/routes/project-settings.js +263 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +6 -0
- package/src/bongos/serve-internal.js +12 -1
- package/src/module-api.js +13 -1
- package/tests/hall_kit_charts.mjs +173 -0
- package/tests/hall_nav.mjs +3 -1
- package/tests/hall_oversight_world.mjs +159 -0
- package/tests/hall_page_gate_map.mjs +4 -0
- package/tests/hall_reading_world.mjs +153 -0
- package/tests/project_settings_page.mjs +337 -0
|
@@ -349,6 +349,10 @@ test('the kit is loaded before every page that draws through it', () => {
|
|
|
349
349
|
['index.html', 'hall-render.js'],
|
|
350
350
|
['watch.html', 'watch.js'],
|
|
351
351
|
['profile.html', 'profile-charts.js'],
|
|
352
|
+
// task 1003447: the roadmap's own series module reads the kit, and the page
|
|
353
|
+
// reads that. Both edges of the chain are ordered here.
|
|
354
|
+
['roadmap.html', 'roadmap-timeline.js'],
|
|
355
|
+
['roadmap.html', 'roadmap.js'],
|
|
352
356
|
]) {
|
|
353
357
|
// The loaded ORDER, read off the script tags — index.html's prose mentions
|
|
354
358
|
// hall-render.js ~90 lines above where it loads it, so a substring search
|
|
@@ -375,3 +379,172 @@ test('the migrated pages run for real against the kit and emit its classes', ()
|
|
|
375
379
|
/class="kit-bars"/,
|
|
376
380
|
);
|
|
377
381
|
});
|
|
382
|
+
|
|
383
|
+
// ---- 5. the SPAN bar and the fitted axis (task 1003447) --------------------
|
|
384
|
+
//
|
|
385
|
+
// Two options, both added so the goal's last two charts needed no new drawing
|
|
386
|
+
// code and no new class family: a bar that starts somewhere other than the
|
|
387
|
+
// track's left edge (which is what makes a bar series a TIMELINE), and a plot
|
|
388
|
+
// whose axis sits at something other than zero (which is what makes a step line
|
|
389
|
+
// readable for a LEVEL rather than a running total).
|
|
390
|
+
|
|
391
|
+
test('a row\'s `start` offsets the bar and leaves its length alone', () => {
|
|
392
|
+
// The roadmap's shape: version A ran for the first quarter of the axis,
|
|
393
|
+
// version B for the middle half. Same primitive, same classes.
|
|
394
|
+
const html = kit.barSeriesHtml([
|
|
395
|
+
{ label: 'A', value: 25 },
|
|
396
|
+
{ label: 'B', value: 50, start: 25 },
|
|
397
|
+
], { max: 100 });
|
|
398
|
+
const fills = [...html.matchAll(/class="kit-bars__fill" style="([^"]+)"/g)].map((m) => m[1]);
|
|
399
|
+
assert.deepEqual(fills, ['width:25%', 'margin-left:25%;width:50%']);
|
|
400
|
+
assert.equal((html.match(/class="kit-bars"/g) || []).length, 1, 'still one class family');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('a bar with no `start` writes no offset at all', () => {
|
|
404
|
+
// The compatibility contract: every caller that predates the span option gets
|
|
405
|
+
// byte-identical markup, so `margin-left:0%` never appears in a diff or in a
|
|
406
|
+
// snapshot of an existing chart.
|
|
407
|
+
const html = kit.barSeriesHtml([{ label: 'a', value: 3 }, { label: 'b', value: 1, start: 0 }]);
|
|
408
|
+
assert.doesNotMatch(html, /margin-left/);
|
|
409
|
+
// A nonsense start is ignored rather than written through into the style.
|
|
410
|
+
const junk = kit.barSeriesHtml([{ label: 'a', value: 3, start: 'nope' }], { max: 10 });
|
|
411
|
+
assert.doesNotMatch(junk, /margin-left/);
|
|
412
|
+
assert.doesNotMatch(junk, /NaN/);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test('a tiny non-zero `start` still offsets, so "first" stays a real claim', () => {
|
|
416
|
+
// The offset takes the same floor the width does. Without it a span beginning
|
|
417
|
+
// just after the axis origin rounds to 0% and draws flush left — which on a
|
|
418
|
+
// timeline reads as "this one started first", a claim about ordering rather
|
|
419
|
+
// than a rounding artefact.
|
|
420
|
+
const html = kit.barSeriesHtml([
|
|
421
|
+
{ label: 'first', value: 500 },
|
|
422
|
+
{ label: 'barely later', value: 500, start: 1 },
|
|
423
|
+
], { max: 1000 });
|
|
424
|
+
const fills = [...html.matchAll(/class="kit-bars__fill" style="([^"]+)"/g)].map((m) => m[1]);
|
|
425
|
+
assert.equal(fills[0], 'width:50%', 'the one at the origin carries no offset');
|
|
426
|
+
assert.match(fills[1], /^margin-left:1%/, 'and the one after it is visibly after it');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('a positive value never draws nothing, however small its share', () => {
|
|
430
|
+
// Rounding alone sends anything under half a percent to `width:0%`, which
|
|
431
|
+
// reads as "none" — the one thing it is not. Load-bearing for the roadmap: a
|
|
432
|
+
// version that started and shipped inside one day is a real span.
|
|
433
|
+
const html = kit.barSeriesHtml([{ label: 'tiny', value: 1 }, { label: 'huge', value: 100000 }]);
|
|
434
|
+
assert.match(html, /width:1%/, 'the sliver survives');
|
|
435
|
+
// and zero is still zero: an empty track is the honest picture of nothing.
|
|
436
|
+
assert.match(kit.barSeriesHtml([{ label: 'a', value: 0 }, { label: 'b', value: 9 }]), /width:0%/);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('plotWindow puts a reachable-zero series on a zero floor', () => {
|
|
440
|
+
const w = kit.plotWindow([0, 3, 1, 4]);
|
|
441
|
+
assert.equal(w.yMin, 0, 'a count sits on zero — the distance from it IS the reading');
|
|
442
|
+
assert.ok(w.yMax > 4, 'with air above, so the peak does not ride the top edge');
|
|
443
|
+
assert.equal(w.lo, 0);
|
|
444
|
+
assert.equal(w.hi, 4);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('plotWindow fits the floor for a level, which is why the caller must say so', () => {
|
|
448
|
+
const w = kit.plotWindow([24880, 27195], { zeroBased: false });
|
|
449
|
+
assert.ok(w.yMin > 24000 && w.yMin < 24880, 'the axis rises to just under the minimum');
|
|
450
|
+
assert.ok(w.yMax > 27195);
|
|
451
|
+
// The whole point: on a zero floor this month of change would occupy the top
|
|
452
|
+
// 9% of the box. Fitted, it occupies the whole of it.
|
|
453
|
+
const zero = kit.plotWindow([24880, 27195]);
|
|
454
|
+
assert.ok((w.yMax - w.yMin) < (zero.yMax - zero.yMin) / 5, 'the fitted window is far tighter');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('plotWindow survives a flat series and an empty one', () => {
|
|
458
|
+
// A flat series has no range to take a fraction of, and dividing by it is how
|
|
459
|
+
// a chart renders as an invisible NaN.
|
|
460
|
+
const flat = kit.plotWindow([5, 5, 5]);
|
|
461
|
+
assert.ok(flat.yMax > 5, 'flat still gets air, so the line is not welded to the top');
|
|
462
|
+
const flatFit = kit.plotWindow([5, 5, 5], { zeroBased: false });
|
|
463
|
+
assert.ok(flatFit.yMin < 5 && flatFit.yMax > 5, 'and a fitted flat line lands mid-box');
|
|
464
|
+
const zeros = kit.plotWindow([0, 0, 0]);
|
|
465
|
+
assert.equal(zeros.yMin, 0);
|
|
466
|
+
assert.ok(zeros.yMax > 0, 'a month of honest zeros still divides by something');
|
|
467
|
+
assert.deepEqual(kit.plotWindow([]), { lo: 0, hi: 0, yMin: 0, yMax: 1, n: 0 });
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('plotWindow DROPS a non-finite reading rather than counting it as zero', () => {
|
|
471
|
+
// The trap this exists to close: Number(null) is 0 and finite, so a snapshot
|
|
472
|
+
// that predates a column would drag `lo` to zero and draw a cliff.
|
|
473
|
+
const w = kit.plotWindow([100, null, 120, undefined, 110]);
|
|
474
|
+
assert.equal(w.lo, 100);
|
|
475
|
+
assert.equal(w.n, 3, 'three readings, not five');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test('yMin lifts the axis, and its default leaves every existing series where it was', () => {
|
|
479
|
+
const box = { width: 100, height: 100, pad: { l: 0, r: 0, t: 0, b: 0 } };
|
|
480
|
+
const pts = [{ x: 0, value: 10 }, { x: 10, value: 20 }];
|
|
481
|
+
// Default (0): 20 of a 20-high window is the top, 10 is halfway.
|
|
482
|
+
const zero = kit.timeStepGeometry(pts, { x0: 0, x1: 10, yMax: 20, ...box });
|
|
483
|
+
assert.equal(zero.py(20).toFixed(1), '0.0');
|
|
484
|
+
assert.equal(zero.py(10).toFixed(1), '50.0');
|
|
485
|
+
// Fitted to 10..20: the same two points now span the whole box.
|
|
486
|
+
const fitted = kit.timeStepGeometry(pts, { x0: 0, x1: 10, yMin: 10, yMax: 20, ...box });
|
|
487
|
+
assert.equal(fitted.py(20).toFixed(1), '0.0');
|
|
488
|
+
assert.equal(fitted.py(10).toFixed(1), '100.0');
|
|
489
|
+
// A degenerate window (yMin === yMax) must not divide by zero.
|
|
490
|
+
const flat = kit.timeStepGeometry(pts, { x0: 0, x1: 10, yMin: 5, yMax: 5, ...box });
|
|
491
|
+
assert.doesNotMatch(flat.d, /NaN|Infinity/);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test('RUN: the roadmap timeline draws the real versions as spans on one axis', async () => {
|
|
495
|
+
// The page's own series module, run against the checked-in /public/versions
|
|
496
|
+
// fixture — the arithmetic a source assertion cannot reach. It is imported
|
|
497
|
+
// rather than stubbed for the same reason the kit is: a copy of the maths
|
|
498
|
+
// agreeing with itself proves nothing about what a reader gets.
|
|
499
|
+
const modPath = path.join(HALL, 'roadmap-timeline.js');
|
|
500
|
+
const tl = await import(`file://${modPath.replace(/\\/g, '/')}`).then((m) => m.default);
|
|
501
|
+
// public__versions.json, NOT versions.json — the deck calls /public/versions,
|
|
502
|
+
// and the authenticated list is a different payload. Reading the wrong one
|
|
503
|
+
// here is precisely the trap this task's own CLAUDE.md note warns about.
|
|
504
|
+
const fixture = JSON.parse(fs.readFileSync(
|
|
505
|
+
path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'public__versions.json'), 'utf8'));
|
|
506
|
+
const all = fixture.versions.map((v) => ({ id: v.id, start: v.started_at, ship: v.shipped_at }));
|
|
507
|
+
const now = Date.parse('2026-09-03T12:00:00Z');
|
|
508
|
+
|
|
509
|
+
const rows = tl.timelineRows(all, now);
|
|
510
|
+
assert.equal(rows.length, all.length, 'every fixture version has a start date');
|
|
511
|
+
const starts = rows.map((r) => r.from);
|
|
512
|
+
assert.deepEqual(starts, [...starts].sort((a, b) => a - b), 'oldest first');
|
|
513
|
+
assert.ok(rows.some((r) => r.open), 'the in-flight version runs to today');
|
|
514
|
+
assert.ok(rows.every((r) => r.to >= r.from), 'no span runs backwards');
|
|
515
|
+
|
|
516
|
+
const card = tl.timelineCardHtml(all, now, kit);
|
|
517
|
+
assert.equal(card.count, rows.length);
|
|
518
|
+
assert.doesNotMatch(card.html, /NaN|Infinity|undefined/);
|
|
519
|
+
assert.match(card.html, /class="kit-bars"/, 'drawn by the kit, not by hand');
|
|
520
|
+
const offsets = [...card.html.matchAll(/margin-left:(\d+)%/g)].map((m) => Number(m[1]));
|
|
521
|
+
assert.ok(offsets.length >= 2, 'later versions start further along the axis');
|
|
522
|
+
assert.deepEqual(offsets, [...offsets].sort((a, b) => a - b), 'and their offsets grow with their start dates');
|
|
523
|
+
// PMS-V1 started and shipped inside ONE day of a four-month axis: the span
|
|
524
|
+
// that rounds to nothing without the 1% floor. It is also the earliest
|
|
525
|
+
// version, so it correctly carries no offset — assert the floor, not a
|
|
526
|
+
// margin-left the axis origin has no reason to write.
|
|
527
|
+
const pms = card.html.match(/PMS-V1<\/span><span class="kit-bars__track"[^>]*><span class="kit-bars__fill" style="([^"]+)"/);
|
|
528
|
+
assert.ok(pms, 'PMS-V1 has a bar');
|
|
529
|
+
assert.match(pms[1], /width:[1-9]/, 'a one-day span still draws, rather than rounding to nothing');
|
|
530
|
+
assert.doesNotMatch(pms[1], /margin-left/, 'and the earliest version sits at the axis origin');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('RUN: the timeline refuses to draw rather than drawing an empty axis', async () => {
|
|
534
|
+
const modPath = path.join(HALL, 'roadmap-timeline.js');
|
|
535
|
+
const tl = await import(`file://${modPath.replace(/\\/g, '/')}`).then((m) => m.default);
|
|
536
|
+
const now = Date.parse('2026-09-03T12:00:00Z');
|
|
537
|
+
assert.equal(tl.timelineCardHtml([], now, kit), null, 'nothing at all');
|
|
538
|
+
assert.equal(tl.timelineCardHtml([{ id: 'A', start: '2026-01-01' }], now, kit), null,
|
|
539
|
+
'one version is not a timeline');
|
|
540
|
+
// A version with no start date is left OUT and counted in the foot, never
|
|
541
|
+
// drawn at the axis origin — which would read as "began with the project".
|
|
542
|
+
const card = tl.timelineCardHtml([
|
|
543
|
+
{ id: 'A', start: '2026-01-01', ship: '2026-02-01' },
|
|
544
|
+
{ id: 'B', start: '2026-03-01', ship: null },
|
|
545
|
+
{ id: 'C', start: null, ship: null },
|
|
546
|
+
], now, kit);
|
|
547
|
+
assert.equal(card.count, 2);
|
|
548
|
+
assert.doesNotMatch(card.html, />C</, 'the unstarted version is not a bar');
|
|
549
|
+
assert.match(card.html, /1 version with no start date is not shown/, 'the foot says so');
|
|
550
|
+
});
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -170,7 +170,9 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
170
170
|
assert.deepEqual(nav[3].items.map((i) => i.id),
|
|
171
171
|
['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
|
|
172
172
|
// 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
|
|
173
|
-
|
|
173
|
+
// 'project-settings' joins the Government group at the metic floor (task 1003280,
|
|
174
|
+
// goal 1000072) — it sits before 'government', which is the one archon-only item.
|
|
175
|
+
assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'harbor', 'gate', 'sessions', 'project-settings', 'government']);
|
|
174
176
|
});
|
|
175
177
|
|
|
176
178
|
test('Economy lives in Docs and points at the reward-schedule page', () => {
|
|
@@ -11,6 +11,7 @@ import test from 'node:test';
|
|
|
11
11
|
import assert from 'node:assert/strict';
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
+
import vm from 'node:vm';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
|
|
16
17
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -116,3 +117,161 @@ test('the vocabulary wall holds: the family is named oversight on the government
|
|
|
116
117
|
assert.ok(!/governance/i.test(read(f)), `${f} does not say the pre-ADR-0174 word`);
|
|
117
118
|
}
|
|
118
119
|
});
|
|
120
|
+
|
|
121
|
+
// ---- the repo-health trend (task 1003447, `legibility-series-are-drawn`) ----
|
|
122
|
+
//
|
|
123
|
+
// /public/repo-health has always answered with `latest` PLUS a trend[], and the
|
|
124
|
+
// route's own comment says the trend exists "so the status page can draw LOC /
|
|
125
|
+
// dead-exports / stale-branch lines over time ... oldest->newest so the
|
|
126
|
+
// dashboard can plot left->right". Watch requested it WITH ?days=30 — paying for
|
|
127
|
+
// the whole window — and renderLedger read one snapshot off it. The series
|
|
128
|
+
// arrived over the wire and went on the floor.
|
|
129
|
+
//
|
|
130
|
+
// The AXIS arithmetic is the kit's (plotWindow / timeStepSeriesHtml) and
|
|
131
|
+
// tests/hall_kit_charts.mjs runs it for real. What lives here is Watch's half:
|
|
132
|
+
// that the section composes the kit rather than drawing by hand, and — the part
|
|
133
|
+
// no source assertion can reach — that booting the REAL page against the REAL
|
|
134
|
+
// checked-in fixture puts four finite plots on the panel.
|
|
135
|
+
|
|
136
|
+
const FIXTURES = path.join(ROOT, 'scripts', 'hall-preview', 'fixtures');
|
|
137
|
+
const repoHealthFixture = () => JSON.parse(fs.readFileSync(path.join(FIXTURES, 'public__repo-health.json'), 'utf8'));
|
|
138
|
+
|
|
139
|
+
// Boot watch.js in a stub DOM with the real dom-utils and the real kit, answer
|
|
140
|
+
// /me as an archon and /public/repo-health with the fixture, and hand back what
|
|
141
|
+
// landed in #ledger-trend. Every OTHER endpoint answers 500, which is the
|
|
142
|
+
// page's own design (each section loads independently) and keeps this harness
|
|
143
|
+
// to the one panel it is about.
|
|
144
|
+
async function bootWatchLedger(overrides = {}) {
|
|
145
|
+
const payload = { ...repoHealthFixture(), ...overrides };
|
|
146
|
+
const els = new Map();
|
|
147
|
+
const makeEl = () => ({
|
|
148
|
+
dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
|
|
149
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
150
|
+
setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
|
|
151
|
+
addEventListener() {}, appendChild() {}, insertAdjacentHTML(_, h) { this._html += h; },
|
|
152
|
+
closest() { return null; },
|
|
153
|
+
querySelector() { return null; }, querySelectorAll() { return []; },
|
|
154
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
155
|
+
});
|
|
156
|
+
const documentObj = {
|
|
157
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
158
|
+
querySelector(sel) {
|
|
159
|
+
const s = String(sel);
|
|
160
|
+
return s.startsWith('#') ? this.getElementById(s.slice(1)) : makeEl();
|
|
161
|
+
},
|
|
162
|
+
querySelectorAll() { return []; },
|
|
163
|
+
createElement() { return makeEl(); },
|
|
164
|
+
addEventListener() {},
|
|
165
|
+
};
|
|
166
|
+
const client = {
|
|
167
|
+
request: async (method, urlPath) => {
|
|
168
|
+
if (urlPath.endsWith('/me')) {
|
|
169
|
+
return { ok: true, status: 200, data: { builder: { id: '1', rank: 'archon', github_login: 'x' } } };
|
|
170
|
+
}
|
|
171
|
+
if (urlPath.includes('/public/repo-health')) return { ok: true, status: 200, data: payload };
|
|
172
|
+
return { ok: false, status: 500, data: {} };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const windowObj = {
|
|
176
|
+
BongosClient: { createClient: () => client },
|
|
177
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
178
|
+
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
|
179
|
+
location: { hash: '', pathname: '/watch', search: '', assign() {} },
|
|
180
|
+
addEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
181
|
+
};
|
|
182
|
+
const sandbox = {
|
|
183
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
184
|
+
localStorage: windowObj.localStorage,
|
|
185
|
+
// The two browser globals the kit reaches for outside `window`: CSS.escape
|
|
186
|
+
// (tab selectors) and history.replaceState (the tab's deep link).
|
|
187
|
+
CSS: { escape: (s) => String(s) },
|
|
188
|
+
history: { replaceState() {} },
|
|
189
|
+
console: { ...console, error() {}, warn() {} },
|
|
190
|
+
setTimeout, clearTimeout, URLSearchParams, Intl,
|
|
191
|
+
};
|
|
192
|
+
sandbox.globalThis = sandbox;
|
|
193
|
+
vm.createContext(sandbox);
|
|
194
|
+
for (const f of ['dom-utils.js', 'hall-kit.js', 'watch.js']) {
|
|
195
|
+
vm.runInContext(read(f), sandbox, { filename: f });
|
|
196
|
+
}
|
|
197
|
+
// load() is async: let every section's promise settle before reading the DOM.
|
|
198
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0));
|
|
199
|
+
return { html: documentObj.getElementById('ledger-trend').innerHTML, latest: payload.latest, windowObj };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test('the repo-health trend has a mount, and renderLedger fills it', () => {
|
|
203
|
+
const js = read('watch.js');
|
|
204
|
+
assert.match(read('watch.html'), /id="ledger-trend"/, 'the mount exists in the markup');
|
|
205
|
+
assert.match(js, /renderTrend\(data\);/, 'renderLedger hands the whole payload on, not just latest');
|
|
206
|
+
assert.match(js, /'\/public\/repo-health\?days=30'/, 'the request still asks for the window');
|
|
207
|
+
assert.match(js, /data\.window_days/, 'and the heading says which window it drew');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('every mark in the trend is the kit\'s — no hand-drawn plot on the oversight page', () => {
|
|
211
|
+
const js = read('watch.js');
|
|
212
|
+
for (const fn of ['plotWindow', 'timeStepSeriesHtml', 'plotSvgHtml', 'plotValueLabelHtml']) {
|
|
213
|
+
assert.match(js, new RegExp(`kit\\.${fn}\\(`), `the trend draws through ${fn}`);
|
|
214
|
+
}
|
|
215
|
+
// §6b holds this globally; pinned here too because this is the page that
|
|
216
|
+
// hand-rolled a bar chart once already (task 1003445).
|
|
217
|
+
assert.doesNotMatch(js, /<(?:path|polyline|rect)\b[^>]*\b(?:d|points)="\$\{/,
|
|
218
|
+
'no hand-built SVG geometry on Watch');
|
|
219
|
+
// No readout is wired, so the plot must drop the focus stop AND the crosshair
|
|
220
|
+
// together — the task 1003446 pairing. Half of it is worse than neither.
|
|
221
|
+
assert.match(js, /interactive: false/, 'the trend plots are not fake-interactive');
|
|
222
|
+
assert.match(js, /class="pchart__twin"/, 'and each carries its table twin instead');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('a fitted axis says so in words, because it magnifies every move', () => {
|
|
226
|
+
// total_loc is the one metric drawn off a non-zero floor: on a zero floor a
|
|
227
|
+
// month of real change is a flat band across the top of the box. That choice
|
|
228
|
+
// makes small movement look large, so the figure states its baseline rather
|
|
229
|
+
// than letting the picture imply a collapse to nothing.
|
|
230
|
+
const js = read('watch.js');
|
|
231
|
+
assert.match(js, /key: 'total_loc',[\s\S]{0,120}zeroBased: false/, 'total_loc fits its floor');
|
|
232
|
+
for (const k of ['dead_exports', 'stale_branches', 'confirmed_unshipped']) {
|
|
233
|
+
assert.match(js, new RegExp(`key: '${k}',[\\s\\S]{0,120}zeroBased: true`), `${k} keeps the zero floor`);
|
|
234
|
+
}
|
|
235
|
+
assert.match(js, /not zero/, 'and a fitted figure names its baseline in the foot');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('RUN: the real page against the real fixture puts four finite plots on the panel', async () => {
|
|
239
|
+
// The half a source assertion cannot see. A NaN in a path `d` renders as an
|
|
240
|
+
// invisible chart with no error anywhere — a grep never finds that, and a
|
|
241
|
+
// stub agreeing with a stub never would either.
|
|
242
|
+
const { html, latest } = await bootWatchLedger();
|
|
243
|
+
assert.ok(html.length > 0, 'the panel drew something');
|
|
244
|
+
assert.doesNotMatch(html, /NaN|Infinity|undefined/, 'no broken geometry reaches the DOM');
|
|
245
|
+
const figures = [...html.matchAll(/id="ledger-trend-([a-z_]+)"/g)].map((m) => m[1]);
|
|
246
|
+
assert.deepEqual(figures, ['total_loc', 'dead_exports', 'stale_branches', 'confirmed_unshipped'],
|
|
247
|
+
'all four metrics draw, in the reading order');
|
|
248
|
+
assert.equal((html.match(/class="kit-plot"/g) || []).length, 4, 'four plots');
|
|
249
|
+
assert.ok(!/tabindex="0"/.test(html), 'none of them is a focus stop with nothing to do');
|
|
250
|
+
assert.equal((html.match(/class="kit-plot__hair"/g) || []).length, 0, 'and none carries a crosshair nothing moves');
|
|
251
|
+
// The plot and the figure band above it derive from ONE payload, so they
|
|
252
|
+
// cannot disagree: the labelled endpoint is the number the snapshot states.
|
|
253
|
+
const compact = `${(latest.total_loc / 1e3).toFixed(1)}k`;
|
|
254
|
+
assert.ok(html.includes(`>${compact}<`), `the line ends on ${compact}, the value the snapshot figure shows`);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('RUN: a payload with no trend renders nothing rather than an empty panel', async () => {
|
|
258
|
+
// The honest exit the module doc requires. The figures above already say what
|
|
259
|
+
// today's numbers are; an empty card under them would be worse than no card.
|
|
260
|
+
const { html } = await bootWatchLedger({ trend: [] });
|
|
261
|
+
assert.equal(html.trim(), '', 'no heading, no card, no "no data" box');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('RUN: one snapshot is a reading, not a trend', async () => {
|
|
265
|
+
// A single point draws a dot on an axis with nothing to compare it to.
|
|
266
|
+
const { html } = await bootWatchLedger({ trend: repoHealthFixture().trend.slice(-1) });
|
|
267
|
+
assert.equal(html.trim(), '');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('RUN: a metric its snapshots never carried is skipped, not drawn as a cliff', async () => {
|
|
271
|
+
// An older snapshot predates a column and sends null. Reading that as 0 would
|
|
272
|
+
// draw a fall to the floor that never happened, so the figure is dropped.
|
|
273
|
+
const trend = repoHealthFixture().trend.map((r) => ({ ...r, dead_exports: null }));
|
|
274
|
+
const { html } = await bootWatchLedger({ trend });
|
|
275
|
+
const figures = [...html.matchAll(/id="ledger-trend-([a-z_]+)"/g)].map((m) => m[1]);
|
|
276
|
+
assert.deepEqual(figures, ['total_loc', 'stale_branches', 'confirmed_unshipped']);
|
|
277
|
+
});
|
|
@@ -68,6 +68,10 @@ const ORACLE = {
|
|
|
68
68
|
'/builders/harbor': 'requireGovernmentPage',
|
|
69
69
|
'/builders/gate': 'requireGovernmentPage',
|
|
70
70
|
'/builders/sessions': 'requireGovernmentPage',
|
|
71
|
+
// The five fundamentals of the project (task 1003280, goal 1000072). Same tier as
|
|
72
|
+
// its neighbours: a steering surface, metic+ per ADR 0157. Its two WRITES gate
|
|
73
|
+
// independently on `project.settings.manage`, so this row is only about the shell.
|
|
74
|
+
'/builders/project-settings': 'requireGovernmentPage',
|
|
71
75
|
|
|
72
76
|
// Metic+ via the `project.curate` atom — the exact permission GET /scouting
|
|
73
77
|
// checks, so the page can never drift wider than the API it fronts. Its
|
|
@@ -520,3 +520,156 @@ test('the vouched shapes still become links, exactly as before', () => {
|
|
|
520
520
|
const degraded = MD.parseMarkdown('[t](../../CLAUDE.md)\n', MD.makeResolveLinkHref({ baseDir: 'docs/onboarding/' })).html;
|
|
521
521
|
assert.ok(degraded.includes('<a href="../../CLAUDE.md"'), 'and with no bases a repo path still degrades to a link, as §10 pins above');
|
|
522
522
|
});
|
|
523
|
+
|
|
524
|
+
// ---- the deck draws its dates (task 1003447, `legibility-series-are-drawn`) --
|
|
525
|
+
//
|
|
526
|
+
// /public/versions has always carried started_at and shipped_at per version, and
|
|
527
|
+
// the deck drew NEITHER: both dates reached the browser and came out as "Sep
|
|
528
|
+
// 2026" on a shipped pill. The SERIES lives in roadmap-timeline.js and
|
|
529
|
+
// tests/hall_kit_charts.mjs runs it against the real fixture; what belongs here
|
|
530
|
+
// is the deck's half — that the section is composed, gated and placed the way
|
|
531
|
+
// the reading rooms compose everything else.
|
|
532
|
+
|
|
533
|
+
test('the deck asks the kit to draw its timeline, and hand-rolls nothing', () => {
|
|
534
|
+
const js = read('roadmap.js');
|
|
535
|
+
assert.match(js, /window\.OTBRoadmapTimeline/, 'the series comes from its own module');
|
|
536
|
+
assert.match(js, /timelineCardHtml\(all, now\)/, 'over the merged versions');
|
|
537
|
+
assert.match(js, /html \+= timelineHtml\(all, Date\.now\(\)\)/, 'and lands in the deck');
|
|
538
|
+
// §6b holds this globally; pinned here because a Gantt is the shape most
|
|
539
|
+
// likely to be reached for as "just a few divs with a left offset".
|
|
540
|
+
for (const f of ['roadmap.js', 'roadmap-timeline.js']) {
|
|
541
|
+
assert.doesNotMatch(read(f), /<(?:path|polyline|rect)\b[^>]*\b(?:d|points)="\$\{/,
|
|
542
|
+
`${f} builds no SVG geometry by hand`);
|
|
543
|
+
assert.doesNotMatch(read(f), /class="[^"]*(?:__|-)(?:fill|bar|track)[^"]*"[^>]*style="width:/,
|
|
544
|
+
`${f} draws no bar by hand`);
|
|
545
|
+
}
|
|
546
|
+
assert.match(read('roadmap-timeline.js'), /k\.barSeriesHtml\(/, 'the spans are the kit\'s bar series');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test('a missing timeline module leaves the deck exactly as it was', () => {
|
|
550
|
+
// The section is additive: the module is a <script> that can 404 behind a bad
|
|
551
|
+
// deploy, and when it does the deck must render its hero and its cards rather
|
|
552
|
+
// than an empty band or a thrown boot.
|
|
553
|
+
const js = read('roadmap.js');
|
|
554
|
+
const fn = js.slice(js.indexOf('function timelineHtml'), js.indexOf('function sectionHtml'));
|
|
555
|
+
assert.match(fn, /mod \? mod\.timelineCardHtml/, 'the module is checked before it is called');
|
|
556
|
+
assert.match(fn, /card \? sectionHtml\([\s\S]*?\) : ''/, 'and a null card renders no section at all');
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// Boot roadmap.js in a stub DOM with the real dom-utils, the real kit and the
|
|
560
|
+
// real timeline module, answer /public/progress + /public/versions with the
|
|
561
|
+
// checked-in fixtures, and hand back what landed in #rm-root.
|
|
562
|
+
//
|
|
563
|
+
// This is the half the source pins and the kit's own RUN test both miss: the
|
|
564
|
+
// GLUE. `mergeVersions()` maps `m.started_at`/`m.shipped_at` onto `start`/`ship`
|
|
565
|
+
// and the timeline reads those names — a rename or a typo on either side leaves
|
|
566
|
+
// every span unparsable, the section renders nothing, and every other test in
|
|
567
|
+
// this file still passes because none of them run the mapping.
|
|
568
|
+
async function bootRoadmap({ progress, versions } = {}) {
|
|
569
|
+
const FIX = path.join(ROOT, 'scripts', 'hall-preview', 'fixtures');
|
|
570
|
+
const fx = (f) => JSON.parse(fs.readFileSync(path.join(FIX, f), 'utf8'));
|
|
571
|
+
const prog = progress || fx('public__progress.json');
|
|
572
|
+
// public__versions.json, not versions.json: the deck calls the PUBLIC route.
|
|
573
|
+
const vers = versions || fx('public__versions.json');
|
|
574
|
+
|
|
575
|
+
const els = new Map();
|
|
576
|
+
const makeEl = () => ({
|
|
577
|
+
dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
|
|
578
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
579
|
+
setAttribute() {}, getAttribute() { return null; },
|
|
580
|
+
addEventListener() {}, appendChild() {}, insertAdjacentHTML(_, h) { this._html += h; },
|
|
581
|
+
closest() { return null; }, querySelector() { return null; }, querySelectorAll() { return []; },
|
|
582
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
583
|
+
});
|
|
584
|
+
const documentObj = {
|
|
585
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
586
|
+
querySelector(sel) {
|
|
587
|
+
const s = String(sel);
|
|
588
|
+
return s.startsWith('#') ? this.getElementById(s.slice(1)) : makeEl();
|
|
589
|
+
},
|
|
590
|
+
querySelectorAll() { return []; },
|
|
591
|
+
createElement() { return makeEl(); },
|
|
592
|
+
addEventListener() {},
|
|
593
|
+
};
|
|
594
|
+
const client = {
|
|
595
|
+
request: async (_m, urlPath) => {
|
|
596
|
+
if (urlPath.includes('/public/progress')) return { ok: true, status: 200, data: prog };
|
|
597
|
+
if (urlPath.includes('/public/versions')) return { ok: true, status: 200, data: vers };
|
|
598
|
+
// No goals: goalSummary() swallows the failure by design, and the goal
|
|
599
|
+
// layer is not what this test is about.
|
|
600
|
+
return { ok: false, status: 500, data: {} };
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
const windowObj = {
|
|
604
|
+
BongosClient: { createClient: () => client },
|
|
605
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
606
|
+
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
|
607
|
+
location: { hash: '', pathname: '/roadmap', search: '', assign() {} },
|
|
608
|
+
addEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
609
|
+
};
|
|
610
|
+
const sandbox = {
|
|
611
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
612
|
+
localStorage: windowObj.localStorage,
|
|
613
|
+
CSS: { escape: (s) => String(s) }, history: { replaceState() {} },
|
|
614
|
+
console: { ...console, error() {}, warn() {} },
|
|
615
|
+
setTimeout, clearTimeout, URLSearchParams, Intl,
|
|
616
|
+
};
|
|
617
|
+
sandbox.globalThis = sandbox;
|
|
618
|
+
vm.createContext(sandbox);
|
|
619
|
+
for (const f of ['dom-utils.js', 'hall-kit.js', 'roadmap-timeline.js', 'roadmap.js']) {
|
|
620
|
+
vm.runInContext(read(f), sandbox, { filename: f });
|
|
621
|
+
}
|
|
622
|
+
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
623
|
+
return { html: documentObj.getElementById('rm-root').innerHTML, prog, vers };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
test('RUN: the deck really draws its timeline — mergeVersions\' field mapping included', async () => {
|
|
627
|
+
const { html, vers } = await bootRoadmap();
|
|
628
|
+
assert.ok(html.length > 0, 'the deck rendered');
|
|
629
|
+
assert.match(html, /class="rm-sec__t">In time</, 'the timeline section is on the page');
|
|
630
|
+
assert.match(html, /class="kit-bars"/, 'and it is the kit drawing it');
|
|
631
|
+
assert.doesNotMatch(html, /NaN|Infinity|undefined/, 'no broken geometry reaches the DOM');
|
|
632
|
+
|
|
633
|
+
// Every version the fixture gives a start date has a bar, labelled by its id.
|
|
634
|
+
// This is the assertion that fails if `start`/`ship` ever drift from
|
|
635
|
+
// `started_at`/`shipped_at`: the mapping breaks, no date parses, and the
|
|
636
|
+
// section quietly disappears.
|
|
637
|
+
const started = vers.versions.filter((v) => v.started_at);
|
|
638
|
+
assert.ok(started.length >= 2, 'the fixture has enough started versions to be a timeline');
|
|
639
|
+
for (const v of started) {
|
|
640
|
+
assert.ok(html.includes(`class="kit-bars__label">${v.id}<`), `${v.id} has a bar`);
|
|
641
|
+
}
|
|
642
|
+
// A version still running says "now" rather than naming a date nothing recorded.
|
|
643
|
+
if (vers.versions.some((v) => v.started_at && !v.shipped_at)) {
|
|
644
|
+
assert.match(html, /→ now</, 'an open span reads "now"');
|
|
645
|
+
}
|
|
646
|
+
// Later versions sit further along the shared axis — the whole point of a
|
|
647
|
+
// timeline, and the thing a plain bar series would NOT show.
|
|
648
|
+
const offsets = [...html.matchAll(/margin-left:(\d+)%/g)].map((m) => Number(m[1]));
|
|
649
|
+
assert.ok(offsets.length >= 1, 'spans are offset, not all flush left');
|
|
650
|
+
assert.deepEqual(offsets, [...offsets].sort((a, b) => a - b), 'offsets grow with start date');
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test('RUN: a deck whose versions carry no dates renders every other section unharmed', async () => {
|
|
654
|
+
// The degradation that matters: /public/versions is fetched with a
|
|
655
|
+
// `.catch(() => [])`, so a 404 or a stripped payload must cost the timeline
|
|
656
|
+
// and nothing else. Before this task the whole page relied on that silently.
|
|
657
|
+
const { html } = await bootRoadmap({
|
|
658
|
+
versions: { versions: [{ id: 'BONGOS-V1', name: 'x', status: 'building', started_at: null, shipped_at: null }] },
|
|
659
|
+
});
|
|
660
|
+
assert.doesNotMatch(html, /In time</, 'no timeline section');
|
|
661
|
+
assert.match(html, /rm-hero/, 'but the hero still renders');
|
|
662
|
+
assert.doesNotMatch(html, /NaN|undefined/);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('the timeline module is a pure builder, so it is testable and cannot boot itself', () => {
|
|
666
|
+
const src = read('roadmap-timeline.js');
|
|
667
|
+
// The node-require-safe tail (the hall-kit.js / dom-utils.js contract): the
|
|
668
|
+
// ONLY eval-time side effect is the assignment, and only when a window exists.
|
|
669
|
+
assert.match(src, /if \(typeof window !== 'undefined'\) window\.OTBRoadmapTimeline = api;/);
|
|
670
|
+
assert.match(src, /if \(typeof module !== 'undefined' && module\.exports\) module\.exports = api;/);
|
|
671
|
+
// It must not reach for the page: no document, no fetch, no client.
|
|
672
|
+
for (const forbidden of [/\bdocument\./, /\bfetch\(/, /BongosClient/]) {
|
|
673
|
+
assert.doesNotMatch(src, forbidden, `roadmap-timeline.js touches ${forbidden} — it is a builder, not a page`);
|
|
674
|
+
}
|
|
675
|
+
});
|