@bongos/core 1.19.371 → 1.19.373
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 +34 -29
- package/.claude/skills/goal-create/SKILL.md +7 -4
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0161-publish-on-merge.md +1 -1
- package/docs/adr/0180-dependency-intake-and-drift-policy.md +6 -0
- package/docs/api/openapi.json +4 -2
- package/docs/copy-inventory.md +134 -132
- package/docs/copy-registry.json +180 -162
- package/modules/hall-ui/public/dom-utils.js +31 -0
- package/modules/hall-ui/public/goals-page.js +34 -0
- package/modules/hall-ui/public/goals-render.js +21 -7
- package/modules/hall-ui/public/style.css +9 -0
- package/modules/hall-ui/public/task.js +20 -3
- package/modules/hall-ui/public/work.js +16 -1
- package/modules/lifecycle/goal-category.js +28 -0
- package/modules/lifecycle/routes/goals.js +10 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bump-version.js +41 -1
- package/src/module-api.js +5 -1
- package/tests/bump_version.mjs +77 -1
- package/tests/goal_category.mjs +28 -0
- package/tests/goal_category_ui.mjs +279 -0
- package/tests/goal_criterion_confirm_ui.mjs +6 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// tests/goal_category_ui.mjs — the hall shows a goal's category and filters by
|
|
2
|
+
// it, and a task shows the one it inherits (task 1003276, goal 1000072,
|
|
3
|
+
// criterion legibility-categories).
|
|
4
|
+
//
|
|
5
|
+
// This EXECUTES the real goals page in a vm under a minimal DOM/kit stub (the
|
|
6
|
+
// tests/goal_criterion_confirm_ui.mjs precedent — there is no jsdom here), boots
|
|
7
|
+
// it against a fake API serving a categorised goal and an uncategorised one, and
|
|
8
|
+
// asserts on the HTML it actually writes and on what the Category control
|
|
9
|
+
// actually narrows. Source pins are used only for the two properties a harness
|
|
10
|
+
// cannot hold: that the chip is not a navigation destination, and that the other
|
|
11
|
+
// two surfaces render it through the SAME shared helper.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/goal_category_ui.mjs
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import vm from 'node:vm';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { makeRunner } from './helpers.mjs';
|
|
20
|
+
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
23
|
+
const SRC = read('modules/hall-ui/public/goals-page.js');
|
|
24
|
+
const TASK_LIB_SRC = read('modules/hall-ui/public/task-lib.js');
|
|
25
|
+
const GOALS_LIB_SRC = read('modules/hall-ui/public/goals-lib.js');
|
|
26
|
+
const GOALS_RENDER_SRC = read('modules/hall-ui/public/goals-render.js');
|
|
27
|
+
const DOM_UTILS_SRC = read('modules/hall-ui/public/dom-utils.js');
|
|
28
|
+
const WORK_SRC = read('modules/hall-ui/public/work.js');
|
|
29
|
+
const TASK_PAGE_SRC = read('modules/hall-ui/public/task.js');
|
|
30
|
+
const STYLE_CSS = read('modules/hall-ui/public/style.css');
|
|
31
|
+
|
|
32
|
+
const { test, summary } = makeRunner();
|
|
33
|
+
|
|
34
|
+
const SECURITY = { id: '3', slug: 'security', label: 'Security' };
|
|
35
|
+
const ECONOMICS = { id: '6', slug: 'economics', label: 'Economics' };
|
|
36
|
+
const CATEGORIES = [
|
|
37
|
+
{ id: 3, slug: 'security', label: 'Security', sort_order: 50, active: true },
|
|
38
|
+
{ id: 6, slug: 'economics', label: 'Economics', sort_order: 60, active: true },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const GOALS = [
|
|
42
|
+
{ id: 7, version_id: 'V1', title: 'The walls hold', status: 'open', visibility: 'public', created_by: 9, scope_modules: [], category_id: '3', category: SECURITY },
|
|
43
|
+
{ id: 8, version_id: 'V1', title: 'What a credit means', status: 'open', visibility: 'public', created_by: 9, scope_modules: [], category_id: '6', category: ECONOMICS },
|
|
44
|
+
{ id: 9, version_id: 'V1', title: 'Nobody has placed this yet', status: 'open', visibility: 'public', created_by: 9, scope_modules: [], category_id: null, category: null },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// Boot goals-page.js on the LIST view and hand back the rendered grid plus a
|
|
48
|
+
// handle on the filter bar's onChange, so a filter can actually be exercised
|
|
49
|
+
// rather than asserted about.
|
|
50
|
+
async function renderList() {
|
|
51
|
+
const calls = { requests: [], appended: [] };
|
|
52
|
+
const els = new Map();
|
|
53
|
+
// The page builds TWO filter bars (the goal list, and the goal-detail To-do
|
|
54
|
+
// list). Keep both and pick by the control we care about, or this harness
|
|
55
|
+
// silently tests the wrong one.
|
|
56
|
+
const bars = [];
|
|
57
|
+
|
|
58
|
+
const makeEl = (key) => ({
|
|
59
|
+
_key: key, dataset: {}, style: {}, title: '', textContent: '', hidden: false, disabled: false, value: '',
|
|
60
|
+
innerHTML: '',
|
|
61
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
62
|
+
setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
|
|
63
|
+
appendChild(child) { calls.appended.push({ into: key, value: child.value, text: child.textContent }); },
|
|
64
|
+
focus() {}, click() {}, querySelectorAll() { return []; }, addEventListener() {},
|
|
65
|
+
});
|
|
66
|
+
const $ = (sel) => {
|
|
67
|
+
if (!sel) return null;
|
|
68
|
+
if (!els.has(sel)) els.set(sel, makeEl(sel));
|
|
69
|
+
return els.get(sel);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const api = {
|
|
73
|
+
request(method, p) {
|
|
74
|
+
calls.requests.push([method, p]);
|
|
75
|
+
const reply = (data) => Promise.resolve({ status: 200, ok: true, data });
|
|
76
|
+
if (method === 'GET' && /\/me$/.test(p)) return reply({ builder: { id: 1, rank: 'archon' }, active_claims: [] });
|
|
77
|
+
if (method === 'GET' && /\/goals$/.test(p)) return reply({ goals: GOALS });
|
|
78
|
+
if (method === 'GET' && /\/work-categories$/.test(p)) return reply({ categories: CATEGORIES });
|
|
79
|
+
if (method === 'GET' && /\/goals\/\d+$/.test(p)) {
|
|
80
|
+
const id = Number(p.match(/(\d+)$/)[1]);
|
|
81
|
+
return reply({ goal: GOALS.find((g) => Number(g.id) === id), members: [], criteria: [], dependencies: [] });
|
|
82
|
+
}
|
|
83
|
+
if (method === 'GET' && /\/tasks\?/.test(p)) return reply({ tasks: [] });
|
|
84
|
+
return reply({});
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const windowObj = {
|
|
89
|
+
OTB: null, // filled by dom-utils.js, run for real below
|
|
90
|
+
OTBKit: {
|
|
91
|
+
makePager: (prefix) => ({ prefix, page: 1, perPage: 25 }),
|
|
92
|
+
paginate: (rows) => rows,
|
|
93
|
+
wirePager() {}, pagerBarHtml: () => '',
|
|
94
|
+
makeTabs: () => ({ setCount() {}, select() {}, activate() {} }),
|
|
95
|
+
makeFilterBar(spec) { bars.push(spec); },
|
|
96
|
+
emptyStateHtml: (m) => `<div class="kit-empty">${m}</div>`,
|
|
97
|
+
skeletonRows: () => '', ageChipHtml: () => '',
|
|
98
|
+
},
|
|
99
|
+
OTBBranding: { currencyLabel: () => 'credits', taskSettled: () => false, taskCreditValue: () => 0 },
|
|
100
|
+
BongosClient: { createClient: () => api },
|
|
101
|
+
location: { hash: '', pathname: '/builders/goals', assign() {} },
|
|
102
|
+
addEventListener() {}, scrollTo() {},
|
|
103
|
+
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
104
|
+
localStorage: { getItem() { return null; }, setItem() {} },
|
|
105
|
+
};
|
|
106
|
+
const documentObj = {
|
|
107
|
+
body: { dataset: {} },
|
|
108
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
|
|
109
|
+
getElementById: (id) => $(`#${id}`),
|
|
110
|
+
querySelector: (sel) => $(sel),
|
|
111
|
+
querySelectorAll: () => [],
|
|
112
|
+
createElement: () => makeEl('created'),
|
|
113
|
+
addEventListener() {},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object, JSON, Math, Date, encodeURIComponent, Promise, RegExp };
|
|
117
|
+
sandbox.globalThis = sandbox;
|
|
118
|
+
vm.createContext(sandbox);
|
|
119
|
+
vm.runInContext(DOM_UTILS_SRC, sandbox, { filename: 'dom-utils.js' });
|
|
120
|
+
// dom-utils builds its own $ over document; the harness's element map is what
|
|
121
|
+
// the page must actually read, so point OTB.$ at it (the confirm-UI precedent).
|
|
122
|
+
windowObj.OTB.$ = $;
|
|
123
|
+
windowObj.OTB.fmtDate = () => '';
|
|
124
|
+
windowObj.OTB.toast = () => {};
|
|
125
|
+
vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
|
|
126
|
+
vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
|
|
127
|
+
vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
|
|
128
|
+
vm.runInContext(SRC, sandbox, { filename: 'goals-page.js' });
|
|
129
|
+
for (let i = 0; i < 30; i++) await new Promise((r) => setTimeout(r, 0));
|
|
130
|
+
|
|
131
|
+
const controlIds = (spec) => [...(spec.primary || []), ...(spec.overflow || [])].map((c) => c.id);
|
|
132
|
+
const listBar = bars.find((b) => controlIds(b).includes('f-goal-version'));
|
|
133
|
+
assert.ok(listBar, `the goal-LIST filter bar must exist — saw ${JSON.stringify(bars.map(controlIds))}`);
|
|
134
|
+
|
|
135
|
+
const setFilter = async (id, value) => {
|
|
136
|
+
$(id).value = value;
|
|
137
|
+
assert.ok(listBar.onChange, 'the list filter bar must declare an onChange');
|
|
138
|
+
listBar.onChange();
|
|
139
|
+
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
140
|
+
return $('#goal-grid').innerHTML;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return { calls, bars, listBar, controlIds, grid: () => $('#goal-grid').innerHTML, setFilter, categoryChip: windowObj.OTB.categoryChip };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// =========================================================================
|
|
147
|
+
// The chip
|
|
148
|
+
// =========================================================================
|
|
149
|
+
|
|
150
|
+
await test('a categorised goal renders its category; an uncategorised one renders no chip', async () => {
|
|
151
|
+
const { grid } = await renderList();
|
|
152
|
+
const html = grid();
|
|
153
|
+
assert.match(html, /gr-cat[^>]*>Security/, 'the Security goal wears its label');
|
|
154
|
+
assert.match(html, /gr-cat[^>]*>Economics/, 'and so does the Economics one');
|
|
155
|
+
const chips = html.match(/class="gr-cat/g) || [];
|
|
156
|
+
assert.equal(chips.length, 2, `two of three goals are categorised — got ${chips.length} chips`);
|
|
157
|
+
assert.equal(/Uncategorised|Not categorised/i.test(html), false,
|
|
158
|
+
'an uncategorised goal shows NOTHING, not a grey placeholder pill — most goals will carry one, and a board of placeholders reads worse than a board with gaps');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await test('the same chip renders in the card view, from the same facts', async () => {
|
|
162
|
+
const { setFilter } = await renderList();
|
|
163
|
+
const html = await setFilter('#f-goal-view', 'cards');
|
|
164
|
+
assert.match(html, /goal-card/, 'the card view is what rendered');
|
|
165
|
+
assert.match(html, /gr-cat[^>]*>Security/, 'and it carries the category too');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// =========================================================================
|
|
169
|
+
// The filter — the half the criterion actually asks for
|
|
170
|
+
// =========================================================================
|
|
171
|
+
|
|
172
|
+
await test('the Category control is declared on the kit filter bar (no bespoke markup)', async () => {
|
|
173
|
+
const { listBar, controlIds } = await renderList();
|
|
174
|
+
const ids = controlIds(listBar);
|
|
175
|
+
assert.ok(ids.includes('f-goal-category'), `Category must be a kit control — got ${JSON.stringify(ids)}`);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await test('the options come from the project SET, not from the goals on screen', async () => {
|
|
179
|
+
// "Present in every builders hall on any project" only holds if the vocabulary
|
|
180
|
+
// travels over the wire. A category nobody has used yet must still be offered.
|
|
181
|
+
const { calls, appended } = await renderList().then((r) => ({ calls: r.calls, appended: r.calls.appended }));
|
|
182
|
+
assert.ok(calls.requests.some(([m, p]) => m === 'GET' && /\/work-categories$/.test(p)),
|
|
183
|
+
'the page must READ the set rather than derive it from state.goals');
|
|
184
|
+
const into = appended.filter((a) => a.into === '#f-goal-category');
|
|
185
|
+
assert.deepEqual(into.map((a) => a.value), ['security', 'economics', 'none'],
|
|
186
|
+
'every category in the set, plus the uncategorised bucket');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await test('choosing a category narrows the board to it', async () => {
|
|
190
|
+
const { setFilter } = await renderList();
|
|
191
|
+
const html = await setFilter('#f-goal-category', 'security');
|
|
192
|
+
assert.match(html, /The walls hold/);
|
|
193
|
+
assert.equal(/What a credit means/.test(html), false, 'the Economics goal is filtered out');
|
|
194
|
+
assert.equal(/Nobody has placed this yet/.test(html), false, 'so is the uncategorised one');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await test('"not categorised yet" finds exactly the goals nobody has placed', async () => {
|
|
198
|
+
// This option is the point of the whole feature: it is the queue of goals the
|
|
199
|
+
// human still has to name a category for.
|
|
200
|
+
const { setFilter } = await renderList();
|
|
201
|
+
const html = await setFilter('#f-goal-category', 'none');
|
|
202
|
+
assert.match(html, /Nobody has placed this yet/);
|
|
203
|
+
assert.equal(/The walls hold/.test(html), false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await test('the filter matches on the SLUG, so re-wording a label cannot break it', async () => {
|
|
207
|
+
await renderList();
|
|
208
|
+
const src = GOALS_RENDER_SRC.slice(GOALS_RENDER_SRC.indexOf('function filteredGoals'));
|
|
209
|
+
assert.match(src.slice(0, 1600), /category\?\.slug === cat/,
|
|
210
|
+
'match on slug (the stable key), never on the human label');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// =========================================================================
|
|
214
|
+
// The rule: a chip is a label, not a destination
|
|
215
|
+
// =========================================================================
|
|
216
|
+
|
|
217
|
+
await test('the chip is NOT a link — a per-category page would be a navigation tier', async () => {
|
|
218
|
+
const { grid } = await renderList();
|
|
219
|
+
const html = grid();
|
|
220
|
+
const chip = html.slice(html.indexOf('class="gr-cat'), html.indexOf('class="gr-cat') + 200);
|
|
221
|
+
assert.equal(/<a\b/.test(chip), false, 'no anchor');
|
|
222
|
+
assert.equal(/href=/.test(chip), false, 'no href');
|
|
223
|
+
// And nothing in the CSS gives it a click affordance.
|
|
224
|
+
const rule = STYLE_CSS.slice(STYLE_CSS.indexOf('.gr-cat {'), STYLE_CSS.indexOf('.gr-cat {') + 400);
|
|
225
|
+
assert.equal(/cursor:\s*pointer/.test(rule), false,
|
|
226
|
+
'no pointer cursor — the chip must not even LOOK like a destination (ADR 0192)');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// =========================================================================
|
|
230
|
+
// Inheritance: the task surfaces
|
|
231
|
+
// =========================================================================
|
|
232
|
+
|
|
233
|
+
await test('categoryChip marks an inherited chip and says where it came from', async () => {
|
|
234
|
+
const { categoryChip } = await renderList();
|
|
235
|
+
const own = categoryChip(SECURITY);
|
|
236
|
+
const inherited = categoryChip(SECURITY, { inherited: true });
|
|
237
|
+
assert.equal(/gr-cat--inherited/.test(own), false);
|
|
238
|
+
assert.match(inherited, /gr-cat--inherited/);
|
|
239
|
+
assert.match(inherited, /via goal/, 'a reader must not go hunting for a per-task editor');
|
|
240
|
+
assert.match(inherited, /inherited from this task's goal/, 'and the tooltip says so in full (escaped — it lands in an attribute)');
|
|
241
|
+
assert.equal(categoryChip(null), '', 'no category, no chip');
|
|
242
|
+
assert.equal(categoryChip({ label: ' ' }), '', 'a blank label is no label');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await test('categoryChip escapes what it renders', async () => {
|
|
246
|
+
const { categoryChip } = await renderList();
|
|
247
|
+
const html = categoryChip({ id: '1', slug: 'x', label: '<img src=x onerror=alert(1)>' });
|
|
248
|
+
assert.equal(/<img/.test(html), false);
|
|
249
|
+
assert.match(html, /<img/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await test('the Tasks board resolves a task\'s category THROUGH its goal', async () => {
|
|
253
|
+
// Inheritance is literal in the schema (no tasks.category column), so the board
|
|
254
|
+
// resolves it the same way — through goal_id, from the goals it already loads.
|
|
255
|
+
assert.match(WORK_SRC, /taskState\.goalCategories\[g\.id\] = g\.category \|\| null;/,
|
|
256
|
+
'the category rides along with the goal title the board already fetches');
|
|
257
|
+
assert.match(WORK_SRC, /categoryChip\(\s*\(taskState\.goalCategories \|\| \{\}\)\[String\(t\.goal_id\)\], \{ inherited: true \}\)/,
|
|
258
|
+
'and renders through the shared helper, marked inherited');
|
|
259
|
+
assert.equal(/taskState\.goalCategories.*filter|f-category/.test(WORK_SRC), false,
|
|
260
|
+
'the board must NOT filter or order on a category — nothing a person may not act on should decide what the queue offers');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await test('the task page names its goal and shows the inherited category', async () => {
|
|
264
|
+
assert.match(TASK_PAGE_SRC, /categoryChip\(goal && goal\.category, \{ inherited: true \}\)/);
|
|
265
|
+
assert.match(TASK_PAGE_SRC, /const goalName = goal && goal\.title/,
|
|
266
|
+
'the goal link used to read "Goal #1000072", which tells a person nothing');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await test('one renderer serves all three surfaces, so they cannot drift', () => {
|
|
270
|
+
// The chip lives in dom-utils.js because that is the only script the Goals
|
|
271
|
+
// page, the Tasks board and the task page all load.
|
|
272
|
+
assert.match(DOM_UTILS_SRC, /function categoryChip\(/);
|
|
273
|
+
for (const [name, src] of [['goals-render', GOALS_RENDER_SRC], ['work', WORK_SRC], ['task page', TASK_PAGE_SRC]]) {
|
|
274
|
+
assert.match(src, /categoryChip\(/, `${name} must render through the shared helper`);
|
|
275
|
+
assert.equal(/function categoryChip\(/.test(src), false, `${name} must not hold its own copy`);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
summary();
|
|
@@ -108,6 +108,12 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
|
|
|
108
108
|
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])),
|
|
109
109
|
$, fmtNum: (n) => String(n), fmtDate: () => '', rankLabel: (r) => r,
|
|
110
110
|
toast: (m) => calls.toasts.push(m),
|
|
111
|
+
// task 1003276 — the goal detail head renders the category chip through
|
|
112
|
+
// this shared helper. Stubbed to '' because the chip is not this test's
|
|
113
|
+
// subject (tests/goal_category_ui.mjs runs the real one); it must be
|
|
114
|
+
// PRESENT, though — an undefined helper throws mid-render and takes the
|
|
115
|
+
// whole detail view down, which is how this stub first failed.
|
|
116
|
+
categoryChip: () => '',
|
|
111
117
|
},
|
|
112
118
|
OTBKit: {
|
|
113
119
|
makePager: (prefix) => pager(prefix),
|