@bongos/core 1.19.300 → 1.19.302
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 +33 -23
- package/.claude/skills/idea-triage/SKILL.md +19 -0
- package/docs/copy-inventory.md +36 -27
- package/docs/copy-registry.json +112 -31
- package/docs/file-map.md +1 -1
- package/modules/hall-ui/public/idea.css +15 -0
- package/modules/hall-ui/public/idea.js +91 -5
- package/modules/hall-ui/public/ideas.css +27 -0
- package/modules/hall-ui/public/ideas.html +29 -7
- package/modules/hall-ui/public/ideas.js +141 -17
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/render-ideas.js +62 -9
- package/scripts/gds/triage.js +19 -2
- package/src/module-api.js +5 -1
- package/tests/idea_detail_page.mjs +130 -4
- package/tests/idea_hall_composer.mjs +51 -0
- package/tests/idea_spark_hall.mjs +263 -0
- package/tests/idea_triage_grade.mjs +167 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// tests/idea_spark_hall.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002930 (BV1.R154, goal 1000062) — the spark handoff in the hall,
|
|
4
|
+
// exercised at RUNTIME rather than over the source.
|
|
5
|
+
//
|
|
6
|
+
// The done-when this file owns: "starting development from the queue leads into
|
|
7
|
+
// the R152 authoring surface pre-filled with the spark". So it boots ideas.js
|
|
8
|
+
// twice against a stub DOM — once plainly, once as ?develop=<id> — and asserts
|
|
9
|
+
// what a builder would actually get: the queue rows and where they point, the
|
|
10
|
+
// spark's own words carried into the composer, a title that is shown but NOT
|
|
11
|
+
// editable, and the file going to the develop route with no title or grade on it.
|
|
12
|
+
//
|
|
13
|
+
// (The author-only mark control is the other half of R154 and lives with the page
|
|
14
|
+
// it renders on — tests/idea_detail_page.mjs.)
|
|
15
|
+
//
|
|
16
|
+
// Pure vm + file read.
|
|
17
|
+
//
|
|
18
|
+
// Run: node tests/idea_spark_hall.mjs
|
|
19
|
+
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import vm from 'node:vm';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
30
|
+
const SRC = fs.readFileSync(path.join(HALL, 'ideas.js'), 'utf8');
|
|
31
|
+
|
|
32
|
+
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
33
|
+
const TEMPLATE = {
|
|
34
|
+
key: BASE_TEMPLATE.key,
|
|
35
|
+
label: BASE_TEMPLATE.label,
|
|
36
|
+
fields: BASE_TEMPLATE.fields.map((f) => ({ key: f.key, label: f.label, required: !!f.required, substance_min: f.substanceMin || 0 })),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const SPARK = {
|
|
40
|
+
id: '77', title: 'Let a builder mute one channel', status: 'open', grade: 'spark',
|
|
41
|
+
body_md: 'I muted the whole server and then missed a ship.',
|
|
42
|
+
captured_by: '9', captured_at: '2026-08-01T00:00:00Z', filed_by_login: 'thinker',
|
|
43
|
+
field_values: null, completeness_score: null, template_key: null,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const QUEUE = [
|
|
47
|
+
SPARK,
|
|
48
|
+
{ id: '78', title: 'A shared changelog', status: 'open', grade: 'spark', captured_at: '2026-07-30T00:00:00Z', filed_by_login: null },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function boot({ search = '', sparks = QUEUE, count = null, idea = SPARK, ideaOk = true, store = {} } = {}) {
|
|
52
|
+
const els = new Map();
|
|
53
|
+
const makeEl = () => ({
|
|
54
|
+
dataset: {}, style: {}, textContent: '', value: '', hidden: false, disabled: false,
|
|
55
|
+
readOnly: false, tabIndex: 0, _html: '', _handlers: {},
|
|
56
|
+
classList: { add() {}, remove() {} },
|
|
57
|
+
setAttribute() {}, getAttribute() { return null; },
|
|
58
|
+
addEventListener(t, fn) { this._handlers[t] = fn; },
|
|
59
|
+
querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
|
|
60
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const documentObj = {
|
|
64
|
+
title: '', body: { dataset: { page: 'ideas' } },
|
|
65
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
66
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
67
|
+
querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
|
|
68
|
+
querySelectorAll() { return []; },
|
|
69
|
+
createElement() { return makeEl(); },
|
|
70
|
+
addEventListener() {},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const calls = [];
|
|
74
|
+
const client = {
|
|
75
|
+
inbox: {
|
|
76
|
+
getInboxTemplate: async () => ({ status: 200, ok: true, data: { template: TEMPLATE } }),
|
|
77
|
+
getInboxSparks: async () => { calls.push(['sparks', null]); return { status: 200, ok: true, data: { sparks, count: count == null ? sparks.length : count } }; },
|
|
78
|
+
getInboxId: async (a) => { calls.push(['idea', a]); return { status: ideaOk ? 200 : 404, ok: ideaOk, data: ideaOk ? { idea } : null }; },
|
|
79
|
+
postInboxScore: async (a) => { calls.push(['score', a]); return { status: 200, ok: true, data: { score: 60, fields: TEMPLATE.fields.map((f) => ({ key: f.key, substance: 24, floor: 40, ratio: 0.6 })) } }; },
|
|
80
|
+
postInbox: async (a) => { calls.push(['capture', a]); return { status: 201, ok: true, data: { idea: { id: '900', grade: 'full', completeness_score: 80 } } }; },
|
|
81
|
+
postInboxIdDevelop: async (a) => { calls.push(['develop', a]); return { status: 200, ok: true, data: { idea: { ...idea, grade: 'full', completeness_score: 80, ideator_credit: 5 } } }; },
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const toasts = [];
|
|
86
|
+
const windowObj = {
|
|
87
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
88
|
+
BongosClient: { createClient: () => client },
|
|
89
|
+
OTB: {
|
|
90
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
91
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
92
|
+
toast: (m) => toasts.push(m),
|
|
93
|
+
},
|
|
94
|
+
localStorage: {
|
|
95
|
+
getItem: (k) => (Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null),
|
|
96
|
+
setItem: (k, v) => { store[k] = String(v); },
|
|
97
|
+
removeItem: (k) => { delete store[k]; },
|
|
98
|
+
},
|
|
99
|
+
location: { hostname: 'builders.cloudbongos.com', pathname: '/ideas', search, assign() {} },
|
|
100
|
+
addEventListener() {},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date };
|
|
104
|
+
sandbox.globalThis = sandbox;
|
|
105
|
+
vm.createContext(sandbox);
|
|
106
|
+
vm.runInContext(SRC, sandbox, { filename: 'ideas.js' });
|
|
107
|
+
|
|
108
|
+
const el = (id) => documentObj.getElementById(id);
|
|
109
|
+
return {
|
|
110
|
+
calls, toasts, store, el,
|
|
111
|
+
queueHtml: () => el('fi-queue').innerHTML,
|
|
112
|
+
fill: () => { for (const f of TEMPLATE.fields) el(`fi-f-${f.key}`).value = `an answer for ${f.key} long enough to count`; },
|
|
113
|
+
submit: () => el('fi-form')._handlers.submit({ preventDefault() {} }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
118
|
+
|
|
119
|
+
let passed = 0;
|
|
120
|
+
let failed = 0;
|
|
121
|
+
async function test(name, fn) {
|
|
122
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
123
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- the queue -----------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
await test('the queue lists each waiting spark, naming who had the thought', async () => {
|
|
129
|
+
const r = boot();
|
|
130
|
+
await settle(); await settle();
|
|
131
|
+
const html = r.queueHtml();
|
|
132
|
+
assert.ok(html.includes('Let a builder mute one channel'));
|
|
133
|
+
assert.ok(html.includes('A shared changelog'));
|
|
134
|
+
assert.match(html, /@thinker/);
|
|
135
|
+
assert.match(html, /an unnamed builder/, 'a missing login degrades, it does not print @null');
|
|
136
|
+
assert.doesNotMatch(html, /undefined|null</);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await test('each row links to the idea PAGE and offers a develop link', async () => {
|
|
140
|
+
const r = boot();
|
|
141
|
+
await settle(); await settle();
|
|
142
|
+
const html = r.queueHtml();
|
|
143
|
+
assert.match(html, /href="\/idea\/77"/, 'the title links at the R153 page');
|
|
144
|
+
assert.match(html, /href="\?develop=77"/, 'and the action enters develop mode');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test('an empty queue says how one gets filled', async () => {
|
|
148
|
+
const r = boot({ sparks: [] });
|
|
149
|
+
await settle(); await settle();
|
|
150
|
+
assert.match(r.queueHtml(), /Nothing is waiting/);
|
|
151
|
+
assert.match(r.queueHtml(), /author offers a quick idea up/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await test('a truncated queue says so', async () => {
|
|
155
|
+
const r = boot({ count: 9 });
|
|
156
|
+
await settle(); await settle();
|
|
157
|
+
assert.match(r.queueHtml(), /Showing 2 of 9/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// --- develop mode: pre-filled with the spark -----------------------------
|
|
161
|
+
|
|
162
|
+
await test('develop mode carries the spark\'s own words into the composer', async () => {
|
|
163
|
+
const r = boot({ search: '?develop=77' });
|
|
164
|
+
await settle(); await settle(); await settle();
|
|
165
|
+
assert.equal(r.el('fi-develop').hidden, false);
|
|
166
|
+
assert.equal(r.el('fi-develop-title').textContent, SPARK.title);
|
|
167
|
+
assert.equal(r.el('fi-develop-body').textContent, SPARK.body_md);
|
|
168
|
+
assert.match(r.el('fi-develop-who').textContent, /@thinker/);
|
|
169
|
+
assert.match(r.el('fi-develop-who').textContent, /keep the credit/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await test('the title is carried but NOT editable — developing must not rename it', async () => {
|
|
173
|
+
const r = boot({ search: '?develop=77' });
|
|
174
|
+
await settle(); await settle(); await settle();
|
|
175
|
+
assert.equal(r.el('fi-title').value, SPARK.title, 'the title is present');
|
|
176
|
+
assert.equal(r.el('fi-title').readOnly, true);
|
|
177
|
+
assert.equal(r.el('fi-title').hidden, true, 'and shown as a fact in the block above, not as a field');
|
|
178
|
+
assert.equal(r.el('fi-title-label').hidden, true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await test('the action says what it does', async () => {
|
|
182
|
+
const r = boot({ search: '?develop=77' });
|
|
183
|
+
await settle(); await settle(); await settle();
|
|
184
|
+
assert.equal(r.el('fi-submit').textContent, 'Develop this idea');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await test('filing in develop mode calls the DEVELOP route with no title and no grade', async () => {
|
|
188
|
+
const r = boot({ search: '?develop=77' });
|
|
189
|
+
await settle(); await settle(); await settle();
|
|
190
|
+
r.fill();
|
|
191
|
+
await r.submit();
|
|
192
|
+
await settle();
|
|
193
|
+
const call = r.calls.find((c) => c[0] === 'develop');
|
|
194
|
+
assert.ok(call, 'POST /inbox/:id/develop was called');
|
|
195
|
+
assert.equal(call[1].id, '77');
|
|
196
|
+
assert.ok(call[1].body.field_values, 'it carries the five answers');
|
|
197
|
+
assert.ok(!('title' in call[1].body), 'and no title');
|
|
198
|
+
assert.ok(!('grade' in call[1].body), 'and no grade');
|
|
199
|
+
assert.ok(!r.calls.some((c) => c[0] === 'capture'), 'and never files a second idea');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await test('the developer is told about the split', async () => {
|
|
203
|
+
const r = boot({ search: '?develop=77' });
|
|
204
|
+
await settle(); await settle(); await settle();
|
|
205
|
+
r.fill();
|
|
206
|
+
await r.submit();
|
|
207
|
+
await settle();
|
|
208
|
+
assert.ok(r.toasts.some((t) => /split with its author/.test(t)), `toasts: ${JSON.stringify(r.toasts)}`);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await test('the develop draft is stored under its OWN key', async () => {
|
|
212
|
+
const store = {};
|
|
213
|
+
const r = boot({ search: '?develop=77', store });
|
|
214
|
+
await settle(); await settle(); await settle();
|
|
215
|
+
r.fill();
|
|
216
|
+
// The draft write is debounced behind an input event; call the saver's trigger.
|
|
217
|
+
r.el('fi-f-purpose')._handlers.input();
|
|
218
|
+
await new Promise((res) => setTimeout(res, 600));
|
|
219
|
+
const keys = Object.keys(store);
|
|
220
|
+
assert.ok(keys.length >= 1, `something was stored (${JSON.stringify(keys)})`);
|
|
221
|
+
assert.ok(keys.every((k) => k.includes('develop:77')), `keyed per idea: ${JSON.stringify(keys)}`);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// --- develop mode refuses what the server would refuse -------------------
|
|
225
|
+
|
|
226
|
+
await test('a non-spark cannot be developed from this surface', async () => {
|
|
227
|
+
for (const [grade, expect] of [['full', /already been developed/], ['quick', /not a Big Idea Spark/]]) {
|
|
228
|
+
const r = boot({ search: '?develop=77', idea: { ...SPARK, grade } });
|
|
229
|
+
await settle(); await settle(); await settle();
|
|
230
|
+
assert.match(r.el('fi-sub').textContent, expect, grade);
|
|
231
|
+
assert.equal(r.el('fi-form').hidden, true, 'and the form is not offered');
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await test('an unloadable idea says so instead of showing an empty form', async () => {
|
|
236
|
+
const r = boot({ search: '?develop=77', ideaOk: false });
|
|
237
|
+
await settle(); await settle(); await settle();
|
|
238
|
+
assert.match(r.el('fi-sub').textContent, /Could not load idea 77/);
|
|
239
|
+
assert.equal(r.el('fi-form').hidden, true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await test('the plain composer is unaffected — develop mode is never entered', async () => {
|
|
243
|
+
const r = boot();
|
|
244
|
+
await settle(); await settle();
|
|
245
|
+
// Asserted as "develop mode never ran" rather than "#fi-develop is hidden":
|
|
246
|
+
// the stub DOM does not parse the markup, so the element's `hidden` default is
|
|
247
|
+
// false here regardless. The block ships with the `hidden` attribute, which
|
|
248
|
+
// tests/idea_hall_composer.mjs pins over the HTML source; what matters at
|
|
249
|
+
// runtime is that nothing reveals it, and the tell is that no idea was fetched.
|
|
250
|
+
assert.ok(!r.calls.some((c) => c[0] === 'idea'), 'no idea is loaded without ?develop=');
|
|
251
|
+
assert.equal(r.el('fi-title').readOnly, false, 'the title stays editable');
|
|
252
|
+
r.el('fi-title').value = 'a new idea';
|
|
253
|
+
r.fill();
|
|
254
|
+
await r.submit();
|
|
255
|
+
await settle();
|
|
256
|
+
const call = r.calls.find((c) => c[0] === 'capture');
|
|
257
|
+
assert.ok(call, 'it still files through POST /inbox');
|
|
258
|
+
assert.equal(call[1].body.grade, 'full');
|
|
259
|
+
assert.equal(call[1].body.title, 'a new idea');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
console.log(`\nidea_spark_hall: ${passed} passed, ${failed} failed`);
|
|
263
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// tests/idea_triage_grade.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002924 (BV1.R148, goal 1000062) — the grade on the triage surfaces, and
|
|
4
|
+
// the ordering.
|
|
5
|
+
//
|
|
6
|
+
// The done-when has three clauses and the THIRD is the one with a trap in it:
|
|
7
|
+
// 1. each idea in triage shows its grade at a glance;
|
|
8
|
+
// 2. full ideas sort by completeness;
|
|
9
|
+
// 3. quick ideas and sparks remain visible rather than being buried under
|
|
10
|
+
// scored ideas.
|
|
11
|
+
//
|
|
12
|
+
// (2) and (3) cannot both be satisfied by one list sorted on completeness: every
|
|
13
|
+
// unscored idea lands after every scored one, so in a long inbox nobody reaches
|
|
14
|
+
// them. The answer is two ordered groups, and that is what these assertions pin —
|
|
15
|
+
// including that the group split is on GRADE and not on "has a score", which
|
|
16
|
+
// would misfile an unscored Full Idea.
|
|
17
|
+
//
|
|
18
|
+
// Pure require of the two exported helpers; no session, no network.
|
|
19
|
+
//
|
|
20
|
+
// Run: node tests/idea_triage_grade.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const { gradeLabel, partition } = require('../scripts/gds/render-ideas.js');
|
|
31
|
+
|
|
32
|
+
const open = (o) => ({ status: 'open', captured_at: '2026-08-01T00:00:00Z', ...o });
|
|
33
|
+
|
|
34
|
+
let passed = 0;
|
|
35
|
+
let failed = 0;
|
|
36
|
+
function test(name, fn) {
|
|
37
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
38
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- (1) the grade at a glance ------------------------------------------
|
|
42
|
+
|
|
43
|
+
test('a Full Idea shows its grade AND its score', () => {
|
|
44
|
+
assert.equal(gradeLabel({ grade: 'full', completeness_score: 88 }), 'FULL 88/100');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a Full Idea with no score says unscored rather than implying zero', () => {
|
|
48
|
+
// R142 returns null for anything it cannot score, and that is a legal stored
|
|
49
|
+
// state — "unscorable" and "not yet scored" deliberately look the same.
|
|
50
|
+
assert.equal(gradeLabel({ grade: 'full', completeness_score: null }), 'FULL (unscored)');
|
|
51
|
+
assert.doesNotMatch(gradeLabel({ grade: 'full', completeness_score: null }), /0/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a spark says what it is waiting for', () => {
|
|
55
|
+
assert.match(gradeLabel({ grade: 'spark' }), /awaiting development/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a missing grade reads as quick — the R140 absence-is-quick design', () => {
|
|
59
|
+
// Not a gap to hide: an idea with no ideas_grades row IS quick.
|
|
60
|
+
assert.equal(gradeLabel({}), 'quick');
|
|
61
|
+
assert.equal(gradeLabel({ grade: null }), 'quick');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// --- (2) full ideas sort by completeness --------------------------------
|
|
65
|
+
|
|
66
|
+
test('Full Ideas are ordered best-formed first', () => {
|
|
67
|
+
const g = partition([
|
|
68
|
+
open({ id: 1, grade: 'full', completeness_score: 40 }),
|
|
69
|
+
open({ id: 2, grade: 'full', completeness_score: 95 }),
|
|
70
|
+
open({ id: 3, grade: 'full', completeness_score: 70 }),
|
|
71
|
+
]);
|
|
72
|
+
assert.deepEqual(g.openFull.map((i) => i.id), [2, 3, 1]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('ties among Full Ideas fall back to recency, so the order is stable', () => {
|
|
76
|
+
const g = partition([
|
|
77
|
+
open({ id: 1, grade: 'full', completeness_score: 70, captured_at: '2026-07-01T00:00:00Z' }),
|
|
78
|
+
open({ id: 2, grade: 'full', completeness_score: 70, captured_at: '2026-08-01T00:00:00Z' }),
|
|
79
|
+
]);
|
|
80
|
+
assert.deepEqual(g.openFull.map((i) => i.id), [2, 1]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an UNSCORED Full Idea sorts last among full ideas, but stays a full idea', () => {
|
|
84
|
+
// The split is on GRADE, not on "has a score" — filing it with the quick ideas
|
|
85
|
+
// would hide a five-field idea from the group a triager reads for five-field
|
|
86
|
+
// ideas, purely because the scorer could not score it.
|
|
87
|
+
const g = partition([
|
|
88
|
+
open({ id: 1, grade: 'full', completeness_score: null }),
|
|
89
|
+
open({ id: 2, grade: 'full', completeness_score: 10 }),
|
|
90
|
+
]);
|
|
91
|
+
assert.deepEqual(g.openFull.map((i) => i.id), [2, 1]);
|
|
92
|
+
assert.equal(g.openRest.length, 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// --- (3) quick ideas and sparks are NOT buried --------------------------
|
|
96
|
+
|
|
97
|
+
test('quick ideas and sparks get their OWN group, newest first', () => {
|
|
98
|
+
const g = partition([
|
|
99
|
+
open({ id: 1, grade: 'quick', captured_at: '2026-07-01T00:00:00Z' }),
|
|
100
|
+
open({ id: 2, grade: 'spark', captured_at: '2026-08-10T00:00:00Z' }),
|
|
101
|
+
open({ id: 3, captured_at: '2026-08-05T00:00:00Z' }),
|
|
102
|
+
]);
|
|
103
|
+
assert.deepEqual(g.openRest.map((i) => i.id), [2, 3, 1]);
|
|
104
|
+
assert.equal(g.openFull.length, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a quick idea is NEVER pushed behind scored ideas — it has its own top', () => {
|
|
108
|
+
// The trap the done-when names. With one score-sorted list, id 99 would be
|
|
109
|
+
// 50th; in its own group it is first.
|
|
110
|
+
const many = Array.from({ length: 49 }, (_, i) => open({ id: i + 1, grade: 'full', completeness_score: 50 + (i % 40) }));
|
|
111
|
+
const g = partition([...many, open({ id: 99, grade: 'quick', captured_at: '2026-08-20T00:00:00Z' })]);
|
|
112
|
+
assert.equal(g.openRest[0].id, 99, 'first in its own group, not last in a merged one');
|
|
113
|
+
assert.equal(g.openFull.length, 49);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('every open idea lands in exactly one group — none is dropped', () => {
|
|
117
|
+
const ideas = [
|
|
118
|
+
open({ id: 1, grade: 'full', completeness_score: 80 }),
|
|
119
|
+
open({ id: 2, grade: 'spark' }),
|
|
120
|
+
open({ id: 3, grade: 'quick' }),
|
|
121
|
+
open({ id: 4 }),
|
|
122
|
+
open({ id: 5, grade: 'full', completeness_score: null }),
|
|
123
|
+
];
|
|
124
|
+
const g = partition(ideas);
|
|
125
|
+
const seen = [...g.openFull, ...g.openRest].map((i) => i.id).sort((a, b) => a - b);
|
|
126
|
+
assert.deepEqual(seen, [1, 2, 3, 4, 5]);
|
|
127
|
+
assert.equal(g.open.length, 5, 'the undivided open list is still returned for the count');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a non-open idea is not in either open group', () => {
|
|
131
|
+
const g = partition([
|
|
132
|
+
open({ id: 1, grade: 'full', completeness_score: 90 }),
|
|
133
|
+
{ id: 2, status: 'discarded', grade: 'full', completeness_score: 99, captured_at: new Date().toISOString() },
|
|
134
|
+
]);
|
|
135
|
+
assert.deepEqual(g.openFull.map((i) => i.id), [1]);
|
|
136
|
+
assert.ok(!g.openRest.some((i) => i.id === 2));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// --- the surfaces agree --------------------------------------------------
|
|
140
|
+
|
|
141
|
+
test('the interactive walk and the generated file share ONE grade label', () => {
|
|
142
|
+
// Two triage surfaces describing the same idea differently defeats the point of
|
|
143
|
+
// a grade at a glance, so triage.js imports the label rather than restating it.
|
|
144
|
+
const triage = fs.readFileSync(path.join(ROOT, 'scripts/gds/triage.js'), 'utf8');
|
|
145
|
+
assert.match(triage, /require\('\.\/render-ideas'\)/, 'triage.js imports the shared helpers');
|
|
146
|
+
assert.match(triage, /gradeLabel\(idea\)/, 'and prints the shared label on each card');
|
|
147
|
+
assert.ok(!/FULL \$\{|SPARK —/.test(triage), 'triage.js does not restate the label text');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the walk is ordered by the shared partition, not re-sorted locally', () => {
|
|
151
|
+
const triage = fs.readFileSync(path.join(ROOT, 'scripts/gds/triage.js'), 'utf8');
|
|
152
|
+
assert.match(triage, /partition\(ideas\)/);
|
|
153
|
+
assert.match(triage, /openFull\.concat\(grouped\.openRest\)/, 'full ideas first, then the rest');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('the skill documents the grade and the two-group walk', () => {
|
|
157
|
+
const skill = fs.readFileSync(path.join(ROOT, '.claude/skills/idea-triage/SKILL.md'), 'utf8');
|
|
158
|
+
assert.match(skill, /completeness_score/);
|
|
159
|
+
assert.match(skill, /Full Ideas first, best-formed first/);
|
|
160
|
+
// And it must carry the REASON, not just the instruction — the next person to
|
|
161
|
+
// "simplify" this into one sorted list needs to know why it is two.
|
|
162
|
+
assert.match(skill, /nobody reaches them/);
|
|
163
|
+
assert.match(skill, /Do not auto-convert/, 'and R149\'s rule for a Full Idea');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
console.log(`\nidea_triage_grade: ${passed} passed, ${failed} failed`);
|
|
167
|
+
if (failed > 0) process.exit(1);
|