@bongos/core 1.19.946 → 1.19.947

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.
@@ -0,0 +1,192 @@
1
+ // tests/goal_graph.mjs — GET /goals/graph, the data under the goal map (task
2
+ // 1003479, goal 1000089, ADR 0227 §6 item 3 + §10.4).
3
+ //
4
+ // The endpoint is an AGGREGATE: one node per goal, one edge per ordered pair of
5
+ // goals whose tasks depend on each other. What this file pins is the part a
6
+ // reader cannot see from the picture — that the shape is right (a count of goals
7
+ // is not a count of task edges), that a private goal is redacted rather than
8
+ // dropped, that no task id or title can leave the query, and that the identity
9
+ // deciding all of it comes from the authenticated request.
10
+ //
11
+ // DB-free: the pure shaper is run directly, the SQL is run against a recording
12
+ // fake pool, and the handler against stub collaborators (the goal-category.js
13
+ // handler-factory precedent).
14
+ //
15
+ // Run: node tests/goal_graph.mjs
16
+ process.env.NODE_ENV = 'test';
17
+ import assert from 'node:assert/strict';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { createRequire } from 'node:module';
22
+ import { makeRunner } from './helpers.mjs';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const { shapeGraph, goalGraphRows, goalGraph, goalGraphHandler } = require(path.join(ROOT, 'modules/lifecycle/goal-graph.js'));
27
+ const { DONE_STATUSES, CUT_STATUSES } = require(path.join(ROOT, 'modules/lifecycle/task-status.js'));
28
+ const GOALS_ROUTE = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/goals.js'), 'utf8');
29
+ const GRAPH_SRC = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/goal-graph.js'), 'utf8');
30
+
31
+ const { test, summary } = makeRunner();
32
+
33
+ const CATS = new Map([['7', { id: 7, slug: 'security', label: 'Security' }]]);
34
+ const g = (id, extra = {}) => ({ goal_id: String(id), title: `Goal ${id}`, status: 'open', version_id: 'V1', category_id: null, visible: true, task_total: 4, task_done: 1, ...extra });
35
+ const e = (from, to, count = 1) => ({ from_goal_id: String(from), to_goal_id: String(to), count });
36
+
37
+ // ---- shapeGraph --------------------------------------------------------------
38
+
39
+ await test('an edge means "from waits on to", and each node counts DISTINCT goals, not task edges', () => {
40
+ const out = shapeGraph({ goals: [g(1), g(2), g(3)], edges: [e(1, 2, 9), e(1, 3, 1), e(3, 2, 2)] });
41
+ const by = Object.fromEntries(out.nodes.map((n) => [n.goal_id, n]));
42
+ assert.deepEqual(out.edges, [{ from: '1', to: '2', count: 9 }, { from: '1', to: '3', count: 1 }, { from: '3', to: '2', count: 2 }]);
43
+ assert.equal(by['1'].depends_on, 2, 'goal 1 waits on two goals, though ten task edges carry it');
44
+ assert.equal(by['2'].depended_on_by, 2);
45
+ assert.equal(by['2'].depends_on, 0);
46
+ });
47
+
48
+ await test('counts split connected from isolated, and the two add up to the goals', () => {
49
+ const out = shapeGraph({ goals: [g(1), g(2), g(3), g(4)], edges: [e(1, 2)] });
50
+ assert.deepEqual(out.counts, { goals: 4, connected: 2, isolated: 2, edges: 1 });
51
+ });
52
+
53
+ await test('an edge to a goal that is not a node is dropped, not drawn to nowhere; self and duplicate edges too', () => {
54
+ const out = shapeGraph({ goals: [g(1), g(2)], edges: [e(1, 99), e(2, 2), e(1, 2, 3), e(1, 2, 3)] });
55
+ assert.deepEqual(out.edges, [{ from: '1', to: '2', count: 3 }]);
56
+ });
57
+
58
+ await test('a category is attached as {id, slug, label}; an uncategorised goal carries null (legal, ADR 0192)', () => {
59
+ const out = shapeGraph({ goals: [g(1, { category_id: 7 }), g(2)], edges: [] }, CATS);
60
+ assert.deepEqual(out.nodes[0].category, { id: '7', slug: 'security', label: 'Security' });
61
+ assert.equal(out.nodes[1].category, null);
62
+ });
63
+
64
+ await test('a PRIVATE goal the viewer cannot see stays a node (so neighbours are not falsely isolated) but loses title and category', () => {
65
+ const out = shapeGraph({ goals: [g(1, { visible: false, category_id: 7, title: 'Secret roadmap' }), g(2)], edges: [e(2, 1)] }, CATS);
66
+ const hidden = out.nodes[0];
67
+ assert.equal(hidden.title, null);
68
+ assert.equal(hidden.category, null);
69
+ assert.equal(hidden.task_total, 4, 'counts are not the secret — the prose is (ADR 0112)');
70
+ assert.equal(hidden.depended_on_by, 1, 'and its edges still count, or the structure would lie');
71
+ assert.equal(JSON.stringify(out).includes('Secret roadmap'), false, 'the title is nowhere in the payload');
72
+ });
73
+
74
+ await test('an empty corpus is a valid, empty graph', () => {
75
+ assert.deepEqual(shapeGraph({}), { nodes: [], edges: [], counts: { goals: 0, connected: 0, isolated: 0, edges: 0 } });
76
+ });
77
+
78
+ // ---- the SQL, against a recording pool ----------------------------------------
79
+
80
+ function fakePool(goalRows = [], edgeRows = []) {
81
+ const calls = [];
82
+ return {
83
+ calls,
84
+ async query(sql, params) {
85
+ calls.push({ sql, params });
86
+ return { rows: /FROM dependencies/.test(sql) ? edgeRows : goalRows };
87
+ },
88
+ };
89
+ }
90
+
91
+ await test('the viewer, the Archon flag and the shared status buckets ride as bind parameters — never interpolated', async () => {
92
+ const pool = fakePool();
93
+ await goalGraphRows({ versionId: 'V1', viewerId: 42, viewerIsArchon: true }, { pool });
94
+ const [goalsQ] = pool.calls;
95
+ assert.deepEqual(goalsQ.params, ['V1', true, 42, CUT_STATUSES, DONE_STATUSES]);
96
+ assert.equal(/\b42\b/.test(goalsQ.sql), false, 'no viewer id in the SQL text');
97
+ });
98
+
99
+ await test('only an explicit archon === true widens visibility (a truthy string is not an Archon)', async () => {
100
+ const pool = fakePool();
101
+ await goalGraphRows({ viewerId: 1, viewerIsArchon: 'yes' }, { pool });
102
+ assert.equal(pool.calls[0].params[1], false);
103
+ });
104
+
105
+ await test('a repeated ?version= (parsed as an array by the query string) is flattened before it binds — never a driver-level 500', async () => {
106
+ const pool = fakePool();
107
+ await goalGraphRows({ versionId: ['V1', 'V2'] }, { pool });
108
+ assert.equal(pool.calls[0].params[0], 'V1');
109
+ assert.equal(pool.calls[1].params[0], 'V1');
110
+ });
111
+
112
+ await test('the edge query keeps only task→task dependencies that CROSS a goal boundary', async () => {
113
+ const pool = fakePool();
114
+ await goalGraphRows({}, { pool });
115
+ const sql = pool.calls[1].sql;
116
+ assert.match(sql, /d\.from_kind = 'task'/);
117
+ assert.match(sql, /d\.to_kind\s+= 'task'/);
118
+ assert.match(sql, /ft\.goal_id <> tt\.goal_id/, 'a dependency inside one goal belongs to the per-goal forest, not this map');
119
+ });
120
+
121
+ await test('NO TASK IDENTITY leaves either query — nothing selects a task id or a task title', async () => {
122
+ const pool = fakePool();
123
+ await goalGraphRows({}, { pool });
124
+ for (const { sql } of pool.calls) {
125
+ const select = sql.slice(sql.lastIndexOf('SELECT'), sql.indexOf('FROM', sql.lastIndexOf('SELECT')));
126
+ assert.equal(/\bt\.(id|title)\b|\b(ft|tt)\.(id|title)\b/.test(select), false, `a task column is selected: ${select}`);
127
+ }
128
+ });
129
+
130
+ await test('the payload is the shaped graph, categories included, from one fake read', async () => {
131
+ const pool = fakePool([{ goal_id: '1', title: 'A', status: 'open', version_id: 'V1', category_id: 7, visible: true, task_total: 3, task_done: 3 },
132
+ { goal_id: '2', title: 'B', status: 'open', version_id: 'V1', category_id: null, visible: true, task_total: 2, task_done: 0 }],
133
+ [{ from_goal_id: '2', to_goal_id: '1', count: 5 }]);
134
+ const out = await goalGraph({ viewerId: 1 }, { pool, categoryIndex: async () => CATS });
135
+ assert.equal(out.nodes[0].category.label, 'Security');
136
+ assert.deepEqual(out.edges, [{ from: '2', to: '1', count: 5 }]);
137
+ assert.equal(out.counts.connected, 2);
138
+ });
139
+
140
+ // ---- the handler ---------------------------------------------------------------
141
+
142
+ function run(handler, req) {
143
+ const res = { body: null, failed: null, json(b) { this.body = b; return this; }, fail(code, o) { this.failed = { code, o }; return this; } };
144
+ return handler(req, res).then(() => res);
145
+ }
146
+
147
+ await test('the handler reads identity from the authenticated request, never the query', async () => {
148
+ let seen = null;
149
+ const h = goalGraphHandler({ read: async (o) => { seen = o; return { nodes: [], edges: [], counts: {} }; }, log: { error() {} } });
150
+ const res = await run(h, { builder: { id: 7, rank: 'metic' }, query: { version: 'V2', viewerId: '999', viewerIsArchon: 'true' } });
151
+ assert.deepEqual(seen, { versionId: 'V2', viewerId: 7, viewerIsArchon: false });
152
+ assert.ok(res.body);
153
+ });
154
+
155
+ await test('an Archon request widens visibility; no version means every version', async () => {
156
+ let seen = null;
157
+ const h = goalGraphHandler({ read: async (o) => { seen = o; return {}; }, log: { error() {} } });
158
+ await run(h, { builder: { id: 1, rank: 'archon' }, query: {} });
159
+ assert.deepEqual(seen, { versionId: null, viewerId: 1, viewerIsArchon: true });
160
+ });
161
+
162
+ await test('a failed read is a named 500, never a stack trace', async () => {
163
+ const errors = [];
164
+ const h = goalGraphHandler({ read: async () => { throw new Error('db down'); }, log: { error: (...a) => errors.push(a) } });
165
+ const res = await run(h, { builder: { id: 1, rank: 'metic' }, query: {} });
166
+ assert.equal(res.failed.code, 'graph_failed');
167
+ assert.equal(res.failed.o.status, 500);
168
+ assert.equal(errors.length, 1);
169
+ });
170
+
171
+ // ---- mounting --------------------------------------------------------------------
172
+
173
+ await test('/goals/graph is mounted for any signed-in builder, BEFORE /goals/:id (or Express reads "graph" as an id)', () => {
174
+ const graphAt = GOALS_ROUTE.indexOf("router.get('/goals/graph'");
175
+ const idAt = GOALS_ROUTE.indexOf("router.get('/goals/:id'");
176
+ assert.ok(graphAt > -1, 'the route is registered');
177
+ assert.ok(graphAt < idAt, 'literal before param');
178
+ assert.match(GOALS_ROUTE.slice(graphAt, graphAt + 120), /auth\.requireBuilder/);
179
+ assert.match(GOALS_ROUTE.slice(Math.max(0, graphAt - 200), graphAt), /rank: any-builder/, 'the route-rank scanner needs its declaration');
180
+ });
181
+
182
+ await test('the module never branches on a category — it is a label here (ADR 0192)', () => {
183
+ const code = GRAPH_SRC.replace(/^\s*\/\/.*$/gm, '');
184
+ assert.equal(/\bif\s*\([^)]*category/i.test(code), false);
185
+ // Each WHERE runs to the next GROUP BY / ORDER BY / closing CTE paren — a
186
+ // category may be SELECTed for the label, and must never appear in a filter.
187
+ const wheres = code.split(/\bWHERE\b/).slice(1).map((s) => s.split(/GROUP BY|ORDER BY|\n\s*\)/)[0]);
188
+ assert.ok(wheres.length >= 2, 'sanity: the queries have WHERE clauses to check');
189
+ for (const w of wheres) assert.equal(/category/i.test(w), false, `a query filters on category: ${w}`);
190
+ });
191
+
192
+ summary();
@@ -0,0 +1,214 @@
1
+ // tests/goal_map_lib.mjs — the goal map's model and layout, EXECUTED (task
2
+ // 1003479, goal 1000089, ADR 0227 §6 item 3 + the §10.4 amendment).
3
+ //
4
+ // goal-map-lib.js is pure, so this runs the real code over the real harness
5
+ // fixture and over a synthetic corpus built to the SHAPE the ADR measured on the
6
+ // live database (70 goals, 34 of them with no edge, the rest in one large cluster
7
+ // and a scatter of small ones). What is pinned is what a reader would otherwise
8
+ // have to trust the picture about: that no goal is drawn twice or on top of
9
+ // another, that a goal always sits to the right of what it waits on, that the
10
+ // stand-alone goals never reach the canvas, that the same data always gives the
11
+ // same picture, and that every fact the picture carries is also a table cell.
12
+ //
13
+ // Run: node tests/goal_map_lib.mjs
14
+ import assert from 'node:assert/strict';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { createRequire } from 'node:module';
19
+ import { makeRunner } from './helpers.mjs';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const LIB_PATH = path.join(ROOT, 'modules/hall-ui/public/goal-map-lib.js');
24
+ const L = require(LIB_PATH);
25
+ const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/goals__graph.json'), 'utf8'));
26
+
27
+ const { test, summary } = makeRunner();
28
+
29
+ const node = (id, extra = {}) => ({ goal_id: String(id), title: `Goal ${id}`, status: 'open', version_id: 'V1', category: null, task_total: 5, task_done: 2, ...extra });
30
+ const edge = (from, to, count = 1) => ({ from: String(from), to: String(to), count });
31
+
32
+ function overlaps(a, b) {
33
+ 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;
34
+ }
35
+ function assertSound(a, lay, label) {
36
+ const ids = lay.nodes.map((n) => n.id);
37
+ assert.equal(new Set(ids).size, ids.length, `${label}: no goal is drawn twice`);
38
+ assert.equal(ids.length, a.connected, `${label}: exactly the connected goals are drawn`);
39
+ for (let i = 0; i < lay.nodes.length; i++) {
40
+ for (let j = i + 1; j < lay.nodes.length; j++) {
41
+ assert.equal(overlaps(lay.nodes[i], lay.nodes[j]), false, `${label}: ${lay.nodes[i].id} overlaps ${lay.nodes[j].id}`);
42
+ }
43
+ }
44
+ for (const n of lay.nodes) {
45
+ assert.ok(n.x >= 0 && n.y >= 0, `${label}: ${n.id} is on the canvas`);
46
+ assert.ok(n.x + n.w <= lay.width && n.y + n.h <= lay.height, `${label}: ${n.id} fits inside the svg's own size`);
47
+ }
48
+ }
49
+
50
+ // A deterministic synthetic corpus with the measured shape: `clusterSizes` goals
51
+ // wired into connected clusters (each a chain plus a few extra edges) and `alone`
52
+ // goals with no edge. A tiny LCG, so the test is the same on every machine.
53
+ function synth(clusterSizes, alone) {
54
+ let seed = 20260919;
55
+ const rnd = () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; };
56
+ const nodes = []; const edges = []; let id = 1000;
57
+ for (const size of clusterSizes) {
58
+ const ids = [];
59
+ for (let i = 0; i < size; i++) { ids.push(++id); nodes.push(node(id, { category: rnd() < 0.5 ? { id: '1', slug: 'security', label: 'Security' } : null })); }
60
+ for (let i = 1; i < size; i++) edges.push(edge(ids[i], ids[Math.floor(rnd() * i)], 1 + Math.floor(rnd() * 4)));
61
+ for (let k = 0; k < Math.floor(size / 3); k++) {
62
+ const a = ids[Math.floor(rnd() * size)]; const b = ids[Math.floor(rnd() * size)];
63
+ if (a !== b && !edges.some((e) => e.from === String(a) && e.to === String(b))) edges.push(edge(a, b));
64
+ }
65
+ }
66
+ for (let i = 0; i < alone; i++) nodes.push(node(++id));
67
+ return { nodes, edges };
68
+ }
69
+
70
+ // ---- structure -----------------------------------------------------------------
71
+
72
+ await test('the goals split into connected and stand-alone, and the two add up', () => {
73
+ const a = L.analyse({ nodes: [node(1), node(2), node(3), node(4)], edges: [edge(1, 2)] });
74
+ assert.equal(a.connected, 2);
75
+ assert.deepEqual(a.isolated, ['3', '4']);
76
+ assert.equal(a.connected + a.isolated.length, a.order.length);
77
+ });
78
+
79
+ await test('clusters are found, largest first, and every connected goal knows its cluster', () => {
80
+ const a = L.analyse({ nodes: [1, 2, 3, 4, 5, 6, 7].map((i) => node(i)), edges: [edge(1, 2), edge(3, 4), edge(4, 5), edge(5, 6)] });
81
+ assert.deepEqual(a.clusters.map((c) => c.length), [4, 2]);
82
+ assert.equal(a.clusterOf['3'].index, 1);
83
+ assert.equal(a.clusterOf['3'].size, 4);
84
+ assert.equal(a.clusterOf['1'].index, 2);
85
+ assert.equal(a.clusterOf['7'], undefined, 'a stand-alone goal belongs to no cluster');
86
+ });
87
+
88
+ await test('an edge to an unknown goal, a self edge and a duplicate are ignored, not drawn to nowhere', () => {
89
+ const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(1, 99), edge(2, 2), edge(1, 2, 3), edge(1, 2, 3)] });
90
+ assert.equal(a.edges.length, 1);
91
+ });
92
+
93
+ // ---- layout --------------------------------------------------------------------
94
+
95
+ await test('a goal sits to the RIGHT of every goal it waits on (dependencies read left to right)', () => {
96
+ const a = L.analyse({ nodes: [1, 2, 3, 4].map((i) => node(i)), edges: [edge(2, 1), edge(3, 2), edge(4, 2)] });
97
+ const lay = L.layout(a);
98
+ const x = Object.fromEntries(lay.nodes.map((n) => [n.id, n.x]));
99
+ for (const e of a.edges) assert.ok(x[e.to] < x[e.from], `${e.from} waits on ${e.to}, so must be right of it`);
100
+ });
101
+
102
+ await test('stand-alone goals are never drawn — they are a list, not dots on an empty canvas', () => {
103
+ const a = L.analyse({ nodes: [node(1), node(2), node(3)], edges: [edge(1, 2)] });
104
+ const lay = L.layout(a);
105
+ assert.deepEqual(lay.nodes.map((n) => n.id).sort(), ['1', '2']);
106
+ });
107
+
108
+ await test('a loop (two goals each waiting on the other) terminates and places both', () => {
109
+ const a = L.analyse({ nodes: [node(1), node(2), node(3)], edges: [edge(1, 2), edge(2, 1), edge(3, 1)] });
110
+ const lay = L.layout(a);
111
+ assert.equal(lay.nodes.length, 3);
112
+ assertSound(a, lay, 'loop');
113
+ });
114
+
115
+ await test('the same data always gives the same picture (no random seed, no drift between visits)', () => {
116
+ const p = synth([9, 4, 3], 6);
117
+ assert.deepEqual(L.layout(L.analyse(p)), L.layout(L.analyse(p)));
118
+ });
119
+
120
+ await test('every edge path starts at its dependency and ends at its dependent', () => {
121
+ const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(2, 1, 4)] });
122
+ const lay = L.layout(a);
123
+ const by = Object.fromEntries(lay.nodes.map((n) => [n.id, n]));
124
+ const [e] = lay.edges;
125
+ assert.ok(e.d.startsWith(`M${by['1'].x + lay.nodeW} ${by['1'].y + lay.nodeH / 2}`), 'leaves the dependency’s right edge');
126
+ assert.ok(e.d.endsWith(`${by['2'].x} ${by['2'].y + lay.nodeH / 2}`), 'arrives at the dependent’s left edge');
127
+ assert.ok(e.width >= 1 && e.width <= 3);
128
+ });
129
+
130
+ await test('the harness fixture lays out soundly — one large cluster, pairs, a loop and a private goal', () => {
131
+ const a = L.analyse(FIXTURE);
132
+ const lay = L.layout(a);
133
+ assertSound(a, lay, 'fixture');
134
+ assert.ok(a.clusters.length >= 4 && a.clusters[0].length > a.clusters[1].length, 'reads as one cluster and a scatter');
135
+ assert.ok(a.isolated.length > 0);
136
+ });
137
+
138
+ await test('THE SCALE CHECK: the measured shape (70 goals, 34 alone, one big cluster) lays out soundly and fast', () => {
139
+ const p = synth([17, 6, 4, 3, 2, 2, 2], 34); // 36 connected + 34 alone = the measured 70
140
+ assert.equal(p.nodes.length, 70);
141
+ const a = L.analyse(p);
142
+ assert.equal(a.isolated.length, 34);
143
+ const t0 = Date.now();
144
+ const lay = L.layout(a);
145
+ const ms = Date.now() - t0;
146
+ assertSound(a, lay, 'scale');
147
+ assert.ok(ms < 500, `laid out in ${ms}ms`);
148
+ // The page scrolls the svg INSIDE its own box, so a wide picture is fine — but
149
+ // it must not be absurd: the largest cluster is a handful of columns, not a line.
150
+ assert.ok(lay.width < 3200, `width ${lay.width}`);
151
+ });
152
+
153
+ // ---- categories and the table ---------------------------------------------------
154
+
155
+ await test('a category keeps its colour by alphabetical order of the labels present, and repeats past eight', () => {
156
+ const cats = ['Zeta', 'Alpha', 'Mid'].map((label, i) => ({ id: String(i), slug: label.toLowerCase(), label }));
157
+ const a = L.analyse({ nodes: [node(1, { category: cats[0] }), node(2, { category: cats[1] }), node(3, { category: cats[2] }), node(4)], edges: [] });
158
+ const c = L.categories(a);
159
+ assert.deepEqual(c.list.map((x) => x.label), ['Alpha', 'Mid', 'Zeta']);
160
+ assert.deepEqual(c.list.map((x) => x.colour), [0, 1, 2]);
161
+ assert.equal(c.uncategorised, 1);
162
+ assert.equal(L.colourOf(a.byId['4'], c), 'none', 'uncategorised is legal and wears no colour');
163
+ const many = Array.from({ length: 10 }, (_, i) => ({ id: String(i), slug: `c${String(i).padStart(2, '0')}`, label: `C${String(i).padStart(2, '0')}` }));
164
+ const big = L.categories(L.analyse({ nodes: many.map((m, i) => node(i + 1, { category: m })), edges: [] }));
165
+ assert.equal(big.list[8].colour, 0, 'the ninth wraps — the WORD on the node still says which it is');
166
+ });
167
+
168
+ await test('the table has one row per goal and carries every fact the picture does, both directions by name', () => {
169
+ const a = L.analyse({ nodes: [node(1, { title: 'Base', task_total: 9, task_done: 4, category: { id: '1', slug: 's', label: 'Security' } }), node(2, { title: 'Top' }), node(3, { title: 'Alone' })], edges: [edge(2, 1, 6)] });
170
+ const rows = L.tableRows(a);
171
+ assert.equal(rows.length, 3, 'stand-alone goals are rows too — the table is the whole map');
172
+ const base = rows.find((r) => r.id === '1');
173
+ const top = rows.find((r) => r.id === '2');
174
+ assert.equal(base.category, 'Security');
175
+ assert.equal(base.done + '/' + base.total, '4/9');
176
+ assert.deepEqual(top.waits, [{ id: '1', title: 'Base', count: 6 }]);
177
+ assert.deepEqual(base.needed, [{ id: '2', title: 'Top', count: 6 }]);
178
+ assert.equal(top.clusterLabel, 'Cluster 1 · 2 goals');
179
+ assert.equal(rows.find((r) => r.id === '3').clusterLabel, 'Stands alone');
180
+ assert.equal(rows[rows.length - 1].id, '3', 'stand-alone goals sort last');
181
+ });
182
+
183
+ await test('a private goal is named as one, in the table and in a neighbour’s dependency list', () => {
184
+ const a = L.analyse({ nodes: [node(1, { title: null }), node(2)], edges: [edge(2, 1)] });
185
+ const rows = L.tableRows(a);
186
+ assert.equal(rows.find((r) => r.id === '1').title, 'A private goal');
187
+ assert.equal(rows.find((r) => r.id === '1').isPrivate, true);
188
+ assert.equal(rows.find((r) => r.id === '2').waits[0].title, 'A private goal');
189
+ });
190
+
191
+ await test('the split is STATED, with the measured numbers only', () => {
192
+ const a = L.analyse({ nodes: [1, 2, 3, 4, 5].map((i) => node(i)), edges: [edge(1, 2), edge(2, 3)] });
193
+ const d = L.describe(a);
194
+ assert.match(d.summary, /5 goals carry work/);
195
+ assert.match(d.summary, /3 of them are tied to another goal/);
196
+ assert.match(d.summary, /the other 2 stand alone/);
197
+ assert.match(d.summary, /form 1 cluster, the largest with 3 goals/);
198
+ });
199
+
200
+ await test('no edges at all is said plainly and the table is called the whole map (ADR 0227 §3: the right amount of map may be none)', () => {
201
+ const d = L.describe(L.analyse({ nodes: [node(1), node(2)], edges: [] }));
202
+ assert.match(d.graph, /no graph to draw — the table below is the whole map/);
203
+ assert.match(L.describe(L.analyse({ nodes: [], edges: [] })).summary, /No goal carries any work/);
204
+ });
205
+
206
+ // ---- purity -----------------------------------------------------------------------
207
+
208
+ await test('the lib is pure and cannot reach a task: no DOM, no fetch, no task input', () => {
209
+ const src = fs.readFileSync(LIB_PATH, 'utf8').replace(/^\s*\/\/.*$/gm, '');
210
+ assert.equal(/\bdocument\b|\bfetch\b|\bwindow\b|XMLHttpRequest/.test(src.replace(/root\.OTBGoalMapLib/, '')), false);
211
+ assert.equal(/task_id|\/tasks\b|\.tasks\b/.test(src), false, 'view A/D need task rows; this file has nowhere to put one (ADR 0227 §7)');
212
+ });
213
+
214
+ summary();
@@ -0,0 +1,236 @@
1
+ // tests/goal_map_page.mjs — the goal map page, EXECUTED (task 1003479, goal
2
+ // 1000089; ADR 0227 §5 and §7).
3
+ //
4
+ // The real hall-kit.js, the real goal-map-lib.js and the real goal-map.js run in
5
+ // one vm against a stub DOM and a stubbed GET /goals/graph. What the ADR made
6
+ // non-negotiable is asserted here as behaviour, not read off the source:
7
+ // - the TABLE is on the page by default (§5.1) — no toggle hides it;
8
+ // - category is TEXT on every node as well as a colour (§5.3);
9
+ // - the picture scrolls inside its own box and never widens the page (§5.2);
10
+ // - there is no control that could reach view A (every task) or view D
11
+ // (densified) (§7) — the only fetch is the aggregate;
12
+ // - the stand-alone goals are a list, not dots (§10.4 item 1), and the split is
13
+ // stated on the page.
14
+ // Plus the one thing the browser adds that a lib test cannot see: a goal title is
15
+ // builder-authored text and must never become markup.
16
+ //
17
+ // Run: node tests/goal_map_page.mjs
18
+ import assert from 'node:assert/strict';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import vm from 'node:vm';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { makeRunner, makeEl, walk } from './helpers.mjs';
24
+
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
27
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
28
+ const KIT_JS = read('hall-kit.js');
29
+ const LIB_JS = read('goal-map-lib.js');
30
+ const PAGE_JS = read('goal-map.js');
31
+ const PAGE_HTML = read('goal-map.html');
32
+ const PAGE_CSS = read('goal-map.css');
33
+ const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/goals__graph.json'), 'utf8'));
34
+
35
+ const { test, summary } = makeRunner();
36
+ const tick = () => new Promise((r) => setTimeout(r, 0));
37
+ const settle = async () => { for (let i = 0; i < 20; i++) await tick(); };
38
+
39
+ const node = (id, extra = {}) => ({ goal_id: String(id), title: `Goal ${id}`, status: 'open', version_id: 'V1', category: null, task_total: 5, task_done: 2, depends_on: 0, depended_on_by: 0, ...extra });
40
+
41
+ async function boot(payload, { status = 200, fail = false } = {}) {
42
+ const root = makeEl('div');
43
+ const reg = (id, hidden = false) => { const el = makeEl('div', { id }); if (hidden) el.hidden = true; root.appendChild(el); return el; };
44
+ ['gm-sub', 'gm-graph', 'gm-legend', 'gm-alone', 'gm-alone-lede', 'gm-table'].forEach((id) => reg(id));
45
+ ['gm-alone-scroll', 'gm-table-scroll'].forEach((id) => reg(id, true));
46
+
47
+ const documentObj = {
48
+ getElementById: (id) => walk(root, (el) => (el.id === id ? el : null)),
49
+ // SVG elements: the stub's own class/id only follow attrs at construction, so
50
+ // mirror what a browser does when setAttribute writes them.
51
+ createElementNS(_ns, tag) {
52
+ const el = makeEl(tag);
53
+ const orig = el.setAttribute.bind(el);
54
+ el.setAttribute = (k, v) => { orig(k, v); if (k === 'class') el.className = String(v); if (k === 'id') el.id = String(v); };
55
+ return el;
56
+ },
57
+ };
58
+ const requests = [];
59
+ const navigations = [];
60
+ const locationObj = { hostname: 'builders.example.test', assign: (u) => navigations.push(u) };
61
+ const windowObj = {
62
+ OTB: { escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])) },
63
+ localStorage: { getItem: () => null, setItem() {} },
64
+ location: locationObj,
65
+ addEventListener() {},
66
+ };
67
+ const sandbox = {
68
+ window: windowObj, self: windowObj, document: documentObj, location: locationObj, console: { error() {}, log() {}, warn() {} },
69
+ setTimeout, clearTimeout, Promise, Date, Math, JSON, Error, Number, String, Object, Array, Boolean, Set, Map, RegExp, encodeURIComponent,
70
+ fetch: async (url) => {
71
+ requests.push(url);
72
+ if (fail) throw new Error('offline');
73
+ return { ok: status === 200, status, json: async () => payload };
74
+ },
75
+ };
76
+ sandbox.globalThis = sandbox;
77
+ vm.createContext(sandbox);
78
+ vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
79
+ vm.runInContext(LIB_JS, sandbox, { filename: 'goal-map-lib.js' });
80
+ vm.runInContext(PAGE_JS, sandbox, { filename: 'goal-map.js' });
81
+ await settle();
82
+ const $ = (id) => documentObj.getElementById(id);
83
+ // The stub matches one compound selector at a time, so `tbody tr` is two steps.
84
+ const rows = () => { const tb = $('gm-table').querySelector('tbody'); return tb ? tb.querySelectorAll('tr') : []; };
85
+ return { $, requests, navigations, rows, svg: () => $('gm-graph').querySelector('svg') };
86
+ }
87
+
88
+ // ---- the table is the page ---------------------------------------------------------
89
+
90
+ await test('the TABLE is on the page by default — every goal is a row, and nothing hides it behind a toggle', async () => {
91
+ const h = await boot(FIXTURE);
92
+ assert.equal(h.$('gm-table-scroll').hidden, false);
93
+ const rows = h.rows();
94
+ assert.equal(rows.length, FIXTURE.nodes.length, 'stand-alone goals are rows too');
95
+ assert.ok(h.$('gm-table').querySelector('table').getAttribute('aria-label'), 'the table is named for a screen reader');
96
+ });
97
+
98
+ await test('the picture is the enhancement, and it is a role=img figure: no focus stops, no links inside the drawing', async () => {
99
+ const h = await boot(FIXTURE);
100
+ const svg = h.svg();
101
+ assert.ok(svg, 'the map is drawn');
102
+ assert.equal(svg.getAttribute('role'), 'img');
103
+ assert.equal(svg.getAttribute('aria-labelledby'), 'gm-cap');
104
+ assert.equal(svg.querySelectorAll('a').length, 0, 'thirty-six hidden interactive elements from a drawing is the wrong trade');
105
+ assert.equal(svg.querySelectorAll('[tabindex]').length, 0);
106
+ assert.match(h.$('gm-graph').querySelector('figcaption').textContent, /also in the table below/);
107
+ });
108
+
109
+ await test('every goal in the map is a table row too, and every drawn dependency is named in the table', async () => {
110
+ const h = await boot(FIXTURE);
111
+ const drawn = h.svg().querySelectorAll('.gm-node').length;
112
+ assert.equal(drawn, FIXTURE.counts.connected);
113
+ const tableText = h.$('gm-table').textContent;
114
+ for (const e of FIXTURE.edges) {
115
+ const dependent = FIXTURE.nodes.find((n) => n.goal_id === e.from);
116
+ const dependency = FIXTURE.nodes.find((n) => n.goal_id === e.to);
117
+ assert.ok(tableText.includes(dependency.title || 'A private goal'), `the table names ${dependency.title}`);
118
+ assert.ok(tableText.includes(dependent.title || 'A private goal'));
119
+ }
120
+ });
121
+
122
+ // ---- category is text, not only colour ------------------------------------------------
123
+
124
+ await test('CATEGORY IS A WORD on every drawn node, not only a colour — and "No category" is said too', async () => {
125
+ const h = await boot(FIXTURE);
126
+ const cats = h.svg().querySelectorAll('.gm-cat').map((t) => t.textContent);
127
+ assert.equal(cats.length, FIXTURE.counts.connected);
128
+ assert.ok(cats.every((c) => c.length > 0));
129
+ assert.ok(cats.includes('No category'), 'an uncategorised goal is legal and named as such (ADR 0192)');
130
+ assert.ok(cats.some((c) => /Modules|Security|Builders Hall|Economics/.test(c)));
131
+ assert.ok(h.$('gm-legend').textContent.includes('Modules'), 'and the legend spells each category out');
132
+ });
133
+
134
+ // ---- the split is stated, the alone goals are a list ----------------------------------
135
+
136
+ await test('the stand-alone goals are a LIST, never dots, and the split is stated in the page head', async () => {
137
+ const h = await boot(FIXTURE);
138
+ const drawnIds = new Set(h.svg().querySelectorAll('.gm-node').map((g) => g.attrs.transform));
139
+ assert.equal(drawnIds.size, FIXTURE.counts.connected);
140
+ assert.equal(h.$('gm-alone-scroll').hidden, false);
141
+ assert.equal(h.$('gm-alone').querySelectorAll('li').length, FIXTURE.counts.isolated);
142
+ assert.match(h.$('gm-sub').textContent, new RegExp(`the other ${FIXTURE.counts.isolated} stand alone`));
143
+ });
144
+
145
+ // ---- no view A, no view D -------------------------------------------------------------------
146
+
147
+ await test('NEITHER VIEW A NOR VIEW D is reachable: one aggregate fetch, and no control on the page that changes what is drawn', async () => {
148
+ const h = await boot(FIXTURE);
149
+ assert.deepEqual(h.requests, ['/api/bongos/goals/graph'], 'the only read is the aggregate — it carries no task identity');
150
+ const controls = /<(?:button|select|input|textarea)\b/i;
151
+ assert.equal(controls.test(PAGE_HTML), false, 'the page markup has no control at all');
152
+ assert.equal(h.$('gm-graph').querySelectorAll('button, select, input').length, 0);
153
+ assert.equal(/include=|\/tasks|task_id|densif/i.test(PAGE_JS.replace(/^\s*\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '')), false,
154
+ 'no path in the page script can ask for a task');
155
+ });
156
+
157
+ // ---- scrolling ----------------------------------------------------------------------------
158
+
159
+ await test('the picture scrolls INSIDE its own region and names both overflow axes (the page never scrolls sideways)', async () => {
160
+ const h = await boot(FIXTURE);
161
+ const region = h.$('gm-graph').querySelector('.gm-scroll');
162
+ assert.ok(region && region.contains(h.svg()));
163
+ assert.equal(region.getAttribute('tabindex'), '0', 'a keyboard reader can scroll it');
164
+ assert.equal(region.getAttribute('role'), 'region');
165
+ const rule = PAGE_CSS.match(/\.gm-scroll\s*\{[^}]*\}/)[0];
166
+ assert.match(rule, /overflow-x:\s*auto/);
167
+ assert.match(rule, /overflow-y:\s*hidden/, 'one axis named alone makes the other auto (task 1004130)');
168
+ assert.match(rule, /max-width:\s*100%/);
169
+ });
170
+
171
+ // ---- text is never markup -------------------------------------------------------------------
172
+
173
+ await test('a goal title is builder-authored text: it never becomes markup, in the picture or the table or the chips', async () => {
174
+ const evil = '<img src=x onerror=alert(1)>';
175
+ const h = await boot({
176
+ nodes: [node(1, { title: evil, depends_on: 1 }), node(2, { depended_on_by: 1 }), node(3, { title: evil })],
177
+ edges: [{ from: '1', to: '2', count: 1 }],
178
+ counts: {},
179
+ });
180
+ assert.equal(h.svg().querySelectorAll('img').length, 0);
181
+ assert.ok(h.svg().querySelectorAll('.gm-name').some((t) => t.textContent.includes('<img')), 'as literal text in the drawing');
182
+ assert.equal(h.$('gm-table').querySelectorAll('img').length, 0);
183
+ assert.equal(h.$('gm-alone').querySelectorAll('img').length, 0);
184
+ assert.ok(h.$('gm-table').textContent.includes(evil), 'shown as the characters the author typed');
185
+ });
186
+
187
+ // ---- honest states ----------------------------------------------------------------------------
188
+
189
+ await test('a private goal the viewer cannot read is named as one and is not a link', async () => {
190
+ const h = await boot({ nodes: [node(1, { title: null, depended_on_by: 1 }), node(2, { depends_on: 1 })], edges: [{ from: '2', to: '1', count: 2 }], counts: {} });
191
+ const row = h.rows().find((r) => r.textContent.includes('A private goal'));
192
+ assert.ok(row, 'the row exists');
193
+ assert.equal(row.querySelectorAll('a').length, 0, 'nothing to open');
194
+ });
195
+
196
+ await test('no dependency at all says so plainly, draws no picture, and the table is still the whole map', async () => {
197
+ const h = await boot({ nodes: [node(1), node(2)], edges: [], counts: {} });
198
+ assert.equal(h.svg(), null);
199
+ assert.match(h.$('gm-graph').textContent, /no graph to draw — the table below is the whole map/);
200
+ assert.equal(h.rows().length, 2);
201
+ assert.equal(h.$('gm-alone-scroll').hidden, true, 'nothing to split when nothing is connected');
202
+ });
203
+
204
+ await test('an empty corpus renders the empty state and no table', async () => {
205
+ const h = await boot({ nodes: [], edges: [], counts: {} });
206
+ assert.match(h.$('gm-graph').textContent, /No goal carries any work yet/);
207
+ assert.equal(h.$('gm-table-scroll').hidden, true);
208
+ });
209
+
210
+ await test('a failed read is one honest line — not a blank page, not a stack', async () => {
211
+ const h = await boot(null, { fail: true });
212
+ assert.match(h.$('gm-sub').textContent, /Couldn.t load the goal map/);
213
+ assert.equal(h.svg(), null);
214
+ assert.equal(h.$('gm-table-scroll').hidden, true);
215
+ });
216
+
217
+ await test('signed out (401) goes to sign-in like every other hall read', async () => {
218
+ const h = await boot(null, { status: 401 });
219
+ assert.deepEqual(h.navigations, ['/api/bongos/auth/web/start']);
220
+ });
221
+
222
+ // ---- the shell ---------------------------------------------------------------------------------
223
+
224
+ await test('the page has its h1 in static markup (so every state has one) and loads the lib before the page script', () => {
225
+ assert.match(PAGE_HTML, /<h1 class="page-head__h">Goal map<\/h1>/);
226
+ assert.ok(PAGE_HTML.indexOf('goal-map-lib.js') > -1 && PAGE_HTML.indexOf('goal-map-lib.js') < PAGE_HTML.indexOf('goal-map.js'));
227
+ assert.ok(PAGE_HTML.indexOf('hall-kit.js') < PAGE_HTML.indexOf('goal-map.js'));
228
+ });
229
+
230
+ await test('it takes NO nav slot (the sky’s rule) — the Goals page is its door', () => {
231
+ const shell = read('shell.js');
232
+ assert.equal(/goal-map/.test(shell), false, 'no nav item');
233
+ assert.match(read('goals.html'), /href="\/builders\/goal-map"/);
234
+ });
235
+
236
+ summary();
@@ -48,6 +48,9 @@ const ORACLE = {
48
48
  '/builders/atlas': 'requireBuilderPage',
49
49
  '/builders/modules': 'requireBuilderPage',
50
50
  '/builders/goals': 'requireBuilderPage',
51
+ // task 1003479: the goal map. GET /goals/graph is requireBuilder like the goal board it
52
+ // maps, so the page sits on the same tier — and takes no nav slot (the sky's rule).
53
+ '/builders/goal-map': 'requireBuilderPage',
51
54
  '/builders/roadmap': 'requireBuilderPage',
52
55
  // Gated at this tier all along, but with no row until task 1003448 walked the
53
56
  // module's pages: copy-desk (task 1003113) and ideas (task 1002928) are open to