@bongos/core 1.19.321 → 1.19.323

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,237 @@
1
+ // tests/goal_subtitle.mjs — a goal's explanation stops living inside its name
2
+ // (task 1003224, goal 1000072, criterion legibility-goal-names-and-claude-text).
3
+ //
4
+ // Owner directive, three parts: the TITLE is a name; the SUBTITLE is a separate,
5
+ // subordinate, on-demand field; and the bulk prose Claude writes into the
6
+ // description is presented as Claude's text, absent from the default reading
7
+ // path until someone asks for it.
8
+ //
9
+ // These tests pin the parts a future edit could quietly undo: that subtitle is
10
+ // carried by the same prose-edit path as title (so it inherits the audit trail
11
+ // rather than getting a private one), that the disclosure is CLOSED by default,
12
+ // and that task creation did NOT gain a subtitle — the identical validator pair
13
+ // appears in POST /goals and POST /goals/:id/tasks, and only the first should
14
+ // have changed.
15
+ import { strict as assert } from 'node:assert';
16
+ import { test } from 'node:test';
17
+ import { createRequire } from 'node:module';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const goalEdits = require(path.join(ROOT, 'modules', 'lifecycle', 'goal-edits.js'));
25
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
26
+
27
+ const fakeReqRes = ({ body }) => {
28
+ const res = { code: null, statusCode: null, payload: null,
29
+ fail(code, status, extra) { this.code = code; this.statusCode = typeof status === 'number' ? status : status?.status ?? 400; this.payload = { error: code, ...(extra || {}) }; return this; },
30
+ status(c) { this.statusCode = c; return this; },
31
+ json(p) { this.payload = p; return this; } };
32
+ return { req: { params: { id: '7' }, body, builder: { id: 1, rank: 'archon' } }, res };
33
+ };
34
+ const deps = {
35
+ db: { getGoal: async () => ({ id: 7, status: 'open', created_by: 1 }), getGoalMember: async () => null },
36
+ authorizeGoalManagement: () => ({ ok: true }),
37
+ validateOrRespond: () => false,
38
+ parseId: () => 7,
39
+ LIMITS: { TITLE: 200, DESCRIPTION: 50000 },
40
+ };
41
+
42
+ // ---- the field travels the existing prose path -----------------------------
43
+ test('a subtitle-only PATCH is accepted and recorded', () => {
44
+ // Not a private write path: subtitle rides title's handler so the prose-edit
45
+ // audit trail (deltas + record) covers it identically.
46
+ let patched = null; const recorded = [];
47
+ const handler = goalEdits.goalProseHandler({
48
+ ...deps,
49
+ update: async ({ patch }) => { patched = patch; return { goal: { id: 7, ...patch }, deltas: [goalEdits.proseDelta('subtitle', null, patch.subtitle)] }; },
50
+ record: async (...a) => { recorded.push(a); },
51
+ });
52
+ const { req, res } = fakeReqRes({ body: { subtitle: 'the grep path' } });
53
+ return handler(req, res).then(() => {
54
+ assert.equal(res.code, null, `must not fail — got ${res.code}`);
55
+ assert.deepEqual(patched, { subtitle: 'the grep path' }, 'only the named field is patched');
56
+ assert.equal(res.payload.deltas[0].field, 'subtitle');
57
+ assert.equal(recorded.length, 1, 'the edit is audit-recorded like any other prose edit');
58
+ });
59
+ });
60
+
61
+ test('an EMPTY subtitle is a legal value, not a no-op', () => {
62
+ // '' means deliberately blank; NULL means never set. The column has no
63
+ // default precisely so the UI can tell them apart, so '' must reach the patch.
64
+ let patched = null;
65
+ const handler = goalEdits.goalProseHandler({
66
+ ...deps,
67
+ update: async ({ patch }) => { patched = patch; return { goal: { id: 7 }, deltas: [] }; },
68
+ record: async () => {},
69
+ });
70
+ const { req, res } = fakeReqRes({ body: { subtitle: '' } });
71
+ return handler(req, res).then(() => {
72
+ assert.equal(res.code, null);
73
+ assert.ok(Object.prototype.hasOwnProperty.call(patched, 'subtitle'), 'empty string must still be a patch');
74
+ assert.equal(patched.subtitle, '');
75
+ });
76
+ });
77
+
78
+ test('nothing_to_update names subtitle as editable', () => {
79
+ const handler = goalEdits.goalProseHandler(deps);
80
+ const { req, res } = fakeReqRes({ body: { nope: 1 } });
81
+ return handler(req, res).then(() => {
82
+ assert.equal(res.code, 'nothing_to_update');
83
+ assert.deepEqual(res.payload.editable, ['title', 'subtitle', 'description']);
84
+ });
85
+ });
86
+
87
+ test('title and description still work — subtitle is additive', () => {
88
+ let patched = null;
89
+ const handler = goalEdits.goalProseHandler({
90
+ ...deps,
91
+ update: async ({ patch }) => { patched = patch; return { goal: { id: 7, ...patch }, deltas: [] }; },
92
+ record: async () => {},
93
+ });
94
+ const { req, res } = fakeReqRes({ body: { title: 'Findability', description: 'long prose' } });
95
+ return handler(req, res).then(() => {
96
+ assert.deepEqual(patched, { title: 'Findability', description: 'long prose' });
97
+ assert.ok(!Object.prototype.hasOwnProperty.call(patched, 'subtitle'), 'an unsent field is not patched to null');
98
+ });
99
+ });
100
+
101
+ // ---- the data layer --------------------------------------------------------
102
+ test('the migration is additive, core-numbered, and sets no default', () => {
103
+ const raw = read('migrations', 'core_224_goal_subtitle.sql');
104
+ assert.match(raw, /ADD COLUMN IF NOT EXISTS subtitle text/);
105
+ // Assert over the STATEMENTS, not the prose. The first version of this test
106
+ // matched /DEFAULT ''/ anywhere in the file and failed on the migration's own
107
+ // comment explaining why it does NOT set a default — the same over-broad-source
108
+ // -assertion mistake this session has made repeatedly (learning 1000196).
109
+ const ddl = raw.split('\n').filter((l) => !/^\s*--/.test(l)).join('\n');
110
+ assert.ok(!/\bDROP\b/i.test(ddl), 'no destructive DDL');
111
+ assert.ok(!/SET NOT NULL/i.test(ddl), 'stays nullable');
112
+ assert.ok(!/ADD COLUMN[^;]*DEFAULT/i.test(ddl), 'the added column takes no default');
113
+ assert.match(ddl, /schema_migrations/, 'records itself');
114
+ });
115
+
116
+ test('subtitle is in GOAL_COLS, so every read returns it', () => {
117
+ // One column list drives getGoal, the list query and createGoal's RETURNING —
118
+ // adding it anywhere else would be a second source that can drift.
119
+ const src = read('modules', 'lifecycle', 'db-goals.js');
120
+ const cols = /const GOAL_COLS\s*=\s*\n?\s*'([^']+)'/.exec(src);
121
+ assert.ok(cols, 'GOAL_COLS is still a single string literal');
122
+ assert.ok(cols[1].split(',').map((c) => c.trim()).includes('subtitle'), 'subtitle is selected');
123
+ });
124
+
125
+ test('POST /goals gained subtitle and POST /goals/:id/tasks did NOT', () => {
126
+ // The identical `title` + `description` validator pair appears in both routes.
127
+ // A blind replace-all would have given task creation a subtitle; this pins the
128
+ // boundary. (The first attempt at this edit tripped on exactly that ambiguity.)
129
+ const src = read('modules', 'lifecycle', 'routes', 'goals.js');
130
+ const goalRoute = src.slice(src.indexOf("router.post('/goals',"), src.indexOf("router.get('/goals',"));
131
+ assert.match(goalRoute, /subtitle: \{ type: 'string'/, 'the goal route takes a subtitle');
132
+ const taskRoute = src.slice(src.indexOf("router.post('/goals/:id/tasks',"));
133
+ const taskValidator = taskRoute.slice(0, taskRoute.indexOf('})) return;'));
134
+ assert.ok(!/subtitle/.test(taskValidator), 'task creation must NOT accept a subtitle');
135
+ });
136
+
137
+ // ---- the default reading path ---------------------------------------------
138
+ test("Claude's prose is a CLOSED disclosure, not visible body text", () => {
139
+ const html = read('modules', 'hall-ui', 'public', 'goals.html');
140
+ assert.match(html, /<details class="claude-text"[^>]*hidden/, 'a <details>, hidden until it has content');
141
+ assert.ok(!/<p class="goal-detail__desc" id="goal-detail-desc"><\/p>/.test(html),
142
+ 'the description is no longer a bare always-visible paragraph');
143
+ // the description element must now live INSIDE the disclosure
144
+ const det = html.slice(html.indexOf('<details class="claude-text"'));
145
+ const inner = det.slice(0, det.indexOf('</details>'));
146
+ assert.match(inner, /id="goal-detail-desc"/, 'the description renders inside the disclosure');
147
+ });
148
+
149
+ // applyGoalProse is pure enough to RUN, so these two assert behaviour rather
150
+ // than source text — a stronger pin, and the reason the rules were moved out of
151
+ // goals-page.js (which is 500 lines over the excess-lines ratchet) in the first
152
+ // place. Fake elements stand in for the DOM.
153
+ const el = () => ({ textContent: null, hidden: null, open: null });
154
+ function applyProse(goal) {
155
+ const lib = read('modules', 'hall-ui', 'public', 'goals-lib.js');
156
+ const els = { sub: el(), desc: el(), claude: el() };
157
+ const fn = new Function('window', `${lib}; return window.OTBGoalsLib.applyGoalProse;`)({ OTB: { escapeHtml: (x) => x } });
158
+ fn(els, goal);
159
+ return els;
160
+ }
161
+
162
+ test('the disclosure is CLOSED on render, never opened for the user', () => {
163
+ const els = applyProse({ description: 'Claude wrote a lot here', subtitle: 'sub' });
164
+ assert.equal(els.claude.open, false, 'closed');
165
+ assert.equal(els.claude.hidden, false, 'present, because there is prose to reveal');
166
+ assert.equal(els.desc.textContent, 'Claude wrote a lot here');
167
+ });
168
+
169
+ test('a goal with no description shows no empty disclosure', () => {
170
+ const els = applyProse({ description: ' ', subtitle: 'sub' });
171
+ assert.equal(els.claude.hidden, true, 'an empty disclosure invites a dead click');
172
+ });
173
+
174
+ test('an unset subtitle is hidden; a set one is shown trimmed', () => {
175
+ assert.equal(applyProse({}).sub.hidden, true, 'NULL subtitle renders nothing');
176
+ const els = applyProse({ subtitle: ' the grep path ' });
177
+ assert.equal(els.sub.hidden, false);
178
+ assert.equal(els.sub.textContent, 'the grep path');
179
+ });
180
+
181
+ test('on a LIST the subtitle is a tooltip, never a second visible line', () => {
182
+ const js = read('modules', 'hall-ui', 'public', 'goals-page.js');
183
+ // The helper lives in goals-lib.js — the file that exists because
184
+ // goals-page.js is over the excess-lines ratchet (task 1003135).
185
+ const lib = read('modules', 'hall-ui', 'public', 'goals-lib.js');
186
+ assert.match(lib, /function subtitleAttr/);
187
+ assert.match(lib, /escapeHtml\(t\)/, 'escaped, since it lands in an attribute');
188
+ for (const anchor of ['class="gr-title"', 'class="goal-card__title gr-title"']) {
189
+ const i = js.indexOf(anchor);
190
+ assert.ok(i > 0, `${anchor} still rendered`);
191
+ assert.match(js.slice(i, i + 160), /subtitleAttr\(g\)/, `${anchor} carries the tooltip`);
192
+ }
193
+ });
194
+
195
+ test('every CSS token the new styles use actually exists', () => {
196
+ // copy-desk.css shipped with --line and --panel, which style.css never
197
+ // defines, so its fallbacks rendered as nothing (idea 1000821).
198
+ const css = read('modules', 'hall-ui', 'public', 'goals.css');
199
+ const style = read('modules', 'hall-ui', 'public', 'style.css');
200
+ const block = css.slice(css.indexOf('task 1003224'));
201
+ const used = new Set([...block.matchAll(/var\((--[a-z-]+)\)/g)].map((m) => m[1]));
202
+ assert.ok(used.size >= 4, 'the new block does use tokens');
203
+ for (const t of used) {
204
+ assert.ok(new RegExp(`^\\s*${t}:`, 'm').test(style), `${t} must be defined in style.css`);
205
+ }
206
+ });
207
+
208
+ test('the subtitle style is subordinate to the title style', () => {
209
+ const css = read('modules', 'hall-ui', 'public', 'goals.css');
210
+ const size = (sel) => {
211
+ const i = css.indexOf(sel);
212
+ assert.ok(i > 0, `${sel} exists`);
213
+ const m = /font-size:\s*([\d.]+)px/.exec(css.slice(i, i + 300));
214
+ return m ? Number(m[1]) : null;
215
+ };
216
+ const title = size('.goal-detail__title');
217
+ const sub = size('.goal-detail__subtitle');
218
+ assert.ok(title && sub, 'both declare a size');
219
+ assert.ok(sub < title, `subtitle (${sub}px) must read smaller than the name (${title}px)`);
220
+ });
221
+
222
+ test("the PATCH route's own comment names every field it accepts", () => {
223
+ // The API reference is GENERATED from this comment, so the generator
224
+ // faithfully republishes a stale one: after subtitle was added the docs still
225
+ // advertised 'title/description', which the grader caught as a blocker. This
226
+ // pins comment and validator together so the next added field cannot drift.
227
+ const routes = read('modules', 'lifecycle', 'routes', 'goals.js');
228
+ const i = routes.indexOf("router.patch('/goals/:id',");
229
+ assert.ok(i > 0, 'the PATCH route still exists');
230
+ const comment = routes.slice(routes.lastIndexOf('// PATCH /goals/:id', i), i);
231
+ const edits = read('modules', 'lifecycle', 'goal-edits.js');
232
+ const validator = edits.slice(edits.indexOf('function goalProseHandler'));
233
+ for (const field of ['title', 'subtitle', 'description']) {
234
+ assert.match(validator, new RegExp(`${field}: \\{ `), `${field} is validated`);
235
+ assert.ok(comment.includes(field), `the route comment must name ${field} — the API reference is generated from it`);
236
+ }
237
+ });
@@ -65,8 +65,10 @@ await test('createGoal: INSERT binds all six fields in order and returns the row
65
65
  );
66
66
  assert.deepEqual(out, GOAL_ROW, 'returns the inserted row');
67
67
  const ins = find(pool.queries, /INSERT INTO goals/);
68
- assert.deepEqual(ins.params, ['BONGOS-V1', 'Goal hierarchy', 'desc', ['gds-core'], 'open', 42],
69
- 'version_id, title, description, scope_modules, status, created_by — in that order');
68
+ // task 1003224 appended `subtitle` as $7 rather than inserting it next to
69
+ // title, so $1–$6 keep the positions this test has always pinned.
70
+ assert.deepEqual(ins.params, ['BONGOS-V1', 'Goal hierarchy', 'desc', ['gds-core'], 'open', 42, null],
71
+ 'version_id, title, description, scope_modules, status, created_by, then subtitle — in that order');
70
72
  assert.match(ins.sql, /RETURNING/, 'returns the row, not just inserts');
71
73
  });
72
74
 
@@ -87,8 +89,8 @@ await test('createGoal: defaults — null description/createdBy, empty scope, op
87
89
  const pool = fakePool((s) => (/INSERT INTO goals/.test(s) ? { rows: [GOAL_ROW] } : null));
88
90
  await createGoal({ versionId: 'V1', title: 'general' }, { pool });
89
91
  const ins = find(pool.queries, /INSERT INTO goals/);
90
- assert.deepEqual(ins.params, ['V1', 'general', null, [], 'open', null],
91
- 'unspecified fields fall back to null / [] / open');
92
+ assert.deepEqual(ins.params, ['V1', 'general', null, [], 'open', null, null],
93
+ 'unspecified fields fall back to null / [] / open — subtitle ($7) defaults to null too');
92
94
  });
93
95
 
94
96
  // =========================================================================