@bongos/core 1.19.512 → 1.19.513
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 +62 -37
- 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 +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +40 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +36 -35
- package/docs/copy-registry.json +93 -74
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/hall-kit.js +309 -0
- package/modules/hall-ui/public/hall-render.js +10 -38
- package/modules/hall-ui/public/profile-charts.js +58 -65
- package/modules/hall-ui/public/profile.css +9 -18
- package/modules/hall-ui/public/style.css +101 -15
- package/modules/hall-ui/public/watch.css +6 -14
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/watch.js +139 -54
- package/modules/provisioning/recommendations.js +260 -0
- package/modules/provisioning/routes/provisioning.js +29 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_audit.mjs +84 -0
- package/tests/hall_kit_charts.mjs +328 -0
- package/tests/hall_profile_world.mjs +27 -6
- package/tests/helpers.mjs +161 -0
- package/tests/joinability_invite_only.mjs +10 -2
- package/tests/provisioning_recommendations.mjs +448 -0
- package/tests/task_detail_ui.mjs +1 -153
- package/tests/watch_applications_queue.mjs +381 -0
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// GET /provisioning/health — liveness + core version
|
|
24
24
|
// GET /provisioning/onboard-plan — the canonical onboarding step sequence
|
|
25
25
|
// GET /provisioning/starter-bundles — the curated module preset per project type
|
|
26
|
+
// GET /provisioning/recommendations — bundle/governance/invite recommendations from the answers
|
|
26
27
|
//
|
|
27
28
|
// Reaches core ONLY through the doorway src/module-api.js (the one-way rule, ADR
|
|
28
29
|
// 0083); it requires this module's own provisioning.js (same-dir), never a sibling.
|
|
@@ -36,6 +37,7 @@ const { pool, validateOrRespond, parseId } = api;
|
|
|
36
37
|
const provisioning = require('../provisioning');
|
|
37
38
|
const { onboardPlan, stepsForShape } = require('../onboard-plan');
|
|
38
39
|
const { ALWAYS_ON_CORE, allStarterBundles } = require('../starter-bundles');
|
|
40
|
+
const { recommendationsForDetails } = require('../recommendations');
|
|
39
41
|
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
40
42
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
41
43
|
const log = api.logger('provisioning');
|
|
@@ -1282,6 +1284,33 @@ module.exports = function provisioningRoutes() {
|
|
|
1282
1284
|
res.json({ core: [...ALWAYS_ON_CORE], bundles: allStarterBundles() });
|
|
1283
1285
|
});
|
|
1284
1286
|
|
|
1287
|
+
// GET /provisioning/recommendations?type=<project type>&team_shape=<interview answer>
|
|
1288
|
+
// — the RULE-BASED recommendation for one set of creation answers (BV1.R17, task
|
|
1289
|
+
// 1002336; goal 1000046 criteria `proj-module-select`, `proj-governance-reco`,
|
|
1290
|
+
// `proj-invite-builders`). Returns `{ recommendations: { type, teamShape, tailored,
|
|
1291
|
+
// bundle, governance, invites } }` — the starter bundle adjusted by the interview
|
|
1292
|
+
// answer with a `reason` per adjustment, a named governance preset with its preview
|
|
1293
|
+
// (`applied: false` until the governance module ships, goal 1000030), and the invite
|
|
1294
|
+
// SEEK that R28 (task 1002347) turns into names.
|
|
1295
|
+
//
|
|
1296
|
+
// The two answers arrive separately because they are stored separately: `type` is a
|
|
1297
|
+
// column and `team_shape` a key of the `detail` jsonb, so a caller passes back
|
|
1298
|
+
// exactly what it holds. Both are optional and neither is validated by rejection —
|
|
1299
|
+
// an absent or off-vocabulary value reads as NO ANSWER and yields the stated
|
|
1300
|
+
// defaults, which is the supported path (ADR 0182 D4: skipping the interview is a
|
|
1301
|
+
// real answer), never a 400.
|
|
1302
|
+
//
|
|
1303
|
+
// A pure read over modules/provisioning/recommendations.js — no database, no
|
|
1304
|
+
// per-request state. rank: public — the same absent gate as `starter-bundles` and
|
|
1305
|
+
// `onboard-plan` above: the signed-out hub wizard (/projects?view=new) asks while
|
|
1306
|
+
// the owner is still answering, before any row exists to be own-scoped against, and
|
|
1307
|
+
// the answer is a declaration that names nothing private.
|
|
1308
|
+
router.get('/provisioning/recommendations', (req, res) => {
|
|
1309
|
+
const type = typeof req.query.type === 'string' ? req.query.type : null;
|
|
1310
|
+
const teamShape = typeof req.query.team_shape === 'string' ? req.query.team_shape : null;
|
|
1311
|
+
res.json({ recommendations: recommendationsForDetails({ type, team_shape: teamShape }) });
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1285
1314
|
// GET /provisioning/tls-check?domain=<host> — the Caddy on-demand-TLS ALLOW-LIST
|
|
1286
1315
|
// (ADR 0111 §4, task P4 #1945). Caddy's global `on_demand_tls { ask … }` calls
|
|
1287
1316
|
// this before fetching a Let's Encrypt cert for an SNI it doesn't already have:
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.513",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.513",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.513",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.513'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/hall_audit.mjs
CHANGED
|
@@ -236,6 +236,90 @@ test('the session log pages on the kit — the last hand-roll is gone', () => {
|
|
|
236
236
|
assert.match(read('sessions.html'), /hall-kit\.js/, 'and the page has to load the kit');
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
+
// ---- 6b. every hall bar series is drawn by the kit (task 1003445) ---------
|
|
240
|
+
|
|
241
|
+
test('the kit is the ONLY thing in the hall that draws a bar series', () => {
|
|
242
|
+
// Charts were the one primitive the kit never got, and the hard rule in
|
|
243
|
+
// hall-ui/CLAUDE.md names pager/filter/tab/rail and stops — so the same
|
|
244
|
+
// horizontal bar got written THREE times, each with its own class family:
|
|
245
|
+
// the Curve's kinds (hall-render.js), Watch's Review-quality trend
|
|
246
|
+
// (watch.js) and the profile's craft mix (profile-charts.js `mixChart`).
|
|
247
|
+
// Nothing failed when the third copy landed, which is the whole reason this
|
|
248
|
+
// check exists rather than a sentence in a doc.
|
|
249
|
+
//
|
|
250
|
+
// Two halves, because one alone is escapable.
|
|
251
|
+
//
|
|
252
|
+
// (a) OWNERSHIP, the pager rule's own idiom: the kit stamps `.kit-bars` /
|
|
253
|
+
// `.kit-spark` at runtime and callers pass DATA, so the class appearing
|
|
254
|
+
// in another file is by construction a second implementation.
|
|
255
|
+
const OWNER = 'hall-kit.js';
|
|
256
|
+
const stamped = [];
|
|
257
|
+
for (const f of fs.readdirSync(HALL)) {
|
|
258
|
+
if (!/\.(html|js)$/.test(f) || f === OWNER) continue;
|
|
259
|
+
if (/class="kit-(?:bars|spark|plot)[\s"_-]/.test(read(f))) stamped.push(f);
|
|
260
|
+
}
|
|
261
|
+
assert.deepEqual(stamped, [],
|
|
262
|
+
`${stamped.join(', ')} writes the kit's own chart classes by hand — call `
|
|
263
|
+
+ 'OTBKit.barSeriesHtml / sparklineHtml / plotSvgHtml instead.');
|
|
264
|
+
|
|
265
|
+
// No page may draw an SVG series of its own either — a <path>/<rect>/
|
|
266
|
+
// <polyline> built in a template string IS chart drawing, whatever it is
|
|
267
|
+
// classed. This is what the narc caught on the first round: the bars were
|
|
268
|
+
// unified while two hand-rolled SVG charts stayed behind.
|
|
269
|
+
const svgDrawers = [];
|
|
270
|
+
for (const f of fs.readdirSync(HALL)) {
|
|
271
|
+
if (!/\.js$/.test(f) || f === OWNER) continue;
|
|
272
|
+
const src = read(f);
|
|
273
|
+
if (/<(?:path|polyline|rect)\b[^>]*\b(?:d|points)="\$\{/.test(src)) svgDrawers.push(f);
|
|
274
|
+
}
|
|
275
|
+
assert.deepEqual(svgDrawers, [],
|
|
276
|
+
`${svgDrawers.join(', ')} builds SVG series geometry by hand — the kit owns the `
|
|
277
|
+
+ 'shapes (timeStepSeriesHtml, columnSeriesHtml, sparklineHtml) and plotSvgHtml owns '
|
|
278
|
+
+ 'the axis frame; a page composes them and wires its own readout.');
|
|
279
|
+
|
|
280
|
+
// (b) SHAPE, so a SEVENTH copy under a fresh class family is caught too —
|
|
281
|
+
// which (a) alone would miss, and which is exactly how copies two and
|
|
282
|
+
// three arrived. A bar series is a percentage-width fill inside a
|
|
283
|
+
// track/bar/fill-named element.
|
|
284
|
+
//
|
|
285
|
+
// The allowlist is METERS, not series, and the distinction is real: a
|
|
286
|
+
// meter shows ONE value against a fixed 0-100 scale (and bands it), a
|
|
287
|
+
// series shows several values against each other's computed max. The kit
|
|
288
|
+
// draws the second; folding the first into it would mean inventing a
|
|
289
|
+
// scale the meter does not have. Adding a name here is where the
|
|
290
|
+
// conversation happens — do not add one to make a chart pass.
|
|
291
|
+
const METERS = new Set([
|
|
292
|
+
'idea.js', // .ig-score — one Full-Idea completeness gauge, banded full/part/thin
|
|
293
|
+
'ideas.js', // .fi-mrow — the composer's per-field completeness, driven imperatively
|
|
294
|
+
]);
|
|
295
|
+
const handRolled = [];
|
|
296
|
+
for (const f of fs.readdirSync(HALL)) {
|
|
297
|
+
if (!/\.js$/.test(f) || f === OWNER || METERS.has(f)) continue;
|
|
298
|
+
const src = read(f);
|
|
299
|
+
if (/class="[^"]*(?:__|-)(?:fill|bar|track)[^"]*"[^>]*style="width:/.test(src)) handRolled.push(f);
|
|
300
|
+
}
|
|
301
|
+
assert.deepEqual(handRolled, [],
|
|
302
|
+
`${handRolled.join(', ')} draws a bar series by hand — call OTBKit.barSeriesHtml(rows, opts). `
|
|
303
|
+
+ 'Geometry and tone are options (labelWidth/valueWidth/barHeight/radius/track/tone), so a new '
|
|
304
|
+
+ 'chart needs no new drawing code and no new class family.');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('the three bar copies are gone, and their class families with them', () => {
|
|
308
|
+
// The specific copies this rule replaced, pinned by name so their return is
|
|
309
|
+
// loud. Behaviour is exercised for real in tests/hall_kit_charts.mjs.
|
|
310
|
+
// Comments stripped: each sheet keeps a line SAYING which family it retired,
|
|
311
|
+
// and a substring check would read that sentence as the family still living.
|
|
312
|
+
const css = ['style.css', 'watch.css', 'profile.css']
|
|
313
|
+
.map((f) => read(f).replace(/\/\*[\s\S]*?\*\//g, '')).join('\n');
|
|
314
|
+
for (const dead of ['curve__kind-', 'curve__chart', 'watch-trend', 'pmix']) {
|
|
315
|
+
assert.ok(!css.includes(dead), `${dead} is the kit's .kit-bars/.kit-chart now`);
|
|
316
|
+
}
|
|
317
|
+
assert.match(read('hall-render.js'), /OTBKit\.barSeriesHtml\(/, 'the Curve\'s kinds draw through the kit');
|
|
318
|
+
assert.match(read('watch.js'), /OTBKit\.barSeriesHtml\(/, 'Watch\'s trend draws through the kit');
|
|
319
|
+
assert.match(read('profile-charts.js'), /OTBKit\.barSeriesHtml\(/, 'the craft mix draws through the kit');
|
|
320
|
+
assert.match(read('hall-render.js'), /OTBKit\.sparklineHtml\(/, 'and the Curve\'s line does too');
|
|
321
|
+
});
|
|
322
|
+
|
|
239
323
|
test('the peer-recognition feed is paged through OTBKit, not by hand', () => {
|
|
240
324
|
assert.match(read('hall-widgets.js'), /pagerBarHtml\('acclaim'/, 'the acclaim card stamps the kit pager bar');
|
|
241
325
|
const js = read('builders.js');
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// tests/hall_kit_charts.mjs — the hall's ONE chart primitive (task 1003445).
|
|
2
|
+
//
|
|
3
|
+
// Runs the REAL hall-kit.js, and for the three migrated call sites runs the
|
|
4
|
+
// real page beside it in a vm, so what is asserted is the markup a reader
|
|
5
|
+
// actually gets — not a stub agreeing with a stub. That mattered here: the
|
|
6
|
+
// three copies this primitive replaced were structurally identical and
|
|
7
|
+
// differed only in geometry and tone, so a test that mocked the kit would
|
|
8
|
+
// have passed on a primitive that dropped either.
|
|
9
|
+
//
|
|
10
|
+
// What it holds:
|
|
11
|
+
// 1. the arithmetic — scaling, a shared max, cumulation, the degenerate sets
|
|
12
|
+
// 2. the markup — one class family, geometry as custom properties
|
|
13
|
+
// 3. escaping — a label is server data and lands in innerHTML
|
|
14
|
+
// 4. the three call sites, each drawing through the kit at its own geometry
|
|
15
|
+
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import vm from 'node:vm';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
25
|
+
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8').replace(/\r\n/g, '\n');
|
|
26
|
+
|
|
27
|
+
// The kit is dependency-free and node-require-safe by contract, so it loads
|
|
28
|
+
// directly — no DOM, no window. If that ever stops being true, this import is
|
|
29
|
+
// the first thing that fails, which is the point.
|
|
30
|
+
const kitPath = path.join(HALL, 'hall-kit.js');
|
|
31
|
+
const kit = await import(`file://${kitPath.replace(/\\/g, '/')}`).then((m) => m.default);
|
|
32
|
+
const kitSrc = read('hall-kit.js');
|
|
33
|
+
|
|
34
|
+
// ---- 1. the arithmetic ------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
test('a bar is its share of the largest value in the set', () => {
|
|
37
|
+
const html = kit.barSeriesHtml([
|
|
38
|
+
{ label: 'a', value: 10 },
|
|
39
|
+
{ label: 'b', value: 5 },
|
|
40
|
+
{ label: 'c', value: 0 },
|
|
41
|
+
]);
|
|
42
|
+
const widths = [...html.matchAll(/width:(\d+)%/g)].map((m) => Number(m[1]));
|
|
43
|
+
assert.deepEqual(widths, [100, 50, 0], 'the max is 100%, and zero is drawn as empty, not omitted');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a caller-supplied max lets two series share one scale', () => {
|
|
47
|
+
// Without this, two charts side by side each normalise to their own biggest
|
|
48
|
+
// value and a smaller series draws taller bars than a larger one.
|
|
49
|
+
const html = kit.barSeriesHtml([{ label: 'a', value: 5 }], { max: 20 });
|
|
50
|
+
assert.match(html, /width:25%/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('an all-zero set draws empty tracks rather than dividing by zero', () => {
|
|
54
|
+
const html = kit.barSeriesHtml([{ label: 'a', value: 0 }, { label: 'b', value: 0 }]);
|
|
55
|
+
assert.match(html, /width:0%/);
|
|
56
|
+
assert.doesNotMatch(html, /NaN|Infinity/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a non-finite value reads as zero — a gap-filled feed sends nulls', () => {
|
|
60
|
+
const html = kit.barSeriesHtml([{ label: 'a', value: 4 }, { label: 'b', value: null }]);
|
|
61
|
+
assert.doesNotMatch(html, /NaN/);
|
|
62
|
+
assert.match(html, /width:0%/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('an empty set renders nothing at all, so a caller can test for it', () => {
|
|
66
|
+
// '' rather than an empty container: every call site wants to substitute its
|
|
67
|
+
// own "nothing in this window" line, and a bare wrapper would defeat that.
|
|
68
|
+
assert.equal(kit.barSeriesHtml([]), '');
|
|
69
|
+
assert.equal(kit.barSeriesHtml(null), '');
|
|
70
|
+
assert.equal(kit.barSeriesHtml([{ value: 1 }]), '', 'a row with no label is not a row');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('cumulate is a running total that survives holes', () => {
|
|
74
|
+
assert.deepEqual(kit.cumulate([1, 2, 3]), [1, 3, 6]);
|
|
75
|
+
assert.deepEqual(kit.cumulate([1, null, 3]), [1, 1, 4], 'a null adds nothing, it does not reset');
|
|
76
|
+
assert.deepEqual(kit.cumulate([]), []);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the line is scaled to its own min and max, and a flat series sits mid-box', () => {
|
|
80
|
+
const g = kit.linePoints([0, 10], { width: 100, height: 50, pad: 0 });
|
|
81
|
+
const ys = g.points.split(' ').map((p) => Number(p.split(',')[1]));
|
|
82
|
+
assert.equal(ys[0], 50, 'the minimum sits on the floor');
|
|
83
|
+
assert.equal(ys[1], 0, 'the maximum sits on the ceiling');
|
|
84
|
+
// span 0 would divide by zero; the guard puts a flat line halfway up.
|
|
85
|
+
const flat = kit.linePoints([3, 3, 3], { width: 100, height: 50, pad: 0 });
|
|
86
|
+
const flatYs = flat.points.split(' ').map((p) => Number(p.split(',')[1]));
|
|
87
|
+
assert.deepEqual(flatYs, [50, 50, 50]);
|
|
88
|
+
assert.ok(Number.isFinite(flatYs[0]));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a running-total line is composed, not a second option', () => {
|
|
92
|
+
// There is deliberately no `cumulative` flag on linePoints: `cumulate` is
|
|
93
|
+
// exported, so the two compose. One way to do it, and no option whose only
|
|
94
|
+
// caller is this test.
|
|
95
|
+
const plain = kit.linePoints([1, 1, 1], { width: 100, height: 50, pad: 0 });
|
|
96
|
+
const cumul = kit.linePoints(kit.cumulate([1, 1, 1]), { width: 100, height: 50, pad: 0 });
|
|
97
|
+
assert.deepEqual(cumul.nums, [1, 2, 3]);
|
|
98
|
+
assert.notEqual(plain.points, cumul.points, 'a running total is a different line');
|
|
99
|
+
assert.doesNotMatch(kitSrc, /opts\.cumulative/, 'and the flag is gone from the kit');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---- 2. the markup ---------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
test('geometry rides custom properties, so a new chart needs no new CSS', () => {
|
|
105
|
+
const html = kit.barSeriesHtml([{ label: 'a', value: 1 }], {
|
|
106
|
+
labelWidth: '64px', valueWidth: '96px', barHeight: '6px', pill: true,
|
|
107
|
+
});
|
|
108
|
+
assert.match(html, /--kit-bars-label-w:64px/);
|
|
109
|
+
assert.match(html, /--kit-bars-val-w:96px/);
|
|
110
|
+
assert.match(html, /--kit-bars-h:6px/);
|
|
111
|
+
assert.match(html, /--kit-bars-radius:var\(--radius-pill\)/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a style value that could break out of its declaration is dropped', () => {
|
|
115
|
+
// These land in a style attribute, so they are whitelisted as lengths rather
|
|
116
|
+
// than escaped — a ';' would end the declaration and start another.
|
|
117
|
+
const html = kit.barSeriesHtml([{ label: 'a', value: 1 }], {
|
|
118
|
+
labelWidth: '64px;background:url(http://x)', barHeight: '}*{display:none',
|
|
119
|
+
});
|
|
120
|
+
assert.doesNotMatch(html, /background|url\(|display:none/);
|
|
121
|
+
assert.doesNotMatch(html, /--kit-bars-label-w/, 'a rejected length is omitted, not half-written');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a label is escaped — it is server data landing in innerHTML', () => {
|
|
125
|
+
const html = kit.barSeriesHtml([{ label: '<img onerror=x>', value: 1, valueLabel: '<b>' }]);
|
|
126
|
+
assert.doesNotMatch(html, /<img|<b>/);
|
|
127
|
+
assert.match(html, /<img/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('the track carries no information, so it is hidden from a reader', () => {
|
|
131
|
+
// The value cell already says the number in words; a screen reader hearing
|
|
132
|
+
// the track too would hear every row twice.
|
|
133
|
+
const html = kit.barSeriesHtml([{ label: 'a', value: 1 }]);
|
|
134
|
+
assert.match(html, /class="kit-bars__track" aria-hidden="true"/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('list semantics are opt-in, because only one call site is announced as a list', () => {
|
|
138
|
+
assert.doesNotMatch(kit.barSeriesHtml([{ label: 'a', value: 1 }]), /role="list/);
|
|
139
|
+
const asList = kit.barSeriesHtml([{ label: 'a', value: 1 }], { list: true, ariaLabel: 'Craft mix' });
|
|
140
|
+
assert.match(asList, /role="list" aria-label="Craft mix"/);
|
|
141
|
+
assert.match(asList, /role="listitem"/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a one-point line draws a dot, or the chart reads as empty', () => {
|
|
145
|
+
assert.match(kit.sparklineHtml([7]), /<circle/);
|
|
146
|
+
assert.doesNotMatch(kit.sparklineHtml([7, 8]), /<circle/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('no data is an empty labelled svg, never a broken polyline', () => {
|
|
150
|
+
const html = kit.sparklineHtml([]);
|
|
151
|
+
assert.match(html, /aria-label="no data"/);
|
|
152
|
+
assert.doesNotMatch(html, /polyline/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('the caller owns the line colour — the Curve spends four', () => {
|
|
156
|
+
assert.match(kit.sparklineHtml([1, 2], { color: 'var(--terracotta)' }), /stroke="var\(--terracotta\)"/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('the chart frame does not know which shape it holds', () => {
|
|
160
|
+
const withChart = kit.chartCardHtml('Title', '<svg></svg>', 'foot');
|
|
161
|
+
assert.match(withChart, /kit-chart__title">Title/);
|
|
162
|
+
assert.match(withChart, /kit-chart__foot">foot/);
|
|
163
|
+
// No inner → the frame says so itself, so no call site writes that line.
|
|
164
|
+
assert.match(kit.chartCardHtml('T', '', 'f'), /kit-chart__empty/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ---- 2b. the plot: the axis frame both plotted charts shared ---------------
|
|
168
|
+
|
|
169
|
+
test('the step line holds its value, then rises — that is what cumulative means', () => {
|
|
170
|
+
// A straight polyline between samples would claim the total grew smoothly
|
|
171
|
+
// between two ships. It did not: it held, then jumped.
|
|
172
|
+
const g = kit.timeStepGeometry(
|
|
173
|
+
[{ x: 0, value: 1 }, { x: 10, value: 5 }],
|
|
174
|
+
{ x0: 0, x1: 10, yMax: 5, width: 100, height: 100, pad: { l: 0, r: 0, t: 0, b: 0 } },
|
|
175
|
+
);
|
|
176
|
+
// Two L commands per later point: across at the OLD height, then up.
|
|
177
|
+
assert.equal((g.d.match(/ L/g) || []).length, 3, 'across, up, then out to the axis end');
|
|
178
|
+
assert.match(g.d, /^M0\.0,80\.0/, 'the first sample sits at 1/5 of the box');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('the plotted area closes on the axis, so a fill cannot leak upward', () => {
|
|
182
|
+
const g = kit.timeStepGeometry([{ x: 0, value: 2 }], { x0: 0, x1: 4, yMax: 2 });
|
|
183
|
+
assert.match(g.area, /Z$/, 'the area is a closed path');
|
|
184
|
+
assert.ok(g.area.startsWith(g.d), 'it is the line, then down to the base and back');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a degenerate window does not divide by zero', () => {
|
|
188
|
+
// x0 === x1 (a single day, no window) and a zero max are both reachable from
|
|
189
|
+
// a real feed: one ship today, or a ledger nothing has reached.
|
|
190
|
+
for (const opts of [{ x0: 5, x1: 5, yMax: 3 }, { x0: 0, x1: 10, yMax: 0 }]) {
|
|
191
|
+
const g = kit.timeStepGeometry([{ x: 5, value: 0 }], opts);
|
|
192
|
+
assert.doesNotMatch(g.d, /NaN|Infinity/, JSON.stringify(opts));
|
|
193
|
+
}
|
|
194
|
+
assert.deepEqual(kit.timeStepGeometry([], {}).xs, [], 'and an empty series draws nothing');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('columns cap their width and keep a gap, however few weeks there are', () => {
|
|
198
|
+
const one = kit.columnSeriesHtml([5], { width: 320 });
|
|
199
|
+
const w = Number(/width="([\d.]+)"/.exec(one.body)?.[1] ?? /H([\d.]+)/.exec(one.body)?.[1] ?? 0);
|
|
200
|
+
// One bucket gets the whole plot as its slot; without the cap it would draw
|
|
201
|
+
// a 300px-wide column.
|
|
202
|
+
assert.ok(one.body.length > 0);
|
|
203
|
+
assert.ok(!/width="[3-9]\d\d/.test(one.body), `a lone column is capped, got width ${w}`);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('a zero bucket draws a stub on the axis, not a gap', () => {
|
|
207
|
+
// A missing week and a week with no ships are different facts; an absent
|
|
208
|
+
// column reads as the first.
|
|
209
|
+
const cols = kit.columnSeriesHtml([3, 0, 2]);
|
|
210
|
+
assert.match(cols.body, /kit-plot__col--empty/);
|
|
211
|
+
// Count ELEMENTS, not class occurrences — the empty one is classed
|
|
212
|
+
// `kit-plot__col kit-plot__col--empty`, so the bare name appears twice on it.
|
|
213
|
+
assert.equal((cols.body.match(/class="kit-plot__col/g) || []).length, 3, 'three weeks, three marks');
|
|
214
|
+
assert.equal(cols.xs.length, 3);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('the plot frame carries the axis, the hidden crosshair and both ticks', () => {
|
|
218
|
+
const svg = kit.plotSvgHtml({ series: '<path/>', ariaLabel: 'Works', leftTick: 'Aug 1', rightTick: 'today' });
|
|
219
|
+
assert.match(svg, /class="kit-plot"/);
|
|
220
|
+
assert.match(svg, /class="kit-plot__axis"/);
|
|
221
|
+
assert.match(svg, /class="kit-plot__hair"[^>]*visibility:hidden/, 'the crosshair starts hidden');
|
|
222
|
+
assert.match(svg, /Aug 1/);
|
|
223
|
+
assert.match(svg, /today/);
|
|
224
|
+
assert.match(svg, /aria-label="Works"/);
|
|
225
|
+
// Walkable by arrow key, therefore reachable by tab.
|
|
226
|
+
assert.match(svg, /tabindex="0"/);
|
|
227
|
+
assert.match(svg, /role="img"/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('the plot escapes its aria-label and ticks — both are page data', () => {
|
|
231
|
+
const svg = kit.plotSvgHtml({ ariaLabel: '"><script>x</script>', leftTick: '<b>' });
|
|
232
|
+
assert.doesNotMatch(svg, /<script|<b>/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('a plot labels exactly one value, in ink', () => {
|
|
236
|
+
const label = kit.plotValueLabelHtml(10, 20, '42', 'end');
|
|
237
|
+
assert.match(label, /class="kit-plot__end"/);
|
|
238
|
+
assert.match(label, /text-anchor="end"/);
|
|
239
|
+
assert.match(label, />42</);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ---- 3. the three migrated call sites --------------------------------------
|
|
243
|
+
|
|
244
|
+
// Each runs the REAL page source, so these assert the request the page makes
|
|
245
|
+
// of the kit rather than the kit's own defaults.
|
|
246
|
+
|
|
247
|
+
function runPage(file, extraWindow = {}) {
|
|
248
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
|
249
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
250
|
+
const sandbox = {
|
|
251
|
+
window: {
|
|
252
|
+
OTB: { escapeHtml: esc, $: () => null, fmtNum: (n) => String(n ?? 0), fmtDate: (s) => String(s ?? '') },
|
|
253
|
+
__BRANDING__: { currency: { label: 'drachmae' } },
|
|
254
|
+
...extraWindow,
|
|
255
|
+
},
|
|
256
|
+
document: { addEventListener() {}, getElementById: () => null, querySelector: () => null },
|
|
257
|
+
console, Date, Map, Set, Math, Number, String, Array, Object, JSON, Infinity,
|
|
258
|
+
URLSearchParams, Promise, RegExp, isNaN, parseInt, parseFloat,
|
|
259
|
+
};
|
|
260
|
+
sandbox.globalThis = sandbox;
|
|
261
|
+
vm.createContext(sandbox);
|
|
262
|
+
vm.runInContext(read('hall-kit.js'), sandbox, { filename: 'hall-kit.js' });
|
|
263
|
+
vm.runInContext(read(file), sandbox, { filename: file });
|
|
264
|
+
return sandbox;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
test('the profile craft mix asks for its measured geometry, not the default', () => {
|
|
268
|
+
// .pmix carried a square-left bar (it grows from an axis), a soft track and
|
|
269
|
+
// a label that could shrink to 72px. All three are options now; losing any
|
|
270
|
+
// of them would be a silent visual regression on a surface the owner had
|
|
271
|
+
// already accepted (task 1003489).
|
|
272
|
+
const src = read('profile-charts.js');
|
|
273
|
+
assert.match(src, /OTBKit\.barSeriesHtml\(/);
|
|
274
|
+
assert.match(src, /radius: '0 4px 4px 0'/, 'the bar keeps its square left edge');
|
|
275
|
+
assert.match(src, /track: 'soft'/);
|
|
276
|
+
assert.match(src, /labelWidth: 'minmax\(72px, 110px\)'/);
|
|
277
|
+
assert.match(src, /list: true/, 'the mix is announced as a list');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('Watch\'s trend takes the neutral tone — on an oversight page the accent is the pressable\'s', () => {
|
|
281
|
+
const src = read('watch.js');
|
|
282
|
+
assert.match(src, /tone: 'neutral'/);
|
|
283
|
+
assert.match(src, /labelWidth: '64px'/);
|
|
284
|
+
assert.match(src, /valueWidth: '96px'/);
|
|
285
|
+
assert.match(src, /barHeight: '6px'/);
|
|
286
|
+
assert.match(src, /pill: true/);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('the Curve keeps its own line colour, which is not the kit default', () => {
|
|
290
|
+
const src = read('hall-render.js');
|
|
291
|
+
assert.match(src, /sparklineHtml\(values, \{ color: 'var\(--accent-deep\)'/,
|
|
292
|
+
'the Curve\'s first chart is accent-deep; the kit\'s fallback is --accent');
|
|
293
|
+
assert.match(src, /OTBKit\.barSeriesHtml\(/, 'and its kinds row draws through the kit');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('the kit is loaded before every page that draws through it', () => {
|
|
297
|
+
// A page script reading window.OTBKit at render time with the kit loaded
|
|
298
|
+
// after it is a blank chart in production and a green unit suite.
|
|
299
|
+
for (const [page, script] of [
|
|
300
|
+
['index.html', 'hall-render.js'],
|
|
301
|
+
['watch.html', 'watch.js'],
|
|
302
|
+
['profile.html', 'profile-charts.js'],
|
|
303
|
+
]) {
|
|
304
|
+
// The loaded ORDER, read off the script tags — index.html's prose mentions
|
|
305
|
+
// hall-render.js ~90 lines above where it loads it, so a substring search
|
|
306
|
+
// on the whole file answers the wrong question.
|
|
307
|
+
const loaded = [...read(page).matchAll(/<script[^>]+src="([^"?]+)/g)]
|
|
308
|
+
.map((m) => m[1].split('/').pop());
|
|
309
|
+
const kitAt = loaded.indexOf('hall-kit.js');
|
|
310
|
+
const pageAt = loaded.indexOf(script);
|
|
311
|
+
assert.ok(kitAt > -1, `${page} must load hall-kit.js`);
|
|
312
|
+
assert.ok(pageAt > -1, `${page} must load ${script}`);
|
|
313
|
+
assert.ok(kitAt < pageAt, `${page} must load hall-kit.js BEFORE ${script}`);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('the migrated pages run for real against the kit and emit its classes', () => {
|
|
318
|
+
// hall-render.js is a factory, so this proves the whole file parses and the
|
|
319
|
+
// chart helpers resolve against the real kit — the failure mode a
|
|
320
|
+
// source-text assertion cannot see.
|
|
321
|
+
const s = runPage('hall-render.js');
|
|
322
|
+
assert.equal(typeof s.window.OTBHallRender, 'function', 'the renderer factory survived the migration');
|
|
323
|
+
assert.equal(typeof s.window.OTBKit.barSeriesHtml, 'function');
|
|
324
|
+
assert.match(
|
|
325
|
+
s.window.OTBKit.barSeriesHtml([{ label: 'engineer', value: 3 }]),
|
|
326
|
+
/class="kit-bars"/,
|
|
327
|
+
);
|
|
328
|
+
});
|
|
@@ -53,7 +53,14 @@ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
|
53
53
|
const html = read('profile.html');
|
|
54
54
|
const profileJs = read('profile.js');
|
|
55
55
|
const chartsJs = read('profile-charts.js');
|
|
56
|
+
// task 1003445: the charts draw their bars through OTBKit.barSeriesHtml, so the
|
|
57
|
+
// sandbox runs the REAL kit beside the real page rather than a stub agreeing
|
|
58
|
+
// with a stub (the tests/sessions_kit_pager.mjs pattern). A stub here would
|
|
59
|
+
// still pass if the kit's markup changed underneath the page.
|
|
60
|
+
const kitJs = read('hall-kit.js');
|
|
56
61
|
const profileCss = stripComments(read('profile.css'));
|
|
62
|
+
// The plot's dataviz spec lives in the kit's sheet since task 1003445.
|
|
63
|
+
const styleCss = stripComments(read('style.css'));
|
|
57
64
|
|
|
58
65
|
const GROUPS = ['overview', 'works', 'activity', 'connections'];
|
|
59
66
|
|
|
@@ -120,13 +127,21 @@ test('profile-charts.js loads before profile.js, and profile.js hands it the pag
|
|
|
120
127
|
});
|
|
121
128
|
|
|
122
129
|
test('the marks keep the dataviz specs: 2px accent line, 10% wash, hairline solid axis, an 8px end dot with a surface ring', () => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
assert.match(
|
|
130
|
+
// task 1003445: the PLOT moved to OTBKit (`.kit-plot*` in style.css) — the
|
|
131
|
+
// same frame had been drawn twice by profile-charts.js. The spec is asserted
|
|
132
|
+
// wherever it lives, with the same values, because it is measured rather
|
|
133
|
+
// than chosen; what must not happen is the spec quietly not being checked
|
|
134
|
+
// because its class family was renamed.
|
|
135
|
+
assert.match(styleCss, /\.kit-plot__line \{ fill: none; stroke: var\(--accent\); stroke-width: 2;/);
|
|
136
|
+
assert.match(styleCss, /\.kit-plot__area \{ fill: color-mix\(in srgb, var\(--accent\) 10%, transparent\)/);
|
|
137
|
+
assert.match(styleCss, /\.kit-plot__axis, \.kit-plot__hair \{ stroke: var\(--rule-soft\); stroke-width: 1; \}/);
|
|
138
|
+
assert.match(styleCss, /\.kit-plot__dot \{ fill: var\(--accent\); stroke: var\(--bg-card\); stroke-width: 2; \}/);
|
|
139
|
+
assert.match(kitJs, /r="4"/, 'the end dot is r=4 — an 8px marker');
|
|
140
|
+
assert.match(styleCss, /\.kit-plot__end \{[^}]*fill: var\(--ink\)/, 'the labelled value wears ink, not the series colour');
|
|
141
|
+
// The page's own sheet keeps the figure chrome, and still spends no alias.
|
|
129
142
|
assert.doesNotMatch(profileCss, /var\(--(surface|surface-2|border|border-strong|accent-weak|accent-text)\)/, 'no compat alias spent');
|
|
143
|
+
assert.doesNotMatch(profileCss, /\.pchart__(line|area|axis|hair|dot|col|end|tick)\b/,
|
|
144
|
+
'the plot families are the kit\'s now — a copy left here is a second source of truth');
|
|
130
145
|
});
|
|
131
146
|
|
|
132
147
|
// ---- the charts: arithmetic, in a sandbox --------------------------------------
|
|
@@ -146,6 +161,9 @@ function bootCharts() {
|
|
|
146
161
|
};
|
|
147
162
|
sandbox.globalThis = sandbox;
|
|
148
163
|
vm.createContext(sandbox);
|
|
164
|
+
// The kit first — profile.html pins that order too (hall-kit.js before
|
|
165
|
+
// profile-charts.js), and the page reads window.OTBKit at render time.
|
|
166
|
+
vm.runInContext(kitJs, sandbox, { filename: 'hall-kit.js' });
|
|
149
167
|
vm.runInContext(chartsJs, sandbox, { filename: 'profile-charts.js' });
|
|
150
168
|
return sandbox.window.OTBProfileCharts;
|
|
151
169
|
}
|
|
@@ -277,6 +295,9 @@ function bootRender({ rows, status = 200, analytics = null, ledger = [] }) {
|
|
|
277
295
|
};
|
|
278
296
|
sandbox.globalThis = sandbox;
|
|
279
297
|
vm.createContext(sandbox);
|
|
298
|
+
// The kit first — profile.html pins that order too (hall-kit.js before
|
|
299
|
+
// profile-charts.js), and the page reads window.OTBKit at render time.
|
|
300
|
+
vm.runInContext(kitJs, sandbox, { filename: 'hall-kit.js' });
|
|
280
301
|
vm.runInContext(chartsJs, sandbox, { filename: 'profile-charts.js' });
|
|
281
302
|
return { C: sandbox.window.OTBProfileCharts, api, requests, mount, controls };
|
|
282
303
|
}
|