@bongos/core 1.19.300 → 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 +25 -20
- 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/src/module-api.js +3 -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
|
@@ -26,6 +26,15 @@
|
|
|
26
26
|
// here would be a second copy of a DIFFERENT rule — the exact mistake this file
|
|
27
27
|
// is organised to avoid — so the gauge reports completeness and stops there.
|
|
28
28
|
//
|
|
29
|
+
// TWO MODES, ONE FORM (task 1002930 / R154). Opened plainly, the composer FILES a
|
|
30
|
+
// new Full Idea. Opened as ?develop=<id> from the spark queue below, it DEVELOPS
|
|
31
|
+
// an existing spark: the spark's own words are shown above the fields, its title
|
|
32
|
+
// becomes a read-only fact rather than an editable input (developing an idea must
|
|
33
|
+
// not quietly rename it out from under its author), and filing calls
|
|
34
|
+
// POST /inbox/:id/develop instead of POST /inbox. The five fields, the meter, the
|
|
35
|
+
// validation and the draft behaviour are identical — which is the point of reusing
|
|
36
|
+
// this surface rather than building a second one that drifts from it.
|
|
37
|
+
//
|
|
29
38
|
// DRAFTS ARE LOCAL. "A partial draft is not lost on navigation" is a done-when,
|
|
30
39
|
// and a half-written thought is not something to put in the database: it has no
|
|
31
40
|
// title yet, no grade, and nothing downstream should be able to see it. It lives
|
|
@@ -38,9 +47,17 @@
|
|
|
38
47
|
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
39
48
|
const { escapeHtml, $, toast } = window.OTB;
|
|
40
49
|
|
|
41
|
-
// One key
|
|
42
|
-
//
|
|
43
|
-
|
|
50
|
+
// One key per THING BEING WRITTEN. A new idea and a spark being developed are
|
|
51
|
+
// different drafts, and sharing one key would let a half-written new idea
|
|
52
|
+
// overwrite someone's development work (or restore itself into it). Versioned by
|
|
53
|
+
// template key at save time too, so a project that changes its template (R156)
|
|
54
|
+
// cannot restore a draft shaped for the old one.
|
|
55
|
+
const developId = () => {
|
|
56
|
+
const m = /[?&]develop=(\d+)\b/.exec(location.search);
|
|
57
|
+
return m ? m[1] : null;
|
|
58
|
+
};
|
|
59
|
+
const DEVELOP_ID = developId();
|
|
60
|
+
const DRAFT_KEY = DEVELOP_ID ? `hall-full-idea-draft:develop:${DEVELOP_ID}` : 'hall-full-idea-draft';
|
|
44
61
|
const SCORE_DEBOUNCE_MS = 300;
|
|
45
62
|
const DRAFT_DEBOUNCE_MS = 500;
|
|
46
63
|
|
|
@@ -187,7 +204,9 @@
|
|
|
187
204
|
// problem, not a capture error). Matching that exactly means the button never
|
|
188
205
|
// promises an outcome the server will refuse, and never blocks one it accepts.
|
|
189
206
|
const missingRequired = template.fields.some((f) => f.required && !fieldEl(f.key).value.trim());
|
|
190
|
-
|
|
207
|
+
// In develop mode the title is the existing idea's and is not editable, so it
|
|
208
|
+
// cannot be a reason to keep the button off.
|
|
209
|
+
$('#fi-submit').disabled = filing || missingRequired || (!DEVELOP_ID && !titleEl().value.trim());
|
|
191
210
|
}
|
|
192
211
|
|
|
193
212
|
async function scoreNow() {
|
|
@@ -209,7 +228,7 @@
|
|
|
209
228
|
// round-trip: it depends only on what is typed.
|
|
210
229
|
if (lastScore) renderScore(lastScore);
|
|
211
230
|
else $('#fi-submit').disabled = filing
|
|
212
|
-
|| !titleEl().value.trim()
|
|
231
|
+
|| (!DEVELOP_ID && !titleEl().value.trim())
|
|
213
232
|
|| template.fields.some((f) => f.required && !fieldEl(f.key).value.trim());
|
|
214
233
|
}
|
|
215
234
|
|
|
@@ -251,14 +270,22 @@
|
|
|
251
270
|
showErr('');
|
|
252
271
|
$('#fi-submit').disabled = true;
|
|
253
272
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
273
|
+
// The one behavioural fork between the two modes. Developing is an act on an
|
|
274
|
+
// EXISTING idea, so it carries no title and no grade: the row already has
|
|
275
|
+
// both, and R146 preserves them along with its author.
|
|
276
|
+
const r = DEVELOP_ID
|
|
277
|
+
? await api.inbox.postInboxIdDevelop({
|
|
278
|
+
id: DEVELOP_ID,
|
|
279
|
+
body: { field_values: collect(), template_key: template.key },
|
|
280
|
+
})
|
|
281
|
+
: await api.inbox.postInbox({
|
|
282
|
+
body: {
|
|
283
|
+
title: titleEl().value.trim(),
|
|
284
|
+
grade: 'full',
|
|
285
|
+
template_key: template.key,
|
|
286
|
+
field_values: collect(),
|
|
287
|
+
},
|
|
288
|
+
});
|
|
262
289
|
|
|
263
290
|
filing = false;
|
|
264
291
|
if (!r.ok) {
|
|
@@ -275,11 +302,19 @@
|
|
|
275
302
|
// grade write did not, and says so. Repeat that here instead of claiming a
|
|
276
303
|
// Full Idea was stored when the response says otherwise.
|
|
277
304
|
const landedFull = idea && idea.grade === 'full';
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
305
|
+
if (DEVELOP_ID) {
|
|
306
|
+
// The split is the developer's business: they earned a share of it and the
|
|
307
|
+
// author kept theirs, and saying so is how the handoff stops feeling like
|
|
308
|
+
// giving work away (R147 / ADR 0185).
|
|
309
|
+
const credited = r.data && r.data.idea && r.data.idea.ideator_credit;
|
|
310
|
+
toast(`Developed${score == null ? '' : ` — ${score}/100`}${credited ? `. ${credited} credits split with its author.` : '.'}`);
|
|
311
|
+
} else {
|
|
312
|
+
toast(landedFull
|
|
313
|
+
? `Filed as a Full Idea${score == null ? '' : ` — ${score}/100`}.`
|
|
314
|
+
: 'Filed, but stored as a quick idea — the grade could not be written.');
|
|
315
|
+
}
|
|
281
316
|
|
|
282
|
-
titleEl().value = '';
|
|
317
|
+
if (!DEVELOP_ID) titleEl().value = '';
|
|
283
318
|
for (const f of template.fields) fieldEl(f.key).value = '';
|
|
284
319
|
lastScore = null;
|
|
285
320
|
renderScore(null);
|
|
@@ -308,6 +343,79 @@
|
|
|
308
343
|
// boot
|
|
309
344
|
// ---------------------------------------------------------------------------
|
|
310
345
|
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// the spark queue (task 1002930 / R154)
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
|
|
351
|
+
|
|
352
|
+
function renderQueue(sparks, count) {
|
|
353
|
+
const host = $('#fi-queue');
|
|
354
|
+
if (!sparks || sparks.length === 0) {
|
|
355
|
+
// An answer, not a blank space — and it says how one gets here, since the
|
|
356
|
+
// only way to fill this queue is for an author to offer something up.
|
|
357
|
+
host.innerHTML = '<p class="fi-queue-empty">Nothing is waiting. A spark appears here when its author offers a quick idea up for development.</p>';
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const total = Number.isInteger(count) ? count : sparks.length;
|
|
361
|
+
const more = total > sparks.length ? `<p class="fi-note">Showing ${sparks.length} of ${total}.</p>` : '';
|
|
362
|
+
host.innerHTML = `<div class="fi-queue">${sparks.map((sp) => {
|
|
363
|
+
const who = sp.filed_by_login ? `@${escapeHtml(sp.filed_by_login)}` : 'an unnamed builder';
|
|
364
|
+
const when = sp.captured_at ? ` · ${escapeHtml(String(sp.captured_at).slice(0, 10))}` : '';
|
|
365
|
+
return `
|
|
366
|
+
<div class="fi-spark">
|
|
367
|
+
<p class="fi-spark__title"><a href="${hallBase()}/idea/${encodeURIComponent(sp.id)}">${escapeHtml(sp.title || `Idea ${sp.id}`)}</a></p>
|
|
368
|
+
<span class="fi-spark__who">had by ${who}${when}</span>
|
|
369
|
+
<a class="fi-spark__go btn btn--ghost" href="?develop=${encodeURIComponent(sp.id)}">Develop this →</a>
|
|
370
|
+
</div>`;
|
|
371
|
+
}).join('')}</div>${more}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function loadQueue() {
|
|
375
|
+
const r = await api.inbox.getInboxSparks({});
|
|
376
|
+
// The queue is secondary to the composer: an outage here must not stop
|
|
377
|
+
// someone writing, so it degrades to a line rather than an error state.
|
|
378
|
+
if (!r.ok || !r.data) {
|
|
379
|
+
$('#fi-queue').innerHTML = '<p class="fi-queue-empty">Could not load the queue just now.</p>';
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
renderQueue(r.data.sparks, r.data.count);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// develop mode
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
// Show the spark's own words above the fields, and make the title a FACT rather
|
|
390
|
+
// than an input. Returns false when the idea cannot be developed, so boot can
|
|
391
|
+
// say why instead of presenting a form that will 409.
|
|
392
|
+
function enterDevelopMode(idea) {
|
|
393
|
+
if (idea.grade !== 'spark') {
|
|
394
|
+
$('#fi-sub').textContent = idea.grade === 'full'
|
|
395
|
+
? `Idea ${idea.id} has already been developed into a Full Idea.`
|
|
396
|
+
: `Idea ${idea.id} is not a Big Idea Spark — only its author can offer it up for development.`;
|
|
397
|
+
$('#fi-form').hidden = true;
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
$('#fi-develop').hidden = false;
|
|
401
|
+
$('#fi-develop-title').textContent = idea.title || `Idea ${idea.id}`;
|
|
402
|
+
$('#fi-develop-body').textContent = String(idea.body_md || '').trim();
|
|
403
|
+
$('#fi-develop-who').textContent = idea.filed_by_login
|
|
404
|
+
? `had by @${idea.filed_by_login}${idea.captured_at ? ` · ${String(idea.captured_at).slice(0, 10)}` : ''} — they keep the credit for the thought`
|
|
405
|
+
: 'they keep the credit for the thought';
|
|
406
|
+
// The title is the existing idea's. Shown, not editable: developing an idea
|
|
407
|
+
// must not quietly rename it out from under its author.
|
|
408
|
+
const t = titleEl();
|
|
409
|
+
t.value = idea.title || '';
|
|
410
|
+
t.readOnly = true;
|
|
411
|
+
t.tabIndex = -1;
|
|
412
|
+
$('#fi-title-label').hidden = true;
|
|
413
|
+
t.hidden = true;
|
|
414
|
+
$('#fi-submit').textContent = 'Develop this idea';
|
|
415
|
+
$('#fi-sub').textContent = 'Answer the five questions and this spark becomes a Full Idea.';
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
|
|
311
419
|
async function main() {
|
|
312
420
|
const r = await api.inbox.getInboxTemplate({});
|
|
313
421
|
if (!r.ok || !r.data || !r.data.template) {
|
|
@@ -326,6 +434,22 @@
|
|
|
326
434
|
$('#fi-form').addEventListener('submit', file);
|
|
327
435
|
$('#fi-clear').addEventListener('click', discard);
|
|
328
436
|
|
|
437
|
+
// The queue loads on every visit, develop mode or not: having just picked one
|
|
438
|
+
// up, seeing what else is waiting is the natural next thing.
|
|
439
|
+
loadQueue();
|
|
440
|
+
|
|
441
|
+
if (DEVELOP_ID) {
|
|
442
|
+
const d = await api.inbox.getInboxId({ id: DEVELOP_ID });
|
|
443
|
+
if (!d.ok || !d.data || !d.data.idea) {
|
|
444
|
+
$('#fi-sub').textContent = `Could not load idea ${DEVELOP_ID}.`;
|
|
445
|
+
$('#fi-form').hidden = true;
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (!enterDevelopMode(d.data.idea)) return;
|
|
449
|
+
// Re-run the button rule now that the title requirement is lifted.
|
|
450
|
+
onEdit();
|
|
451
|
+
}
|
|
452
|
+
|
|
329
453
|
if (usable) {
|
|
330
454
|
note('Draft restored.');
|
|
331
455
|
scoreNow();
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.301",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.301",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.301",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -1027,8 +1027,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1027
1027
|
// landed since 1.19.298 with no explicit bump. run 32600322268. (task 1002620)
|
|
1028
1028
|
// 1.19.300 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1029
1029
|
// landed since 1.19.299 with no explicit bump. run 32601521458. (task 1002620)
|
|
1030
|
+
// 1.19.301 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1031
|
+
// landed since 1.19.300 with no explicit bump. run 32602146266. (task 1002620)
|
|
1030
1032
|
// ---------------------------------------------------------------------------
|
|
1031
|
-
const CORE_VERSION = '1.19.
|
|
1033
|
+
const CORE_VERSION = '1.19.301'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1032
1034
|
|
|
1033
1035
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1034
1036
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -41,12 +41,14 @@ const TEMPLATE = {
|
|
|
41
41
|
|
|
42
42
|
// Boot idea.js against a stub DOM + a stub client that answers with `idea`, and
|
|
43
43
|
// return the HTML it wrote into #idea-body.
|
|
44
|
-
function render(idea, { template = TEMPLATE, templateFails = false, ideaStatus = 200 } = {}) {
|
|
44
|
+
function render(idea, { template = TEMPLATE, templateFails = false, ideaStatus = 200, viewer = null, sparkResult = null } = {}) {
|
|
45
45
|
const els = new Map();
|
|
46
46
|
const makeEl = () => ({
|
|
47
47
|
dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
|
|
48
48
|
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
49
|
-
setAttribute() {}, getAttribute() { return null; },
|
|
49
|
+
setAttribute() {}, getAttribute() { return null; },
|
|
50
|
+
_handlers: {},
|
|
51
|
+
addEventListener(type, fn) { this._handlers[type] = fn; },
|
|
50
52
|
querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
|
|
51
53
|
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
52
54
|
});
|
|
@@ -71,7 +73,18 @@ function render(idea, { template = TEMPLATE, templateFails = false, ideaStatus =
|
|
|
71
73
|
if (templateFails) return { status: 500, ok: false, data: null };
|
|
72
74
|
return { status: 200, ok: true, data: { template } };
|
|
73
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
|
+
},
|
|
74
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 } }; } },
|
|
75
88
|
};
|
|
76
89
|
|
|
77
90
|
const windowObj = {
|
|
@@ -92,7 +105,19 @@ function render(idea, { template = TEMPLATE, templateFails = false, ideaStatus =
|
|
|
92
105
|
vm.createContext(sandbox);
|
|
93
106
|
vm.runInContext(SRC, sandbox, { filename: 'idea.js' });
|
|
94
107
|
|
|
95
|
-
return {
|
|
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
|
+
};
|
|
96
121
|
}
|
|
97
122
|
|
|
98
123
|
// The render is async (two awaits at most), so let the microtask queue drain.
|
|
@@ -176,7 +201,13 @@ await test('a QUICK idea shows its body and NOT one of the five questions', asyn
|
|
|
176
201
|
await test('a quick idea costs ONE request — the labels are only needed for a full one', async () => {
|
|
177
202
|
const r = render({ id: '5', title: 't', status: 'open', body_md: 'b', grade: 'quick', captured_by: '9' });
|
|
178
203
|
await settle();
|
|
179
|
-
|
|
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');
|
|
180
211
|
});
|
|
181
212
|
|
|
182
213
|
await test('a SPARK shows its body, no skeleton, and says it is open for development', async () => {
|
|
@@ -267,5 +298,100 @@ await test('a 404 says which idea is missing rather than failing blank', async (
|
|
|
267
298
|
assert.match(r.html(), /No idea 99/);
|
|
268
299
|
});
|
|
269
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
|
+
|
|
270
396
|
console.log(`\nidea_detail_page: ${passed} passed, ${failed} failed`);
|
|
271
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);
|