@bongos/core 1.19.1014 → 1.19.1016

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.
Files changed (104) hide show
  1. package/.bongos-core.json +145 -140
  2. package/.claude/skills/planning-session/SKILL.md +1 -1
  3. package/clients/bongos-client/index.d.ts +1 -1
  4. package/docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md +4 -1
  5. package/docs/adr/0331-the-sky-is-the-ideators-home.md +18 -1
  6. package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +113 -0
  7. package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +201 -0
  8. package/docs/adr/README.md +2 -0
  9. package/docs/api/openapi.json +4 -0
  10. package/docs/api-reference.md +1 -1
  11. package/docs/architecture.md +4 -1
  12. package/docs/copy-inventory.md +139 -110
  13. package/docs/copy-registry.json +872 -296
  14. package/docs/file-map.md +0 -1
  15. package/docs/module-api-changelog.md +4 -0
  16. package/migrations/core_253_idea_filed_via.sql +59 -0
  17. package/modules/agents/pollers/event-router.js +2 -0
  18. package/modules/discord/discord-bugs.js +2 -0
  19. package/modules/discord/discord-inbound.js +3 -0
  20. package/modules/hall-ui/public/atlas.html +1 -1
  21. package/modules/hall-ui/public/blockers.html +1 -1
  22. package/modules/hall-ui/public/board-room.html +1 -1
  23. package/modules/hall-ui/public/collab.html +1 -1
  24. package/modules/hall-ui/public/copy-desk.html +1 -1
  25. package/modules/hall-ui/public/deploy.html +1 -1
  26. package/modules/hall-ui/public/diagrams.html +1 -1
  27. package/modules/hall-ui/public/drachmae.html +1 -1
  28. package/modules/hall-ui/public/fleet.html +1 -1
  29. package/modules/hall-ui/public/gate.html +1 -1
  30. package/modules/hall-ui/public/goal-map.html +125 -52
  31. package/modules/hall-ui/public/goal-map.states.json +29 -5
  32. package/modules/hall-ui/public/goals.html +1 -1
  33. package/modules/hall-ui/public/government.html +1 -1
  34. package/modules/hall-ui/public/idea.html +1 -1
  35. package/modules/hall-ui/public/ideas.html +1 -1
  36. package/modules/hall-ui/public/index.html +1 -1
  37. package/modules/hall-ui/public/modules.html +1 -1
  38. package/modules/hall-ui/public/people.html +1 -1
  39. package/modules/hall-ui/public/primer.html +1 -1
  40. package/modules/hall-ui/public/profile.html +1 -1
  41. package/modules/hall-ui/public/project-settings.html +1 -1
  42. package/modules/hall-ui/public/ranks.html +1 -1
  43. package/modules/hall-ui/public/roadmap.html +1 -1
  44. package/modules/hall-ui/public/roster.html +1 -1
  45. package/modules/hall-ui/public/sessions.html +1 -1
  46. package/modules/hall-ui/public/settings.html +1 -1
  47. package/modules/hall-ui/public/shell.js +11 -0
  48. package/modules/hall-ui/public/sky-data.js +41 -9
  49. package/modules/hall-ui/public/sky-draw.js +3 -2
  50. package/modules/hall-ui/public/sky-interaction.js +14 -7
  51. package/modules/hall-ui/public/sky-layout.js +2 -1
  52. package/modules/hall-ui/public/sky-panel.js +1 -1
  53. package/modules/hall-ui/public/sky-tour.js +10 -6
  54. package/modules/hall-ui/public/studio.html +1 -1
  55. package/modules/hall-ui/public/task.html +1 -1
  56. package/modules/hall-ui/public/thinking.css +6 -3
  57. package/modules/hall-ui/public/thinking.html +16 -10
  58. package/modules/hall-ui/public/watch.html +1 -1
  59. package/modules/hall-ui/public/work.html +1 -1
  60. package/modules/ideas/born-of.js +55 -0
  61. package/modules/ideas/inbox.js +23 -8
  62. package/modules/ideas/projection.js +48 -0
  63. package/modules/ideas/routes/inbox.js +20 -0
  64. package/modules/lifecycle/sky.js +25 -4
  65. package/modules/platform-identity/activity-snapshots.js +47 -0
  66. package/modules/platform-identity/migrations/platform_identity_022_activity_snapshots.sql +46 -0
  67. package/modules/platform-identity/platform-identity.js +13 -0
  68. package/modules/platform-identity/tests/activity-snapshots.mjs +68 -0
  69. package/modules/platform-identity/tests/platform-identity.mjs +21 -0
  70. package/modules/ui-design/skills/impeccable/SKILL.md +1 -1
  71. package/package-lock.json +2 -2
  72. package/package.json +1 -1
  73. package/scripts/gds/capture.js +11 -0
  74. package/scripts/gds/run-unit-tests.js +4 -0
  75. package/src/bongos/serve-internal.js +4 -0
  76. package/src/module-api.js +1 -1
  77. package/tests/activity_snapshots_db.mjs +97 -0
  78. package/tests/discord_bugs.mjs +1 -0
  79. package/tests/discord_inbound.mjs +1 -0
  80. package/tests/goal_map_page.mjs +360 -211
  81. package/tests/guild_namespace_reserved.mjs +76 -0
  82. package/tests/hall_audit.mjs +1 -0
  83. package/tests/hall_nav.mjs +6 -1
  84. package/tests/hall_page_gate_map.mjs +3 -2
  85. package/tests/hall_sky.mjs +33 -16
  86. package/tests/helpers.mjs +21 -5
  87. package/tests/idea_filed_via.mjs +185 -0
  88. package/tests/landing_page.mjs +8 -0
  89. package/tests/nav_permission_atoms.mjs +1 -1
  90. package/tests/profile_rollup_consent.mjs +5 -1
  91. package/tests/project_admin_console_model.mjs +105 -0
  92. package/tests/render_check.mjs +6 -4
  93. package/tests/sky_read.mjs +19 -2
  94. package/tests/ui_design_skills.mjs +9 -0
  95. package/modules/hall-ui/public/goal-map-lib.js +0 -349
  96. package/modules/hall-ui/public/goal-map.css +0 -117
  97. package/modules/hall-ui/public/goal-map.js +0 -232
  98. package/modules/public-landing/public/world/assets/clouds/cloud-island-large.png +0 -0
  99. package/modules/public-landing/public/world/assets/clouds/cloud-puff.png +0 -0
  100. package/modules/public-landing/public/world/assets/creatures/creature-whale-cargo.png +0 -0
  101. package/modules/public-landing/public/world/assets/creatures/creature-whale-glide.png +0 -0
  102. package/modules/public-landing/public/world/assets/plates/plate-clouds-near.png +0 -0
  103. package/modules/public-landing/public/world/assets/plates/scene-establishing.png +0 -0
  104. package/tests/goal_map_lib.mjs +0 -214
