@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.
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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);