@bongos/core 1.19.963 → 1.19.965
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 +70 -40
- 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/adr/0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md +41 -0
- package/docs/adr/0326-an-upgrade-may-add-a-deny-rule-to-a-file-the-instance-owns.md +5 -0
- package/docs/adr/README.md +2 -2
- package/docs/api/openapi.json +47 -2
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +134 -128
- package/docs/copy-registry.json +212 -158
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/goal-forest-lib.js +229 -0
- package/modules/hall-ui/public/goal-forest-render.js +151 -0
- package/modules/hall-ui/public/goals-page.js +6 -0
- package/modules/hall-ui/public/goals-render.js +9 -1
- package/modules/hall-ui/public/goals.css +26 -0
- package/modules/hall-ui/public/goals.html +19 -0
- package/modules/lifecycle/goal-forest.js +135 -0
- package/modules/lifecycle/routes/goals.js +8 -8
- package/modules/provisioning/starter-bundles.js +26 -2
- package/modules/public-landing/public/projects.html +17 -4
- package/modules/ui-design/kit/serve.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +29 -6
- package/scripts/hall-preview/server.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +48 -4
- package/tests/goal_category_ui.mjs +2 -0
- package/tests/goal_criterion_confirm_ui.mjs +2 -0
- package/tests/goal_forest.mjs +160 -0
- package/tests/goal_forest_lib.mjs +153 -0
- package/tests/goal_forest_page.mjs +196 -0
- package/tests/goal_invite_consent.mjs +1 -1
- package/tests/helpers.mjs +7 -3
- package/tests/projects_hub_module_picker.mjs +79 -4
- package/tests/provisioning_starter_bundles.mjs +25 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// tests/goal_forest_lib.mjs — the per-goal dependency forest's model and
|
|
2
|
+
// layout, EXECUTED (task 1003480, goal 1000089, ADR 0227 §6 item 4 + §10.5).
|
|
3
|
+
//
|
|
4
|
+
// goal-forest-lib.js is pure, so this runs the real code over synthetic
|
|
5
|
+
// corpora, including one at the measured worst case (§10.5: the biggest single
|
|
6
|
+
// goal's forest is 127 tasks). What is pinned: no task is drawn twice or on
|
|
7
|
+
// top of another, a task always sits right of what it depends on, two
|
|
8
|
+
// independent chains in one goal share the picture without overlapping, and
|
|
9
|
+
// every fact in the picture is also a table row.
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/goal_forest_lib.mjs
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { makeRunner } from './helpers.mjs';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const L = require(path.join(ROOT, 'modules/hall-ui/public/goal-forest-lib.js'));
|
|
21
|
+
|
|
22
|
+
const { test, summary } = makeRunner();
|
|
23
|
+
|
|
24
|
+
const node = (id, extra = {}) => ({ task_id: String(id), title: `Task ${id}`, status: 'ready', priority: 3, ...extra });
|
|
25
|
+
const edge = (from, to) => ({ from: String(from), to: String(to) });
|
|
26
|
+
|
|
27
|
+
function overlaps(a, b) {
|
|
28
|
+
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
29
|
+
}
|
|
30
|
+
function assertSound(a, lay, label) {
|
|
31
|
+
const ids = lay.nodes.map((n) => n.id);
|
|
32
|
+
assert.equal(new Set(ids).size, ids.length, `${label}: no task drawn twice`);
|
|
33
|
+
assert.equal(ids.length, a.order.length, `${label}: every analysed node is drawn — none are edge-less by construction`);
|
|
34
|
+
for (let i = 0; i < lay.nodes.length; i++) {
|
|
35
|
+
for (let j = i + 1; j < lay.nodes.length; j++) {
|
|
36
|
+
assert.equal(overlaps(lay.nodes[i], lay.nodes[j]), false, `${label}: ${lay.nodes[i].id} overlaps ${lay.nodes[j].id}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const n of lay.nodes) {
|
|
40
|
+
assert.ok(n.x >= 0 && n.y >= 0, `${label}: ${n.id} is on the canvas`);
|
|
41
|
+
assert.ok(n.x + n.w <= lay.width && n.y + n.h <= lay.height, `${label}: ${n.id} fits inside the svg's own size`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- structure -----------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
await test('an edge means "from waits on to", and each node counts its own degree', () => {
|
|
48
|
+
const a = L.analyse({ nodes: [node(1), node(2), node(3)], edges: [edge(1, 2), edge(1, 3)] });
|
|
49
|
+
assert.equal(a.waits['1'].length, 2);
|
|
50
|
+
assert.equal(a.needed['2'].length, 1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await test('a self edge, a duplicate, and an edge to an unknown task are all ignored', () => {
|
|
54
|
+
const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(1, 99), edge(2, 2), edge(1, 2), edge(1, 2)] });
|
|
55
|
+
assert.equal(a.edges.length, 1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await test('chains are found, largest first — two disjoint pairs are two chains of size 2, not one', () => {
|
|
59
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4, 5, 6, 7].map((i) => node(i)), edges: [edge(1, 2), edge(3, 4), edge(5, 6), edge(6, 7)] });
|
|
60
|
+
assert.deepEqual(a.chains.map((c) => c.length), [3, 2, 2]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---- layout --------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
await test('a task sits to the RIGHT of every task it depends on', () => {
|
|
66
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4].map((i) => node(i)), edges: [edge(2, 1), edge(3, 2), edge(4, 2)] });
|
|
67
|
+
const lay = L.layout(a);
|
|
68
|
+
const x = Object.fromEntries(lay.nodes.map((n) => [n.id, n.x]));
|
|
69
|
+
for (const e of a.edges) assert.ok(x[e.to] < x[e.from], `${e.from} waits on ${e.to}, must be right of it`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await test('two independent chains in one goal share the picture with no overlap', () => {
|
|
73
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4].map((i) => node(i)), edges: [edge(1, 2), edge(3, 4)] });
|
|
74
|
+
const lay = L.layout(a);
|
|
75
|
+
assertSound(a, lay, 'two chains');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await test('a would-be cycle terminates and still places every task', () => {
|
|
79
|
+
const a = L.analyse({ nodes: [1, 2, 3].map((i) => node(i)), edges: [edge(1, 2), edge(2, 1), edge(3, 1)] });
|
|
80
|
+
const lay = L.layout(a);
|
|
81
|
+
assert.equal(lay.nodes.length, 3);
|
|
82
|
+
assertSound(a, lay, 'cycle');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await test('the same data always gives the same picture', () => {
|
|
86
|
+
const p = { nodes: [1, 2, 3, 4, 5].map((i) => node(i)), edges: [edge(2, 1), edge(3, 1), edge(4, 2), edge(5, 2)] };
|
|
87
|
+
assert.deepEqual(L.layout(L.analyse(p)), L.layout(L.analyse(p)));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('an edge path starts at the dependency\'s right edge and ends at the dependent\'s left edge', () => {
|
|
91
|
+
const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(2, 1)] });
|
|
92
|
+
const lay = L.layout(a);
|
|
93
|
+
const by = Object.fromEntries(lay.nodes.map((n) => [n.id, n]));
|
|
94
|
+
const [e] = lay.edges;
|
|
95
|
+
assert.ok(e.d.startsWith(`M${by['1'].x + lay.nodeW} ${by['1'].y + lay.nodeH / 2}`));
|
|
96
|
+
assert.ok(e.d.endsWith(`${by['2'].x} ${by['2'].y + lay.nodeH / 2}`));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await test('THE MEASURED WORST CASE: a 127-task single goal forest lays out soundly and fast (ADR 0227 §10.5)', () => {
|
|
100
|
+
let seed = 20260921;
|
|
101
|
+
const rnd = () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; };
|
|
102
|
+
const ids = Array.from({ length: 127 }, (_, i) => i + 1);
|
|
103
|
+
const nodes = ids.map((i) => node(i));
|
|
104
|
+
const edges = [];
|
|
105
|
+
for (let i = 1; i < ids.length; i++) edges.push(edge(ids[i], ids[Math.floor(rnd() * i)]));
|
|
106
|
+
const a = L.analyse({ nodes, edges });
|
|
107
|
+
const t0 = Date.now();
|
|
108
|
+
const lay = L.layout(a);
|
|
109
|
+
const ms = Date.now() - t0;
|
|
110
|
+
assertSound(a, lay, 'worst case');
|
|
111
|
+
assert.ok(ms < 500, `laid out in ${ms}ms`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---- table + description ---------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
await test('the table has one row per task and carries both directions by name', () => {
|
|
117
|
+
const a = L.analyse({ nodes: [node(1, { title: 'Base' }), node(2, { title: 'Top' })], edges: [edge(2, 1)] });
|
|
118
|
+
const rows = L.tableRows(a);
|
|
119
|
+
const base = rows.find((r) => r.id === '1');
|
|
120
|
+
const top = rows.find((r) => r.id === '2');
|
|
121
|
+
assert.deepEqual(top.waits, [{ id: '1', title: 'Base' }]);
|
|
122
|
+
assert.deepEqual(base.needed, [{ id: '2', title: 'Top' }]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await test('priority renders as P1..P5, or an em dash for null', () => {
|
|
126
|
+
const a = L.analyse({ nodes: [node(1, { priority: 1 }), node(2, { priority: null })], edges: [edge(1, 2)] });
|
|
127
|
+
const rows = L.tableRows(a);
|
|
128
|
+
assert.equal(rows.find((r) => r.id === '1').priority, 'P1');
|
|
129
|
+
assert.equal(rows.find((r) => r.id === '2').priority, '—');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('a lone pair is labelled plainly, a real chain is labelled and counted', () => {
|
|
133
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4].map((i) => node(i)), edges: [edge(1, 2), edge(2, 3), edge(2, 4)] });
|
|
134
|
+
const rows = L.tableRows(a);
|
|
135
|
+
assert.match(rows[0].chainLabel, /^Chain 1 · 4 tasks$/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await test('describe states the numbers plainly, and an empty forest says so', () => {
|
|
139
|
+
const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(1, 2)] });
|
|
140
|
+
assert.match(L.describe(a), /^2 tasks in this goal have a dependency.*in 1 chain\.$/);
|
|
141
|
+
assert.equal(L.describe(L.analyse({})), 'No task in this goal depends on another task in this goal.');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ---- purity -----------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
await test('the lib is pure: no DOM, no fetch, no cross-goal input', () => {
|
|
147
|
+
const fs = require('fs');
|
|
148
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goal-forest-lib.js'), 'utf8').replace(/^\s*\/\/.*$/gm, '');
|
|
149
|
+
assert.equal(/\bdocument\b|\bfetch\b|\bwindow\b|XMLHttpRequest/.test(src.replace(/root\.OTBGoalForestLib/, '')), false);
|
|
150
|
+
assert.equal(/goal_id|category/i.test(src), false, 'this file knows nothing about goals or categories — that is the map, not the forest');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
summary();
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// tests/goal_forest_page.mjs — the per-goal dependency forest's WIRING on the
|
|
2
|
+
// Goals page, EXECUTED (task 1003480, goal 1000089, ADR 0227 §6 item 4).
|
|
3
|
+
//
|
|
4
|
+
// goal_forest_lib.mjs proves the layout; goal_forest.mjs proves the server
|
|
5
|
+
// route. What neither can see is whether goals-render.js actually calls the
|
|
6
|
+
// real endpoint, actually draws with the real lib, and actually renders the
|
|
7
|
+
// honest states ADR 0227 requires (no edges → said plainly; a failed read →
|
|
8
|
+
// said plainly; never a stack, never a blank canvas). This runs the REAL
|
|
9
|
+
// goal-forest-lib.js + the REAL createGoalsRender(ctx) factory from
|
|
10
|
+
// goals-render.js in a vm — calling `loadDependencyForest`/
|
|
11
|
+
// `renderDependencyForest` directly (they are exported for exactly this)
|
|
12
|
+
// rather than booting the whole page, which needs a much larger fixture for
|
|
13
|
+
// members/completion/invites this task never touches.
|
|
14
|
+
//
|
|
15
|
+
// Run: node tests/goal_forest_page.mjs
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import vm from 'node:vm';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { makeRunner, makeEl, walk } from './helpers.mjs';
|
|
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');
|
|
26
|
+
const LIB_JS = read('goal-forest-lib.js');
|
|
27
|
+
const FOREST_RENDER_JS = read('goal-forest-render.js');
|
|
28
|
+
const RENDER_JS = read('goals-render.js');
|
|
29
|
+
|
|
30
|
+
const { test, summary } = makeRunner();
|
|
31
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
32
|
+
const settle = async () => { for (let i = 0; i < 20; i++) await tick(); };
|
|
33
|
+
|
|
34
|
+
const t = (id, extra = {}) => ({ task_id: String(id), title: `Task ${id}`, status: 'ready', priority: 3, ...extra });
|
|
35
|
+
const e = (from, to) => ({ from: String(from), to: String(to) });
|
|
36
|
+
|
|
37
|
+
async function boot({ status = 200, fail = false, payload = { nodes: [], edges: [] } } = {}) {
|
|
38
|
+
const root = makeEl('div');
|
|
39
|
+
const reg = (id) => { const el = makeEl('div', { id }); root.appendChild(el); return el; };
|
|
40
|
+
['goal-deps-h', 'goal-deps-count', 'goal-deps-lede', 'goal-deps-body'].forEach(reg);
|
|
41
|
+
|
|
42
|
+
const documentObj = {
|
|
43
|
+
getElementById: (id) => walk(root, (el) => (el.id === id ? el : null)),
|
|
44
|
+
createElementNS(_ns, tag) {
|
|
45
|
+
const el = makeEl(tag);
|
|
46
|
+
const orig = el.setAttribute.bind(el);
|
|
47
|
+
el.setAttribute = (k, v) => { orig(k, v); if (k === 'class') el.className = String(v); if (k === 'id') el.id = String(v); };
|
|
48
|
+
return el;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const windowObj = {
|
|
52
|
+
OTB: {
|
|
53
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
54
|
+
$: (sel) => documentObj.getElementById(String(sel).replace(/^#/, '')),
|
|
55
|
+
fmtNum: (n) => String(n), fmtDate: (iso) => String(iso ?? ''), toast() {}, rankLabel: (r) => String(r ?? ''), categoryChip: () => '',
|
|
56
|
+
},
|
|
57
|
+
OTBTaskLib: {},
|
|
58
|
+
OTBKit: {
|
|
59
|
+
makePager: () => ({ page: 1, perPage: 25 }), paginate: () => [], wirePager() {}, pagerBarHtml: () => '',
|
|
60
|
+
makeTabs: () => ({}), makeFilterBar: () => ({}), emptyStateHtml: (m) => m, skeletonRows: () => '',
|
|
61
|
+
},
|
|
62
|
+
OTBGoalsLib: {},
|
|
63
|
+
};
|
|
64
|
+
const requests = [];
|
|
65
|
+
const sandbox = {
|
|
66
|
+
window: windowObj, self: windowObj, document: documentObj,
|
|
67
|
+
console: { error() {}, warn() {}, log() {} },
|
|
68
|
+
setTimeout, clearTimeout,
|
|
69
|
+
fetch: async () => { throw new Error('goals-render.js must use ctx.getJSON, not fetch directly'); },
|
|
70
|
+
};
|
|
71
|
+
sandbox.globalThis = sandbox;
|
|
72
|
+
vm.createContext(sandbox);
|
|
73
|
+
vm.runInContext(LIB_JS, sandbox, { filename: 'goal-forest-lib.js' });
|
|
74
|
+
vm.runInContext(FOREST_RENDER_JS, sandbox, { filename: 'goal-forest-render.js' });
|
|
75
|
+
vm.runInContext(RENDER_JS, sandbox, { filename: 'goals-render.js' });
|
|
76
|
+
|
|
77
|
+
const state = { current: 7, forest: null, forestError: false };
|
|
78
|
+
const ctx = {
|
|
79
|
+
API: '/api/bongos',
|
|
80
|
+
state,
|
|
81
|
+
sameId: (a, b) => String(a) === String(b),
|
|
82
|
+
getJSON: async (p) => {
|
|
83
|
+
requests.push(p);
|
|
84
|
+
if (fail) throw new Error('offline');
|
|
85
|
+
if (status === 401) throw Object.assign(new Error('redirecting to auth'), {});
|
|
86
|
+
return payload;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const renderers = windowObj.OTBGoalsRender(ctx);
|
|
90
|
+
return { $: documentObj.getElementById, requests, state, renderers };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- the honest states (ADR 0227) ---------------------------------------------
|
|
94
|
+
|
|
95
|
+
await test('loadDependencyForest calls the real per-goal endpoint — never a global one', async () => {
|
|
96
|
+
const h = await boot({ payload: { nodes: [], edges: [] } });
|
|
97
|
+
await h.renderers.loadDependencyForest(7);
|
|
98
|
+
assert.deepEqual(h.requests, ['/api/bongos/goals/7/dependency-forest']);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await test('no dependency at all says so plainly — no canvas, no stack, no leftover count', async () => {
|
|
102
|
+
const h = await boot({ payload: { nodes: [], edges: [] } });
|
|
103
|
+
await h.renderers.loadDependencyForest(7);
|
|
104
|
+
assert.match(h.$('goal-deps-lede').textContent, /No task in this goal depends on another task in this goal/);
|
|
105
|
+
assert.equal(h.$('goal-deps-body').innerHTML, '');
|
|
106
|
+
assert.equal(h.$('goal-deps-count').hidden, true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await test('a failed read says so plainly, distinct from "still loading" and from "nothing to show"', async () => {
|
|
110
|
+
const h = await boot({ fail: true });
|
|
111
|
+
await h.renderers.loadDependencyForest(7);
|
|
112
|
+
assert.match(h.$('goal-deps-lede').textContent, /Couldn.t load this goal.s task dependencies/);
|
|
113
|
+
assert.equal(h.$('goal-deps-count').hidden, true, 'a failed read pills the same as an empty one — never a literal 0');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await test('while loading, the lede says so and the body stays empty (no stale content from a prior goal)', async () => {
|
|
117
|
+
const h = await boot({ payload: { nodes: [t(1), t(2)], edges: [e(1, 2)] } });
|
|
118
|
+
const p = h.renderers.loadDependencyForest(7); // deliberately not awaited yet
|
|
119
|
+
assert.equal(h.$('goal-deps-lede').textContent, 'Reading…');
|
|
120
|
+
assert.equal(h.$('goal-deps-body').innerHTML, '');
|
|
121
|
+
await p;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await test('a stale response for a goal the reader navigated away from is dropped', async () => {
|
|
125
|
+
const h = await boot({ payload: { nodes: [t(1), t(2)], edges: [e(1, 2)] } });
|
|
126
|
+
const p = h.renderers.loadDependencyForest(7);
|
|
127
|
+
h.state.current = 999; // navigated to a different goal mid-request
|
|
128
|
+
await p;
|
|
129
|
+
assert.equal(h.$('goal-deps-lede').textContent, 'Reading…', 'the stale answer never overwrote the new goal\'s loading state');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---- a populated forest ------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
await test('a populated forest draws a role=img figure with no focus stops, and every fact is also a table row', async () => {
|
|
135
|
+
const h = await boot({ payload: { nodes: [t(1, { title: 'Base' }), t(2, { title: 'Top' })], edges: [e(2, 1)] } });
|
|
136
|
+
await h.renderers.loadDependencyForest(7);
|
|
137
|
+
const body = h.$('goal-deps-body');
|
|
138
|
+
const svg = body.querySelector('svg');
|
|
139
|
+
assert.ok(svg, 'the forest is drawn');
|
|
140
|
+
assert.equal(svg.getAttribute('role'), 'img');
|
|
141
|
+
assert.equal(svg.querySelectorAll('a').length, 0);
|
|
142
|
+
assert.equal(svg.querySelectorAll('[tabindex]').length, 0);
|
|
143
|
+
const table = body.querySelector('table');
|
|
144
|
+
assert.ok(table, 'the table is on the page by default (ADR 0227 §5)');
|
|
145
|
+
const rows = table.querySelector('tbody').querySelectorAll('tr');
|
|
146
|
+
assert.equal(rows.length, 2, 'one row per task');
|
|
147
|
+
assert.ok(table.textContent.includes('Base') && table.textContent.includes('Top'));
|
|
148
|
+
assert.equal(h.$('goal-deps-count').hidden, false);
|
|
149
|
+
assert.equal(h.$('goal-deps-count').textContent, '2');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await test('the picture scrolls inside its own region and names both overflow axes', async () => {
|
|
153
|
+
const h = await boot({ payload: { nodes: [t(1), t(2)], edges: [e(2, 1)] } });
|
|
154
|
+
await h.renderers.loadDependencyForest(7);
|
|
155
|
+
const region = h.$('goal-deps-body').querySelector('.gf-scroll');
|
|
156
|
+
assert.ok(region);
|
|
157
|
+
assert.equal(region.getAttribute('tabindex'), '0');
|
|
158
|
+
const css = fs.readFileSync(path.join(HALL, 'goals.css'), 'utf8');
|
|
159
|
+
const rule = css.match(/\.gf-scroll\s*\{[^}]*\}/)[0];
|
|
160
|
+
assert.match(rule, /overflow-x:\s*auto/);
|
|
161
|
+
assert.match(rule, /overflow-y:\s*hidden/, 'one axis named alone makes the other auto (task 1004130)');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await test('a task title is builder-authored text and never becomes markup', async () => {
|
|
165
|
+
const evil = '<img src=x onerror=alert(1)>';
|
|
166
|
+
const h = await boot({ payload: { nodes: [t(1, { title: evil }), t(2)], edges: [e(2, 1)] } });
|
|
167
|
+
await h.renderers.loadDependencyForest(7);
|
|
168
|
+
const body = h.$('goal-deps-body');
|
|
169
|
+
assert.equal(body.querySelector('img'), null);
|
|
170
|
+
assert.ok(body.textContent.includes(evil), 'renders as the literal characters, not as HTML');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await test('a redirecting-to-auth failure is swallowed silently, the loadGoalTasks precedent — the page is about to navigate away', async () => {
|
|
174
|
+
const h = await boot({ status: 401 });
|
|
175
|
+
await h.renderers.loadDependencyForest(7);
|
|
176
|
+
assert.equal(h.$('goal-deps-lede').textContent, 'Reading…', 'no error flash on a redirect the browser is already leaving for');
|
|
177
|
+
assert.equal(h.state.forestError, false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ---- wiring into the detail render -------------------------------------------
|
|
181
|
+
|
|
182
|
+
await test('renderDetail calls loadDependencyForest, beside loadGoalTasks, for the OPEN goal', () => {
|
|
183
|
+
assert.match(RENDER_JS, /loadGoalTasks\(g\.id\);\s*\n\s*loadDependencyForest\(g\.id\);/);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await test('the section is its OWN full-width block, not squeezed into the three-column band', () => {
|
|
187
|
+
const html = fs.readFileSync(path.join(HALL, 'goals.html'), 'utf8');
|
|
188
|
+
const bandAt = html.indexOf('class="scroll goal-band"');
|
|
189
|
+
const depsAt = html.indexOf('id="goal-deps-scroll"');
|
|
190
|
+
const tasksAt = html.indexOf('<h2 class="scroll__h">Tasks</h2>');
|
|
191
|
+
assert.ok(bandAt > -1 && depsAt > -1 && tasksAt > -1);
|
|
192
|
+
assert.ok(bandAt < depsAt && depsAt < tasksAt, 'band, then dependencies, then tasks');
|
|
193
|
+
assert.equal(/id="goal-deps-scroll"[\s\S]{0,400}goal-band__col/.test(html), false, 'not inside a band column');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
summary();
|
|
@@ -169,7 +169,7 @@ function harness({ pendingRequests = [] } = {}) {
|
|
|
169
169
|
};
|
|
170
170
|
sandbox.globalThis = sandbox;
|
|
171
171
|
vm.createContext(sandbox);
|
|
172
|
-
for (const f of ['hall-kit.js', 'task-lib.js', 'goals-lib.js', 'goals-render.js']) {
|
|
172
|
+
for (const f of ['hall-kit.js', 'task-lib.js', 'goals-lib.js', 'goal-forest-render.js', 'goals-render.js']) {
|
|
173
173
|
vm.runInContext(read(P + f), sandbox, { filename: f });
|
|
174
174
|
}
|
|
175
175
|
|
package/tests/helpers.mjs
CHANGED
|
@@ -199,10 +199,14 @@ export function hallSource(pubDir = path.join(ROOT, 'modules', 'hall-ui', 'publi
|
|
|
199
199
|
return familySource(pubDir, (f) => f === 'builders.js' || f === 'hall-render.js');
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
// The GOALS page script: goals-page.js plus its
|
|
203
|
-
// (pure helpers, task 1003135)
|
|
202
|
+
// The GOALS page script: goals-page.js plus its carved siblings — goals-lib.js
|
|
203
|
+
// (pure helpers, task 1003135), goals-render.js (view painters, task 1003206),
|
|
204
|
+
// and goal-forest-render.js (the dependency-forest drawing code goals-render.js
|
|
205
|
+
// calls into via window.OTBGoalForestRender(ctx), task 1003480 — without this
|
|
206
|
+
// file in the boot order, createGoalsRender throws immediately, since it calls
|
|
207
|
+
// that factory eagerly at the top).
|
|
204
208
|
export function goalsPageSource(pubDir = path.join(ROOT, 'modules', 'hall-ui', 'public')) {
|
|
205
|
-
return familySource(pubDir, (f) => ['goals-page.js', 'goals-lib.js', 'goals-render.js'].includes(f));
|
|
209
|
+
return familySource(pubDir, (f) => ['goals-page.js', 'goals-lib.js', 'goal-forest-render.js', 'goals-render.js'].includes(f));
|
|
206
210
|
}
|
|
207
211
|
|
|
208
212
|
// The KERNEL IDENTITY surface: auth.js (session resolution, the middleware and the
|
|
@@ -67,10 +67,14 @@ const PAYLOAD = {
|
|
|
67
67
|
{ type: 'non-profit', bundle: ['discord'], summary: 'Somewhere the community around the cause can gather and be heard.' },
|
|
68
68
|
{ type: 'not-sure', bundle: [], summary: 'Just the core, and nothing else to decide today. Add modules whenever you know what you need.' },
|
|
69
69
|
],
|
|
70
|
+
// `aliases` — search words a row is findable under that its own copy never uses
|
|
71
|
+
// (task 1003500, amending ADR 0244 §3). Never rendered, like the key. dev-box
|
|
72
|
+
// carries none on purpose, so the "a row without aliases still matches on its own
|
|
73
|
+
// words" path is exercised by the same fixture rather than by a special case.
|
|
70
74
|
optional: [
|
|
71
|
-
{ key: 'agents', label: 'Extra agents', summary: 'More AI agents alongside the ones every project gets.' },
|
|
75
|
+
{ key: 'agents', label: 'Extra agents', summary: 'More AI agents alongside the ones every project gets.', aliases: ['bots', 'assistants', 'automation'] },
|
|
72
76
|
{ key: 'dev-box', label: 'Dev boxes', summary: 'A cloud workspace where a change can be tried out first.' },
|
|
73
|
-
{ key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news.' },
|
|
77
|
+
{ key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news.', aliases: ['chatroom', 'talk', 'community'] },
|
|
74
78
|
],
|
|
75
79
|
};
|
|
76
80
|
|
|
@@ -616,8 +620,8 @@ test('the picker and the server resolve the same set for every answer an owner c
|
|
|
616
620
|
const DEV_BOX = PAYLOAD.optional[1];
|
|
617
621
|
|
|
618
622
|
test('the matcher reads a row\'s own words — its name, its summary and its key', () => {
|
|
619
|
-
//
|
|
620
|
-
//
|
|
623
|
+
// Still lexical, never semantic: the only words it knows are the ones the owner
|
|
624
|
+
// can see, plus the two it cannot — the key, and the declared aliases below.
|
|
621
625
|
const h = boot({ state: { projType: 'game' } });
|
|
622
626
|
|
|
623
627
|
assert.equal(h.matches(DEV_BOX, 'boxes'), true, 'by a word in the name');
|
|
@@ -626,6 +630,77 @@ test('the matcher reads a row\'s own words — its name, its summary and its key
|
|
|
626
630
|
assert.equal(h.matches(DEV_BOX, 'discord'), false, 'and never by a word that is in none of them');
|
|
627
631
|
});
|
|
628
632
|
|
|
633
|
+
// ── task 1003500: the aliases, and the gap they close ────────────────────────
|
|
634
|
+
// ADR 0244 §3 said "no synonyms" and deferred search-by-meaning to an embeddings
|
|
635
|
+
// layer (idea 1000552). That layer was declined for this catalog: 22 modules whose
|
|
636
|
+
// only free text is a title and one sentence do not need a model to be findable,
|
|
637
|
+
// and a declared word list is fixable by whoever reads the complaint. These pin the
|
|
638
|
+
// outcome ADR 0244 actually named as missing.
|
|
639
|
+
|
|
640
|
+
test('a row is findable under a word an owner would use but its own copy never does', () => {
|
|
641
|
+
const h = boot({ state: { projType: 'game' } });
|
|
642
|
+
const discord = PAYLOAD.optional[2];
|
|
643
|
+
|
|
644
|
+
// The WORD at the heart of the example ADR 0244 §3 gave. Before this, none of
|
|
645
|
+
// these found anything: "chat", "talk" and "community" appear nowhere in
|
|
646
|
+
// Discord's label, summary or key.
|
|
647
|
+
assert.equal(h.matches(discord, 'chat'), true, 'typing "chat" finds Discord');
|
|
648
|
+
assert.equal(h.matches(discord, 'talk'), true, 'and so does the other word for it');
|
|
649
|
+
assert.equal(h.matches(discord, 'community'), true);
|
|
650
|
+
assert.equal(h.matches(PAYLOAD.optional[0], 'bots'), true, 'agents answers to "bots"');
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test('a SENTENCE still finds nothing — aliases fix the word, not the phrase', () => {
|
|
654
|
+
// The boundary, pinned rather than promised. ADR 0244 §3 illustrated the gap with
|
|
655
|
+
// "somewhere for people to chat", and it is tempting to read the aliases above as
|
|
656
|
+
// having closed exactly that. They have not: the matcher ANDs every term, so
|
|
657
|
+
// `somewhere` and `people` must be in the haystack too, and no alias list can make
|
|
658
|
+
// a phrase query work. The fix for that is OR-then-rank, which §3 and §5 reject on
|
|
659
|
+
// their own merits — so this row failing is the DESIGN, and a future change that
|
|
660
|
+
// makes it pass should have to come here and say why.
|
|
661
|
+
const h = boot({ state: { projType: 'game' } });
|
|
662
|
+
const discord = PAYLOAD.optional[2];
|
|
663
|
+
|
|
664
|
+
assert.equal(h.matches(discord, 'somewhere for people to chat'), false,
|
|
665
|
+
'the sentence ADR 0244 named is still outside a literal AND filter');
|
|
666
|
+
assert.equal(h.matches(discord, 'people to chat'), false, 'and so is a shorter phrase around the same word');
|
|
667
|
+
assert.equal(h.matches(discord, 'chat'), true, 'while the word inside it lands — that is the whole of what aliases buy');
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test('an alias is a search word only — it is never shown to anyone', async () => {
|
|
671
|
+
// The same standing the key has. A row that advertised "chatroom, talk, community"
|
|
672
|
+
// under its summary would be noise on a screen that is trying to be a short list.
|
|
673
|
+
const h = boot({ state: { projType: 'game' } });
|
|
674
|
+
await h.open();
|
|
675
|
+
|
|
676
|
+
for (const row of h.rows()) {
|
|
677
|
+
for (const word of ['chatroom', 'bots', 'automation']) {
|
|
678
|
+
assert.ok(!String(row.label).includes(word), `label leaks "${word}"`);
|
|
679
|
+
assert.ok(!String(row.summary).includes(word), `summary leaks "${word}"`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test('aliases narrow like any other word, and a row without them still matches on its own', () => {
|
|
685
|
+
const h = boot({ state: { projType: 'game' } });
|
|
686
|
+
const discord = PAYLOAD.optional[2];
|
|
687
|
+
|
|
688
|
+
assert.equal(h.matches(discord, 'chat news'), true, 'an alias AND a summary word still narrows to the row that has both');
|
|
689
|
+
assert.equal(h.matches(discord, 'chat workspace'), false, 'an alias does not rescue a term the row cannot answer');
|
|
690
|
+
assert.equal(h.matches(DEV_BOX, 'chat'), false, 'and a row declaring no aliases is not matched by another row\'s');
|
|
691
|
+
assert.equal(h.matches(DEV_BOX, 'workspace'), true, 'while its own words keep working untouched');
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test('typing an alias filters the band to that row, and the count says so', async () => {
|
|
695
|
+
const h = boot({ state: { projType: 'game' } });
|
|
696
|
+
await h.open();
|
|
697
|
+
|
|
698
|
+
h.type('chat');
|
|
699
|
+
|
|
700
|
+
assert.deepEqual(h.rows().map((r) => r.key), ['discord'], 'the band narrows to the row the alias names');
|
|
701
|
+
assert.match(h.filterNote(), /Showing 1 of 3/, 'and the line under the field counts honestly');
|
|
702
|
+
});
|
|
703
|
+
|
|
629
704
|
test('a term matches as a SUBSTRING, so a half-typed word still finds its module', () => {
|
|
630
705
|
const h = boot({ state: { projType: 'game' } });
|
|
631
706
|
|
|
@@ -379,6 +379,24 @@ test('every opt-in module carries owner-facing copy, and no module jargon rides
|
|
|
379
379
|
}
|
|
380
380
|
});
|
|
381
381
|
|
|
382
|
+
test('every opt-in module declares search words its own copy does not already carry', () => {
|
|
383
|
+
// Task 1003500. The picker's filter is literal, so a module is findable only under
|
|
384
|
+
// the vocabulary WE chose for it — which is how "somewhere for people to chat" came
|
|
385
|
+
// to find nothing (ADR 0244 §3). Requiring aliases here is the same move task 1003962
|
|
386
|
+
// made for the copy above: a new opt-in module cannot ship without them.
|
|
387
|
+
for (const row of OPTIONAL_MODULES) {
|
|
388
|
+
assert.ok(Array.isArray(row.aliases) && row.aliases.length >= 3,
|
|
389
|
+
`"${row.key}" needs at least three search aliases — an owner who does not know its name must still find it`);
|
|
390
|
+
const own = `${row.label} ${row.summary} ${row.key}`.toLowerCase();
|
|
391
|
+
for (const alias of row.aliases) {
|
|
392
|
+
assert.equal(alias, alias.toLowerCase().trim(), `"${row.key}" alias "${alias}" must be lower-case and trimmed — the haystack is lower-cased, never the query alone`);
|
|
393
|
+
assert.doesNotMatch(alias, /\s/, `"${row.key}" alias "${alias}" must be one word — the query is split on whitespace, so a phrase can never match`);
|
|
394
|
+
assert.ok(!own.includes(alias),
|
|
395
|
+
`"${row.key}" alias "${alias}" is already in its label, summary or key — it is findable without it, and a duplicate is a line to maintain for nothing`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
382
400
|
test('every module a bundle preselects is a row the picker can actually render', () => {
|
|
383
401
|
// A preselect with no catalog row would light up nothing — the panel would
|
|
384
402
|
// silently ship a choice the owner cannot see or switch off.
|
|
@@ -483,7 +501,13 @@ test('GET /provisioning/starter-bundles carries the opt-in catalog the picker re
|
|
|
483
501
|
assert.equal(res.status, 200, res.raw);
|
|
484
502
|
assert.deepEqual(res.body.optional, OPTIONAL_MODULES.map((m) => ({ ...m })));
|
|
485
503
|
for (const row of res.body.optional) {
|
|
486
|
-
|
|
504
|
+
// `aliases` joined this shape in task 1003500 — search words the picker's
|
|
505
|
+
// matcher reads and never renders. Optional, so a row may carry none.
|
|
506
|
+
assert.deepEqual(Object.keys(row).sort().filter((k) => k !== 'aliases'), ['key', 'label', 'summary']);
|
|
507
|
+
if (row.aliases !== undefined) {
|
|
508
|
+
assert.ok(Array.isArray(row.aliases) && row.aliases.every((a) => typeof a === 'string'),
|
|
509
|
+
`"${row.key}" aliases must be a list of strings — the panel joins them into one haystack`);
|
|
510
|
+
}
|
|
487
511
|
}
|
|
488
512
|
});
|
|
489
513
|
|