@@ -1,232 +0,0 @@
1
- // goal-map.js — the goal map page (task 1003479, goal 1000089; ADR 0227 §6 item 3
2
- // and its §10.4 amendment). One read, GET /goals/graph, three surfaces from it:
3
- // 1. the map — the goals tied to another goal by a dependency, drawn in layers;
4
- // 2. the stand-alone list — the goals that have no edge, which §10.4 says belong
5
- // in a list and not as dots on a canvas that has nothing to say about them;
6
- // 3. the table — EVERY goal, with both directions of each dependency by name.
7
- //
8
- // THE TABLE IS THE PAGE AND THE MAP IS THE ENHANCEMENT (ADR 0227 §5). Every fact
9
- // the picture carries is a cell in the table, on screen by default and not behind
10
- // a "view as table" toggle. The picture is therefore a `role="img"` figure with a
11
- // caption that says where the same facts are, and its nodes are NOT links or
12
- // focus stops — a keyboard or screen-reader reader gets each goal, once, from the
13
- // table, rather than thirty-six hidden interactive elements from a drawing.
14
- //
15
- // THERE IS NO CONTROL that changes what is drawn (ADR 0227 §7): no "show every
16
- // task", no "densify". The only input is the aggregate the endpoint returns,
17
- // which carries no task identity, so views A and D are unreachable by
18
- // construction and not by a flag that happens to be off.
19
- //
20
- // The layout is goal-map-lib.js (pure, run by tests/goal_map_lib.mjs). This file
21
- // draws what it returns, with createElementNS and textContent — the atlas's
22
- // idiom — so a goal title (builder-authored text) never becomes markup.
23
- (() => {
24
- 'use strict';
25
-
26
- const API = '/api/bongos';
27
- const SVGNS = 'http://www.w3.org/2000/svg';
28
- const kit = window.OTBKit;
29
- const lib = window.OTBGoalMapLib;
30
- const textFit = window.OTBTextFit;
31
- const { escapeHtml } = window.OTB;
32
- const $ = (id) => document.getElementById(id);
33
-
34
- const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
35
- const goalHref = (id) => `${hallBase()}/goals#/goal/${encodeURIComponent(id)}`;
36
-
37
- function svgEl(tag, attrs, parent) {
38
- const el = document.createElementNS(SVGNS, tag);
39
- Object.keys(attrs || {}).forEach((k) => el.setAttribute(k, attrs[k]));
40
- if (parent) parent.appendChild(el);
41
- return el;
42
- }
43
- function text(parent, cls, x, y, content) {
44
- const t = svgEl('text', { class: cls, x, y }, parent);
45
- t.textContent = content;
46
- return t;
47
- }
48
-
49
- // ---- the map -----------------------------------------------------------------
50
-
51
- // Node text is fitted to MEASURED width, not a character count (task 1004204;
52
- // the forest's fix, task 1004200). Measuring needs the svg in the document, so
53
- // drawGraph collects each line into `lines` and renderGraph fits them after
54
- // appending. The clipPath is the backstop for a map drawn while hidden.
55
- const TEXT_X = 16;
56
- function fitNodeText(lines, maxWidth) {
57
- lines.forEach(({ el, full }) => {
58
- el.textContent = textFit.fitText(full, maxWidth, (s) => { el.textContent = s; return el.getComputedTextLength(); });
59
- });
60
- }
61
-
62
- function drawGraph(a, lay, cats, lines) {
63
- const svg = svgEl('svg', {
64
- class: 'gm-svg', viewBox: `0 0 ${lay.width} ${lay.height}`, width: lay.width, height: lay.height,
65
- role: 'img', 'aria-labelledby': 'gm-cap', focusable: 'false',
66
- });
67
- const defs = svgEl('defs', {}, svg);
68
- const marker = svgEl('marker', { id: 'gm-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto' }, defs);
69
- svgEl('path', { class: 'gm-arrowhead', d: 'M0 0 L10 5 L0 10 z' }, marker);
70
- const clip = svgEl('clipPath', { id: 'gm-text-clip' }, defs);
71
- svgEl('rect', { width: lay.nodeW, height: lay.nodeH }, clip);
72
-
73
- const edges = svgEl('g', { class: 'gm-edges' }, svg);
74
- lay.edges.forEach((e) => {
75
- const path = svgEl('path', { class: 'gm-edge', d: e.d, 'stroke-width': e.width, 'marker-end': 'url(#gm-arrow)' }, edges);
76
- const dependent = lib.displayTitle(a.byId[e.from]);
77
- const dependency = lib.displayTitle(a.byId[e.to]);
78
- svgEl('title', {}, path).textContent = `${dependent} waits on ${dependency} — ${lib.plural(e.count, 'task')}`;
79
- });
80
-
81
- // The node's WEIGHT (ADR 0227 §6 item 3: "node weight = task count"), as a
82
- // fraction of the busiest goal ACTUALLY DRAWN — a scale that means something
83
- // on this map, not on the whole corpus most of which never appears here.
84
- const maxTotal = lay.nodes.reduce((m, p) => Math.max(m, a.byId[p.id].task_total || 0), 0);
85
-
86
- const nodes = svgEl('g', { class: 'gm-nodes' }, svg);
87
- lay.nodes.forEach((p) => {
88
- const n = a.byId[p.id];
89
- const g = svgEl('g', { class: `gm-node gm-c${lib.colourOf(n, cats)}`, transform: `translate(${p.x} ${p.y})` }, nodes);
90
- const cat = n.category ? n.category.label : 'No category';
91
- svgEl('title', {}, g).textContent = `${lib.displayTitle(n)} — ${cat} — ${n.task_done} of ${n.task_total} tasks done`
92
- + ` — waits on ${lib.plural(n.depends_on || 0, 'goal')}, needed by ${lib.plural(n.depended_on_by || 0, 'goal')}`;
93
- svgEl('rect', { class: 'gm-card', width: p.w, height: p.h, rx: 8 }, g);
94
- svgEl('rect', { class: 'gm-stripe', width: 6, height: p.h, rx: 3 }, g);
95
- [['gm-name', 22, lib.displayTitle(n)], ['gm-cat', 38, cat], ['gm-tasks', 53, `${n.task_done} of ${lib.plural(n.task_total, 'task')} done`]]
96
- .forEach(([cls, y, full]) => {
97
- const el = text(g, cls, TEXT_X, y, full);
98
- el.setAttribute('clip-path', 'url(#gm-text-clip)');
99
- lines.push({ el, full });
100
- });
101
- const trackW = p.w - 32;
102
- svgEl('rect', { class: 'gm-weight-track', x: 16, y: 60, width: trackW, height: 3, rx: 1.5 }, g);
103
- const frac = lib.weightOf(n.task_total, maxTotal);
104
- if (frac > 0) svgEl('rect', { class: 'gm-weight-fill', x: 16, y: 60, width: Math.max(3, trackW * frac), height: 3, rx: 1.5 }, g);
105
- });
106
- return svg;
107
- }
108
-
109
- function renderGraph(a, cats, described) {
110
- const host = $('gm-graph');
111
- if (!a.connected) {
112
- host.innerHTML = `<p class="scroll__lede">${escapeHtml(described.graph || described.summary)}</p>`;
113
- return;
114
- }
115
- host.innerHTML = `
116
- <figure class="gm-figure">
117
- <figcaption id="gm-cap" class="scroll__lede">
118
- An arrow runs from the goal that has to ship first to the goal that waits on it, and a thicker line means more
119
- tasks are waiting. Every goal in this map, and every dependency, is also in the table below.
120
- </figcaption>
121
- <div class="gm-scroll" id="gm-scroll" tabindex="0" role="region" aria-label="Goal map — scrolls sideways inside this box"></div>
122
- </figure>`;
123
- const lay = lib.layout(a);
124
- const lines = [];
125
- $('gm-scroll').appendChild(drawGraph(a, lay, cats, lines));
126
- const fit = () => fitNodeText(lines, lay.nodeW - 2 * TEXT_X);
127
- fit();
128
- // A web font that lands after the first fit changes every width.
129
- if (document.fonts && document.fonts.status !== 'loaded') document.fonts.ready.then(fit);
130
- }
131
-
132
- function renderLegend(cats) {
133
- const host = $('gm-legend');
134
- if (!cats.list.length && !cats.uncategorised) { host.hidden = true; return; }
135
- const swatch = (cls) => `<span class="gm-swatch ${cls}" aria-hidden="true"></span>`;
136
- host.innerHTML = cats.list.map((c) => `<li>${swatch(`gm-c${c.colour}`)}${escapeHtml(c.label)} <span class="gm-n">${c.count}</span></li>`).join('')
137
- + (cats.uncategorised ? `<li>${swatch('gm-cnone')}No category <span class="gm-n">${cats.uncategorised}</span></li>` : '');
138
- host.hidden = false;
139
- }
140
-
141
- // ---- the stand-alone goals ------------------------------------------------------
142
-
143
- function renderAlone(a) {
144
- const section = $('gm-alone-scroll');
145
- if (!a.isolated.length || !a.connected) { section.hidden = true; return; }
146
- $('gm-alone-lede').textContent = `${lib.plural(a.isolated.length, 'goal')} ${a.isolated.length === 1 ? 'has' : 'have'} no task that waits on another goal’s work, `
147
- + 'and none that another goal waits on, so there is nothing to draw between them and another goal. They are listed here instead of drawn as dots.';
148
- $('gm-alone').innerHTML = a.isolated.map((id) => {
149
- const n = a.byId[id];
150
- const label = lib.displayTitle(n);
151
- const facts = (n.category ? `${escapeHtml(n.category.label)} · ` : '') + lib.plural(n.task_total, 'task');
152
- const inner = `<span>${escapeHtml(label)}</span><span class="gm-n">${facts}</span>`;
153
- return n.title
154
- ? `<li><a class="fact-chip gm-chip" href="${goalHref(id)}">${inner}</a></li>`
155
- : `<li><span class="fact-chip gm-chip">${inner}</span></li>`;
156
- }).join('');
157
- section.hidden = false;
158
- }
159
-
160
- // ---- the table (primary) ----------------------------------------------------------
161
-
162
- const depList = (list) => (list.length
163
- ? `<ul class="gm-deps">${list.map((x) => `<li>${escapeHtml(x.title)} <span class="gm-n">· ${lib.plural(x.count, 'task')}</span></li>`).join('')}</ul>`
164
- : '<span class="gm-none">—</span>');
165
-
166
- function rowHtml(r) {
167
- const title = r.isPrivate
168
- ? `<span class="gm-private">${escapeHtml(r.title)}</span>`
169
- : `<a class="lb-name" href="${goalHref(r.id)}">${escapeHtml(r.title)}</a>`;
170
- return `<tr>
171
- <td>${title}</td>
172
- <td>${r.category ? escapeHtml(r.category) : '<span class="gm-none">—</span>'}</td>
173
- <td class="ledger__num">${r.done} of ${r.total}</td>
174
- <td>${depList(r.waits)}</td>
175
- <td>${depList(r.needed)}</td>
176
- <td>${escapeHtml(r.clusterLabel)}</td>
177
- <td>${escapeHtml(r.version)}</td>
178
- </tr>`;
179
- }
180
-
181
- function renderTable(a) {
182
- const section = $('gm-table-scroll');
183
- const rows = lib.tableRows(a);
184
- section.hidden = false;
185
- const pager = kit.makePager('gm', 'hall-goal-map-per-page', { defaultPerPage: 100 });
186
- const ledger = kit.makeLedger({
187
- mount: $('gm-table'),
188
- columns: ['Goal', 'Category', { label: 'Tasks done', class: 'ledger__num' }, 'Waits on', 'Needed by', 'Cluster', 'Version'],
189
- rowHtml,
190
- emptyMsg: 'No goal carries any work yet.',
191
- pager,
192
- });
193
- ledger.render(rows);
194
- const table = $('gm-table').querySelector('table');
195
- if (table) table.setAttribute('aria-label', 'Every goal, with what it waits on and what waits on it');
196
- }
197
-
198
- // ---- boot ---------------------------------------------------------------------------
199
-
200
- function notice(msg) {
201
- $('gm-sub').textContent = msg;
202
- $('gm-graph').innerHTML = `<p class="note">${escapeHtml(msg)}</p>`;
203
- }
204
-
205
- async function boot() {
206
- let payload;
207
- try {
208
- const res = await fetch(`${API}/goals/graph`, { credentials: 'same-origin', headers: { Accept: 'application/json' } });
209
- if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
210
- if (!res.ok) throw new Error(`graph → ${res.status}`);
211
- payload = await res.json();
212
- } catch (err) {
213
- console.error('[goal-map] load failed', err);
214
- notice('Couldn’t load the goal map right now.');
215
- return;
216
- }
217
- const a = lib.analyse(payload);
218
- const cats = lib.categories(a);
219
- const described = lib.describe(a);
220
- $('gm-sub').textContent = described.summary;
221
- if (!a.order.length) {
222
- $('gm-graph').innerHTML = kit.emptyStateHtml(described.summary);
223
- return;
224
- }
225
- renderGraph(a, cats, described);
226
- renderLegend(cats);
227
- renderAlone(a);
228
- renderTable(a);
229
- }
230
-
231
- boot();
232
- })();
@@ -1,214 +0,0 @@
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();