@bongos/core 1.19.299 → 1.19.301
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 +45 -20
- package/docs/copy-inventory.md +53 -28
- package/docs/copy-registry.json +285 -30
- package/docs/file-map.md +1 -1
- package/modules/hall-ui/public/idea.css +89 -0
- package/modules/hall-ui/public/idea.html +55 -0
- package/modules/hall-ui/public/idea.js +289 -0
- 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 +145 -18
- package/modules/hall-ui/public/task-detail.js +13 -0
- package/modules/ideas/inbox.js +14 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/serve-internal.js +7 -1
- package/src/module-api.js +5 -1
- package/tests/idea_detail_page.mjs +397 -0
- package/tests/idea_hall_composer.mjs +51 -0
- package/tests/idea_spark_hall.mjs +263 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// tests/idea_detail_page.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002929 (BV1.R153, goal 1000062) — the idea-detail page (/idea/:id).
|
|
4
|
+
//
|
|
5
|
+
// The done-when, as assertions:
|
|
6
|
+
// 1. a Full Idea is legible end to end — every question and every answer, in
|
|
7
|
+
// TEMPLATE order, plus the score;
|
|
8
|
+
// 2. a QUICK idea renders on the same page with NO empty five-field skeleton —
|
|
9
|
+
// five headings over five dashes is not an empty state, it is a page telling
|
|
10
|
+
// a builder they were wrong to file a quick idea;
|
|
11
|
+
// 3. spark AND developer attribution are both visible — R146's whole point is
|
|
12
|
+
// that the author survives being developed by someone else, so a developed
|
|
13
|
+
// idea must name two people.
|
|
14
|
+
//
|
|
15
|
+
// The page renders by string-building into #idea-body, so this executes the real
|
|
16
|
+
// render path in a minimal DOM stub (the hall_nav.mjs / hall_signout.mjs pattern)
|
|
17
|
+
// and asserts over the HTML a viewer would actually get. Pure vm + file read.
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/idea_detail_page.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import vm from 'node:vm';
|
|
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 HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
31
|
+
const SRC = fs.readFileSync(path.join(HALL, 'idea.js'), 'utf8');
|
|
32
|
+
|
|
33
|
+
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
34
|
+
|
|
35
|
+
// The template as GET /inbox/template projects it (R152's shape).
|
|
36
|
+
const TEMPLATE = {
|
|
37
|
+
key: BASE_TEMPLATE.key,
|
|
38
|
+
label: BASE_TEMPLATE.label,
|
|
39
|
+
fields: BASE_TEMPLATE.fields.map((f) => ({ key: f.key, label: f.label, required: !!f.required, substance_min: f.substanceMin || 0 })),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Boot idea.js against a stub DOM + a stub client that answers with `idea`, and
|
|
43
|
+
// return the HTML it wrote into #idea-body.
|
|
44
|
+
function render(idea, { template = TEMPLATE, templateFails = false, ideaStatus = 200, viewer = null, sparkResult = null } = {}) {
|
|
45
|
+
const els = new Map();
|
|
46
|
+
const makeEl = () => ({
|
|
47
|
+
dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
|
|
48
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
49
|
+
setAttribute() {}, getAttribute() { return null; },
|
|
50
|
+
_handlers: {},
|
|
51
|
+
addEventListener(type, fn) { this._handlers[type] = fn; },
|
|
52
|
+
querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
|
|
53
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const documentObj = {
|
|
57
|
+
title: '',
|
|
58
|
+
body: { dataset: { page: 'idea' } },
|
|
59
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
60
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
61
|
+
querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
|
|
62
|
+
querySelectorAll() { return []; },
|
|
63
|
+
createElement() { return makeEl(); },
|
|
64
|
+
addEventListener() {},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const calls = [];
|
|
68
|
+
const client = {
|
|
69
|
+
inbox: {
|
|
70
|
+
getInboxId: async (args) => { calls.push(['idea', args]); return { status: ideaStatus, ok: ideaStatus === 200, data: { idea } }; },
|
|
71
|
+
getInboxTemplate: async (args) => {
|
|
72
|
+
calls.push(['template', args]);
|
|
73
|
+
if (templateFails) return { status: 500, ok: false, data: null };
|
|
74
|
+
return { status: 200, ok: true, data: { template } };
|
|
75
|
+
},
|
|
76
|
+
// R154's mark control. `sparkResult` lets a test drive the failure path.
|
|
77
|
+
postInboxIdSpark: async (args) => {
|
|
78
|
+
calls.push(['spark', args]);
|
|
79
|
+
return sparkResult || { status: 200, ok: true, data: { idea: { ...idea, grade: 'spark' }, sparked: true } };
|
|
80
|
+
},
|
|
81
|
+
deleteInboxIdSpark: async (args) => {
|
|
82
|
+
calls.push(['unspark', args]);
|
|
83
|
+
return sparkResult || { status: 200, ok: true, data: { idea: { ...idea, grade: 'quick' }, sparked: false } };
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
// The page reads /me for the author-only control (R154).
|
|
87
|
+
me: { getMe: async () => { calls.push(['me', null]); return { status: 200, ok: true, data: { builder: viewer } }; } },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const windowObj = {
|
|
91
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
92
|
+
BongosClient: { createClient: () => client },
|
|
93
|
+
OTB: {
|
|
94
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
95
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
96
|
+
fmtDate: (iso) => String(iso).slice(0, 10),
|
|
97
|
+
toast() {},
|
|
98
|
+
},
|
|
99
|
+
location: { hostname: 'builders.cloudbongos.com', pathname: `/idea/${idea && idea.id ? idea.id : '1'}`, assign() {} },
|
|
100
|
+
addEventListener() {},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout };
|
|
104
|
+
sandbox.globalThis = sandbox;
|
|
105
|
+
vm.createContext(sandbox);
|
|
106
|
+
vm.runInContext(SRC, sandbox, { filename: 'idea.js' });
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
html: () => documentObj.getElementById('idea-body').innerHTML,
|
|
110
|
+
calls,
|
|
111
|
+
documentObj,
|
|
112
|
+
// The mark button is written via innerHTML, so the stub DOM has no real
|
|
113
|
+
// element for it — idea.js looks it up with $('#ig-mark-btn'), which resolves
|
|
114
|
+
// to the memoised stub, so its captured click handler is the click.
|
|
115
|
+
clickMark: () => {
|
|
116
|
+
const btn = documentObj.getElementById('ig-mark-btn');
|
|
117
|
+
return btn._handlers.click ? btn._handlers.click() : null;
|
|
118
|
+
},
|
|
119
|
+
markBtn: () => documentObj.getElementById('ig-mark-btn'),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The render is async (two awaits at most), so let the microtask queue drain.
|
|
124
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
125
|
+
|
|
126
|
+
const FULL_VALUES = Object.fromEntries(BASE_TEMPLATE.fields.map((f, i) => [f.key, `Answer number ${i + 1}, with enough words in it to read like a real answer.`]));
|
|
127
|
+
|
|
128
|
+
const fullIdea = {
|
|
129
|
+
id: '812', title: 'A shared changelog', status: 'open', kind: 'feature',
|
|
130
|
+
body_md: 'the original quick thought', captured_by: '9', captured_at: '2026-08-01T00:00:00Z',
|
|
131
|
+
grade: 'full', template_key: 'base-v1', field_values: FULL_VALUES, completeness_score: 88,
|
|
132
|
+
filed_by_login: 'thinker', developed_by: '42', developed_at: '2026-08-20T00:00:00Z', developed_by_login: 'someone',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let passed = 0;
|
|
136
|
+
let failed = 0;
|
|
137
|
+
async function test(name, fn) {
|
|
138
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
139
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- done-when 1: a Full Idea is legible end to end ---------------------
|
|
143
|
+
|
|
144
|
+
await test('a Full Idea shows every question and every answer, in template order', async () => {
|
|
145
|
+
const r = render(fullIdea);
|
|
146
|
+
await settle();
|
|
147
|
+
const html = r.html();
|
|
148
|
+
let cursor = -1;
|
|
149
|
+
for (const f of TEMPLATE.fields) {
|
|
150
|
+
const qAt = html.indexOf(f.label);
|
|
151
|
+
assert.ok(qAt > -1, `the question "${f.label}" is on the page`);
|
|
152
|
+
assert.ok(qAt > cursor, 'questions appear in TEMPLATE order, not jsonb key order');
|
|
153
|
+
cursor = qAt;
|
|
154
|
+
assert.ok(html.includes(FULL_VALUES[f.key]), `the answer for ${f.key} is on the page`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await test('the score is shown as a number and a bar', async () => {
|
|
159
|
+
const r = render(fullIdea);
|
|
160
|
+
await settle();
|
|
161
|
+
assert.match(r.html(), /88\/100 complete/);
|
|
162
|
+
assert.match(r.html(), /width:88%/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await test('a Full Idea with no score says so instead of drawing a zero', async () => {
|
|
166
|
+
const r = render({ ...fullIdea, completeness_score: null });
|
|
167
|
+
await settle();
|
|
168
|
+
assert.match(r.html(), /Not scored/);
|
|
169
|
+
assert.doesNotMatch(r.html(), /0\/100/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await test('a blank answer on a Full Idea is marked, not hidden', async () => {
|
|
173
|
+
const { fit, ...four } = FULL_VALUES;
|
|
174
|
+
const r = render({ ...fullIdea, field_values: four });
|
|
175
|
+
await settle();
|
|
176
|
+
// It is information: it is WHY the score is what it is.
|
|
177
|
+
assert.ok(r.html().includes('Not answered'));
|
|
178
|
+
const label = TEMPLATE.fields.find((f) => f.key === 'fit').label;
|
|
179
|
+
assert.ok(r.html().includes(label), 'the unanswered question is still shown');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// --- done-when 2: a quick idea gets NO five-field skeleton --------------
|
|
183
|
+
|
|
184
|
+
await test('a QUICK idea shows its body and NOT one of the five questions', async () => {
|
|
185
|
+
const r = render({
|
|
186
|
+
id: '5', title: 'a quick thought', status: 'open', body_md: 'just the thought',
|
|
187
|
+
captured_by: '9', captured_at: '2026-08-01T00:00:00Z', grade: 'quick',
|
|
188
|
+
template_key: null, field_values: null, completeness_score: null,
|
|
189
|
+
filed_by_login: 'thinker', developed_by: null, developed_at: null, developed_by_login: null,
|
|
190
|
+
});
|
|
191
|
+
await settle();
|
|
192
|
+
const html = r.html();
|
|
193
|
+
assert.ok(html.includes('just the thought'));
|
|
194
|
+
for (const f of TEMPLATE.fields) {
|
|
195
|
+
assert.ok(!html.includes(f.label), `a quick idea must not show the question "${f.label}"`);
|
|
196
|
+
}
|
|
197
|
+
assert.doesNotMatch(html, /Not answered/, 'and no empty answer rows either');
|
|
198
|
+
assert.doesNotMatch(html, /\/100 complete/, 'nor a completeness bar it has no score for');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await test('a quick idea costs ONE request — the labels are only needed for a full one', async () => {
|
|
202
|
+
const r = render({ id: '5', title: 't', status: 'open', body_md: 'b', grade: 'quick', captured_by: '9' });
|
|
203
|
+
await settle();
|
|
204
|
+
// Asserted as "no TEMPLATE call", not "exactly one call": R154 added a /me read
|
|
205
|
+
// for the author-only mark control, which is a legitimate second request. The
|
|
206
|
+
// invariant was always about the template — the expensive one a quick idea has
|
|
207
|
+
// no use for — so it is asserted directly rather than via a total count.
|
|
208
|
+
const kinds = r.calls.map((c) => c[0]);
|
|
209
|
+
assert.ok(!kinds.includes('template'), 'no template fetch for a quick idea');
|
|
210
|
+
assert.ok(kinds.includes('idea'), 'the idea itself is read');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await test('a SPARK shows its body, no skeleton, and says it is open for development', async () => {
|
|
214
|
+
const r = render({
|
|
215
|
+
id: '7', title: 'a spark', status: 'open', body_md: 'the seed', grade: 'spark',
|
|
216
|
+
captured_by: '9', captured_at: '2026-08-01T00:00:00Z', filed_by_login: 'thinker',
|
|
217
|
+
});
|
|
218
|
+
await settle();
|
|
219
|
+
const html = r.html();
|
|
220
|
+
assert.ok(html.includes('the seed'));
|
|
221
|
+
assert.match(html, /Big Idea Spark/);
|
|
222
|
+
assert.match(html, /needing development/);
|
|
223
|
+
for (const f of TEMPLATE.fields) assert.ok(!html.includes(f.label), 'a spark has no five-field skeleton either');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// --- done-when 3: both attributions visible ----------------------------
|
|
227
|
+
|
|
228
|
+
await test('a developed idea names TWO people, in their two roles', async () => {
|
|
229
|
+
const r = render(fullIdea);
|
|
230
|
+
await settle();
|
|
231
|
+
const html = r.html();
|
|
232
|
+
assert.match(html, /@thinker/);
|
|
233
|
+
assert.match(html, /had the thought/);
|
|
234
|
+
assert.match(html, /@someone/);
|
|
235
|
+
assert.match(html, /developed it into a Full Idea/);
|
|
236
|
+
// The author must come first: the thought precedes the development, and R146
|
|
237
|
+
// exists so being developed does not displace the person who had it.
|
|
238
|
+
assert.ok(html.indexOf('@thinker') < html.indexOf('@someone'));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await test('an UNdeveloped idea names one person and invents no developer', async () => {
|
|
242
|
+
const r = render({ ...fullIdea, developed_by: null, developed_at: null, developed_by_login: null });
|
|
243
|
+
await settle();
|
|
244
|
+
const html = r.html();
|
|
245
|
+
assert.match(html, /had the thought/);
|
|
246
|
+
assert.doesNotMatch(html, /developed it/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await test('self-development reads as one person doing both, not a handoff', async () => {
|
|
250
|
+
const r = render({ ...fullIdea, developed_by: '9', developed_by_login: 'thinker' });
|
|
251
|
+
await settle();
|
|
252
|
+
assert.match(r.html(), /developed it themselves/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await test('a missing login degrades to the builder id, never "@undefined"', async () => {
|
|
256
|
+
const r = render({ ...fullIdea, filed_by_login: null, developed_by_login: null });
|
|
257
|
+
await settle();
|
|
258
|
+
const html = r.html();
|
|
259
|
+
assert.match(html, /builder 9/);
|
|
260
|
+
assert.match(html, /builder 42/);
|
|
261
|
+
assert.doesNotMatch(html, /@undefined|@null|undefined/);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// --- grade badge + plumbing --------------------------------------------
|
|
265
|
+
|
|
266
|
+
await test('the grade is named in WORDS, not only in colour', async () => {
|
|
267
|
+
for (const [grade, word] of [['full', 'Full Idea'], ['spark', 'Big Idea Spark'], ['quick', 'Quick idea']]) {
|
|
268
|
+
const r = render({ ...fullIdea, grade, ...(grade === 'full' ? {} : { field_values: null }) });
|
|
269
|
+
await settle();
|
|
270
|
+
assert.ok(r.html().includes(word), `${grade} is spelled "${word}"`);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await test('an unknown grade falls back to quick rather than rendering nothing', async () => {
|
|
275
|
+
const r = render({ id: '1', title: 't', body_md: 'b', grade: 'something-new', captured_by: '9' });
|
|
276
|
+
await settle();
|
|
277
|
+
assert.match(r.html(), /Quick idea/);
|
|
278
|
+
assert.ok(r.html().includes('b'));
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await test('a template outage still renders the answers, without inventing an order', async () => {
|
|
282
|
+
const r = render(fullIdea, { templateFails: true });
|
|
283
|
+
await settle();
|
|
284
|
+
const html = r.html();
|
|
285
|
+
assert.match(html, /Could not load the question labels/);
|
|
286
|
+
for (const v of Object.values(FULL_VALUES)) assert.ok(html.includes(v), 'the answers are still readable');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
await test('a promoted idea links to the task it became', async () => {
|
|
290
|
+
const r = render({ ...fullIdea, status: 'promoted', promoted_to_task_id: '1234' });
|
|
291
|
+
await settle();
|
|
292
|
+
assert.match(r.html(), /\/task\/1234/);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await test('a 404 says which idea is missing rather than failing blank', async () => {
|
|
296
|
+
const r = render({ id: '99' }, { ideaStatus: 404 });
|
|
297
|
+
await settle();
|
|
298
|
+
assert.match(r.html(), /No idea 99/);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// --- R154: the mark control is AUTHOR-ONLY ------------------------------
|
|
302
|
+
|
|
303
|
+
const AUTHOR = { id: '9', github_login: 'thinker' };
|
|
304
|
+
const OTHER = { id: '77', github_login: 'someone-else' };
|
|
305
|
+
const quick = {
|
|
306
|
+
id: '5', title: 'a quick thought', status: 'open', body_md: 'just the thought',
|
|
307
|
+
captured_by: '9', captured_at: '2026-08-01T00:00:00Z', grade: 'quick',
|
|
308
|
+
filed_by_login: 'thinker',
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
await test('the AUTHOR of an open quick idea is offered the mark', async () => {
|
|
312
|
+
const r = render(quick, { viewer: AUTHOR });
|
|
313
|
+
await settle();
|
|
314
|
+
assert.match(r.html(), /Mark as a Big Idea Spark/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await test('a NON-author is not — the wall is the route, this is the cosmetic half', async () => {
|
|
318
|
+
const r = render(quick, { viewer: OTHER });
|
|
319
|
+
await settle();
|
|
320
|
+
assert.doesNotMatch(r.html(), /Mark as a Big Idea Spark/);
|
|
321
|
+
assert.doesNotMatch(r.html(), /Withdraw the spark/);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await test('a signed-out or unknown viewer is offered nothing', async () => {
|
|
325
|
+
const r = render(quick, { viewer: null });
|
|
326
|
+
await settle();
|
|
327
|
+
assert.doesNotMatch(r.html(), /Mark as a Big Idea Spark/);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await test('an author looking at their own SPARK is offered the withdrawal', async () => {
|
|
331
|
+
const r = render({ ...quick, grade: 'spark' }, { viewer: AUTHOR });
|
|
332
|
+
await settle();
|
|
333
|
+
assert.match(r.html(), /Withdraw the spark mark/);
|
|
334
|
+
assert.doesNotMatch(r.html(), /Mark as a Big Idea Spark/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await test('no control on a FULL idea — R144 refuses it, so offering it teaches the rule wrong', async () => {
|
|
338
|
+
const r = render({ ...fullIdea, captured_by: '9' }, { viewer: AUTHOR });
|
|
339
|
+
await settle();
|
|
340
|
+
assert.doesNotMatch(r.html(), /Big Idea Spark<\/button>|Mark as a Big Idea Spark/);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await test('no control on a CLOSED idea — marking requires status=open', async () => {
|
|
344
|
+
for (const status of ['promoted', 'discarded', 'merged']) {
|
|
345
|
+
const r = render({ ...quick, status }, { viewer: AUTHOR });
|
|
346
|
+
await settle();
|
|
347
|
+
assert.doesNotMatch(r.html(), /Mark as a Big Idea Spark/, `status=${status}`);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await test('marking calls the spark route and re-renders from the SERVER response', async () => {
|
|
352
|
+
const r = render(quick, { viewer: AUTHOR });
|
|
353
|
+
await settle();
|
|
354
|
+
await r.clickMark();
|
|
355
|
+
await settle();
|
|
356
|
+
assert.ok(r.calls.some((c) => c[0] === 'spark'), 'POST /inbox/:id/spark was called');
|
|
357
|
+
// Re-rendered from what came back, not from a locally flipped flag.
|
|
358
|
+
assert.match(r.html(), /Big Idea Spark/);
|
|
359
|
+
assert.match(r.html(), /Withdraw the spark mark/);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await test('withdrawing calls the DELETE side', async () => {
|
|
363
|
+
const r = render({ ...quick, grade: 'spark' }, { viewer: AUTHOR });
|
|
364
|
+
await settle();
|
|
365
|
+
await r.clickMark();
|
|
366
|
+
await settle();
|
|
367
|
+
assert.ok(r.calls.some((c) => c[0] === 'unspark'));
|
|
368
|
+
assert.match(r.html(), /Quick idea/);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
await test('a refusal shows the server\'s reason, in the author\'s terms', async () => {
|
|
372
|
+
const { envelope } = require('../src/bongos/api-errors.js');
|
|
373
|
+
const cases = [
|
|
374
|
+
['not_your_idea', /Only the builder who filed this idea/],
|
|
375
|
+
['not_a_quick_idea', /already a Full Idea/],
|
|
376
|
+
['idea_not_open', /no longer open/],
|
|
377
|
+
];
|
|
378
|
+
for (const [code, expect] of cases) {
|
|
379
|
+
const r = render(quick, { viewer: AUTHOR, sparkResult: { status: 409, ok: false, data: envelope(code) } });
|
|
380
|
+
await settle();
|
|
381
|
+
await r.clickMark();
|
|
382
|
+
await settle();
|
|
383
|
+
assert.match(r.documentObj.getElementById('ig-mark-err').textContent, expect, code);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await test('a failed mark re-enables the button rather than dead-ending', async () => {
|
|
388
|
+
const { envelope } = require('../src/bongos/api-errors.js');
|
|
389
|
+
const r = render(quick, { viewer: AUTHOR, sparkResult: { status: 500, ok: false, data: envelope('boom') } });
|
|
390
|
+
await settle();
|
|
391
|
+
await r.clickMark();
|
|
392
|
+
await settle();
|
|
393
|
+
assert.equal(r.markBtn().disabled, false);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
console.log(`\nidea_detail_page: ${passed} passed, ${failed} failed`);
|
|
397
|
+
if (failed > 0) process.exit(1);
|
|
@@ -242,5 +242,56 @@ await test('neither read touches the database', async () => {
|
|
|
242
242
|
assert.equal(touched, 0, 'both are pure — which is what makes a call per keystroke reasonable');
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
+
// --- R154: the spark queue + develop mode, as source invariants ----------
|
|
246
|
+
//
|
|
247
|
+
// The composer's runtime is exercised through the page's own DOM in
|
|
248
|
+
// tests/idea_spark_hall.mjs; these are the invariants that are cheapest and most
|
|
249
|
+
// durable to assert over the source, because each one is a rule about WHICH
|
|
250
|
+
// endpoint gets called and a mistake in it silently does the wrong write.
|
|
251
|
+
|
|
252
|
+
await test('develop mode files through the DEVELOP route, not a new capture', () => {
|
|
253
|
+
assert.match(IDEAS_JS, /postInboxIdDevelop/, 'developing calls POST /inbox/:id/develop');
|
|
254
|
+
assert.match(IDEAS_JS, /postInbox\b/, 'and filing a new idea still calls POST /inbox');
|
|
255
|
+
// The fork must be on the develop id, not on anything guessable from the form.
|
|
256
|
+
assert.match(IDEAS_JS, /DEVELOP_ID\s*\n?\s*\?\s*await api\.inbox\.postInboxIdDevelop/, 'the fork keys off the develop id');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await test('a develop POST carries no title and no grade', () => {
|
|
260
|
+
// The row already has both, and R146 preserves them along with its author —
|
|
261
|
+
// sending either would be this surface asserting authority over someone else's
|
|
262
|
+
// idea. Scoped to the develop branch so the new-capture body is unaffected.
|
|
263
|
+
const from = IDEAS_JS.indexOf('postInboxIdDevelop');
|
|
264
|
+
const body = IDEAS_JS.slice(from, IDEAS_JS.indexOf('})', from));
|
|
265
|
+
assert.ok(!/title:/.test(body), 'no title in the develop body');
|
|
266
|
+
assert.ok(!/grade:/.test(body), 'no grade in the develop body');
|
|
267
|
+
assert.match(body, /field_values/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await test('the develop draft is keyed per idea, so it cannot collide with a new one', () => {
|
|
271
|
+
// Sharing one key would let a half-written new idea overwrite development work.
|
|
272
|
+
assert.match(IDEAS_JS, /develop:\$\{DEVELOP_ID\}/);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await test('the queue is read from the R145 endpoint, not rebuilt from the inbox', () => {
|
|
276
|
+
assert.match(IDEAS_JS, /getInboxSparks/);
|
|
277
|
+
// And it links each row at the R153 page rather than the overlay.
|
|
278
|
+
assert.match(IDEAS_JS, /\/idea\/\$\{encodeURIComponent\(sp\.id\)\}/);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await test('the develop block ships HIDDEN — plain mode must not flash it', () => {
|
|
282
|
+
// The runtime test cannot see this (its stub DOM does not parse markup), so the
|
|
283
|
+
// attribute is pinned here, over the HTML itself.
|
|
284
|
+
assert.match(IDEAS_HTML, /id="fi-develop"[^>]*\shidden/, '#fi-develop carries the hidden attribute');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await test('the queue never blocks the composer', () => {
|
|
288
|
+
// The composer is the page's job; the queue is context. An outage in the
|
|
289
|
+
// secondary read must not take the primary surface down with it.
|
|
290
|
+
const from = IDEAS_JS.indexOf('async function loadQueue');
|
|
291
|
+
const fn = IDEAS_JS.slice(from, IDEAS_JS.indexOf('\n }', from));
|
|
292
|
+
assert.match(fn, /if \(!r\.ok/, 'it handles its own failure');
|
|
293
|
+
assert.ok(!/throw/.test(fn), 'and never throws into boot');
|
|
294
|
+
});
|
|
295
|
+
|
|
245
296
|
console.log(`\nidea_hall_composer: ${passed} passed, ${failed} failed`);
|
|
246
297
|
if (failed > 0) process.exit(1);
|