@bongos/core 1.19.298 → 1.19.300

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,271 @@
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 } = {}) {
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; }, addEventListener() {},
50
+ querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
51
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
52
+ });
53
+
54
+ const documentObj = {
55
+ title: '',
56
+ body: { dataset: { page: 'idea' } },
57
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
58
+ getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
59
+ querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
60
+ querySelectorAll() { return []; },
61
+ createElement() { return makeEl(); },
62
+ addEventListener() {},
63
+ };
64
+
65
+ const calls = [];
66
+ const client = {
67
+ inbox: {
68
+ getInboxId: async (args) => { calls.push(['idea', args]); return { status: ideaStatus, ok: ideaStatus === 200, data: { idea } }; },
69
+ getInboxTemplate: async (args) => {
70
+ calls.push(['template', args]);
71
+ if (templateFails) return { status: 500, ok: false, data: null };
72
+ return { status: 200, ok: true, data: { template } };
73
+ },
74
+ },
75
+ };
76
+
77
+ const windowObj = {
78
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
79
+ BongosClient: { createClient: () => client },
80
+ OTB: {
81
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
82
+ $: (sel) => documentObj.querySelector(sel),
83
+ fmtDate: (iso) => String(iso).slice(0, 10),
84
+ toast() {},
85
+ },
86
+ location: { hostname: 'builders.cloudbongos.com', pathname: `/idea/${idea && idea.id ? idea.id : '1'}`, assign() {} },
87
+ addEventListener() {},
88
+ };
89
+
90
+ const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout };
91
+ sandbox.globalThis = sandbox;
92
+ vm.createContext(sandbox);
93
+ vm.runInContext(SRC, sandbox, { filename: 'idea.js' });
94
+
95
+ return { html: () => documentObj.getElementById('idea-body').innerHTML, calls, documentObj };
96
+ }
97
+
98
+ // The render is async (two awaits at most), so let the microtask queue drain.
99
+ const settle = () => new Promise((r) => setTimeout(r, 0));
100
+
101
+ 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.`]));
102
+
103
+ const fullIdea = {
104
+ id: '812', title: 'A shared changelog', status: 'open', kind: 'feature',
105
+ body_md: 'the original quick thought', captured_by: '9', captured_at: '2026-08-01T00:00:00Z',
106
+ grade: 'full', template_key: 'base-v1', field_values: FULL_VALUES, completeness_score: 88,
107
+ filed_by_login: 'thinker', developed_by: '42', developed_at: '2026-08-20T00:00:00Z', developed_by_login: 'someone',
108
+ };
109
+
110
+ let passed = 0;
111
+ let failed = 0;
112
+ async function test(name, fn) {
113
+ try { await fn(); passed++; console.log(` ok ${name}`); }
114
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
115
+ }
116
+
117
+ // --- done-when 1: a Full Idea is legible end to end ---------------------
118
+
119
+ await test('a Full Idea shows every question and every answer, in template order', async () => {
120
+ const r = render(fullIdea);
121
+ await settle();
122
+ const html = r.html();
123
+ let cursor = -1;
124
+ for (const f of TEMPLATE.fields) {
125
+ const qAt = html.indexOf(f.label);
126
+ assert.ok(qAt > -1, `the question "${f.label}" is on the page`);
127
+ assert.ok(qAt > cursor, 'questions appear in TEMPLATE order, not jsonb key order');
128
+ cursor = qAt;
129
+ assert.ok(html.includes(FULL_VALUES[f.key]), `the answer for ${f.key} is on the page`);
130
+ }
131
+ });
132
+
133
+ await test('the score is shown as a number and a bar', async () => {
134
+ const r = render(fullIdea);
135
+ await settle();
136
+ assert.match(r.html(), /88\/100 complete/);
137
+ assert.match(r.html(), /width:88%/);
138
+ });
139
+
140
+ await test('a Full Idea with no score says so instead of drawing a zero', async () => {
141
+ const r = render({ ...fullIdea, completeness_score: null });
142
+ await settle();
143
+ assert.match(r.html(), /Not scored/);
144
+ assert.doesNotMatch(r.html(), /0\/100/);
145
+ });
146
+
147
+ await test('a blank answer on a Full Idea is marked, not hidden', async () => {
148
+ const { fit, ...four } = FULL_VALUES;
149
+ const r = render({ ...fullIdea, field_values: four });
150
+ await settle();
151
+ // It is information: it is WHY the score is what it is.
152
+ assert.ok(r.html().includes('Not answered'));
153
+ const label = TEMPLATE.fields.find((f) => f.key === 'fit').label;
154
+ assert.ok(r.html().includes(label), 'the unanswered question is still shown');
155
+ });
156
+
157
+ // --- done-when 2: a quick idea gets NO five-field skeleton --------------
158
+
159
+ await test('a QUICK idea shows its body and NOT one of the five questions', async () => {
160
+ const r = render({
161
+ id: '5', title: 'a quick thought', status: 'open', body_md: 'just the thought',
162
+ captured_by: '9', captured_at: '2026-08-01T00:00:00Z', grade: 'quick',
163
+ template_key: null, field_values: null, completeness_score: null,
164
+ filed_by_login: 'thinker', developed_by: null, developed_at: null, developed_by_login: null,
165
+ });
166
+ await settle();
167
+ const html = r.html();
168
+ assert.ok(html.includes('just the thought'));
169
+ for (const f of TEMPLATE.fields) {
170
+ assert.ok(!html.includes(f.label), `a quick idea must not show the question "${f.label}"`);
171
+ }
172
+ assert.doesNotMatch(html, /Not answered/, 'and no empty answer rows either');
173
+ assert.doesNotMatch(html, /\/100 complete/, 'nor a completeness bar it has no score for');
174
+ });
175
+
176
+ await test('a quick idea costs ONE request — the labels are only needed for a full one', async () => {
177
+ const r = render({ id: '5', title: 't', status: 'open', body_md: 'b', grade: 'quick', captured_by: '9' });
178
+ await settle();
179
+ assert.deepEqual(r.calls.map((c) => c[0]), ['idea'], 'no template fetch for a quick idea');
180
+ });
181
+
182
+ await test('a SPARK shows its body, no skeleton, and says it is open for development', async () => {
183
+ const r = render({
184
+ id: '7', title: 'a spark', status: 'open', body_md: 'the seed', grade: 'spark',
185
+ captured_by: '9', captured_at: '2026-08-01T00:00:00Z', filed_by_login: 'thinker',
186
+ });
187
+ await settle();
188
+ const html = r.html();
189
+ assert.ok(html.includes('the seed'));
190
+ assert.match(html, /Big Idea Spark/);
191
+ assert.match(html, /needing development/);
192
+ for (const f of TEMPLATE.fields) assert.ok(!html.includes(f.label), 'a spark has no five-field skeleton either');
193
+ });
194
+
195
+ // --- done-when 3: both attributions visible ----------------------------
196
+
197
+ await test('a developed idea names TWO people, in their two roles', async () => {
198
+ const r = render(fullIdea);
199
+ await settle();
200
+ const html = r.html();
201
+ assert.match(html, /@thinker/);
202
+ assert.match(html, /had the thought/);
203
+ assert.match(html, /@someone/);
204
+ assert.match(html, /developed it into a Full Idea/);
205
+ // The author must come first: the thought precedes the development, and R146
206
+ // exists so being developed does not displace the person who had it.
207
+ assert.ok(html.indexOf('@thinker') < html.indexOf('@someone'));
208
+ });
209
+
210
+ await test('an UNdeveloped idea names one person and invents no developer', async () => {
211
+ const r = render({ ...fullIdea, developed_by: null, developed_at: null, developed_by_login: null });
212
+ await settle();
213
+ const html = r.html();
214
+ assert.match(html, /had the thought/);
215
+ assert.doesNotMatch(html, /developed it/);
216
+ });
217
+
218
+ await test('self-development reads as one person doing both, not a handoff', async () => {
219
+ const r = render({ ...fullIdea, developed_by: '9', developed_by_login: 'thinker' });
220
+ await settle();
221
+ assert.match(r.html(), /developed it themselves/);
222
+ });
223
+
224
+ await test('a missing login degrades to the builder id, never "@undefined"', async () => {
225
+ const r = render({ ...fullIdea, filed_by_login: null, developed_by_login: null });
226
+ await settle();
227
+ const html = r.html();
228
+ assert.match(html, /builder 9/);
229
+ assert.match(html, /builder 42/);
230
+ assert.doesNotMatch(html, /@undefined|@null|undefined/);
231
+ });
232
+
233
+ // --- grade badge + plumbing --------------------------------------------
234
+
235
+ await test('the grade is named in WORDS, not only in colour', async () => {
236
+ for (const [grade, word] of [['full', 'Full Idea'], ['spark', 'Big Idea Spark'], ['quick', 'Quick idea']]) {
237
+ const r = render({ ...fullIdea, grade, ...(grade === 'full' ? {} : { field_values: null }) });
238
+ await settle();
239
+ assert.ok(r.html().includes(word), `${grade} is spelled "${word}"`);
240
+ }
241
+ });
242
+
243
+ await test('an unknown grade falls back to quick rather than rendering nothing', async () => {
244
+ const r = render({ id: '1', title: 't', body_md: 'b', grade: 'something-new', captured_by: '9' });
245
+ await settle();
246
+ assert.match(r.html(), /Quick idea/);
247
+ assert.ok(r.html().includes('b'));
248
+ });
249
+
250
+ await test('a template outage still renders the answers, without inventing an order', async () => {
251
+ const r = render(fullIdea, { templateFails: true });
252
+ await settle();
253
+ const html = r.html();
254
+ assert.match(html, /Could not load the question labels/);
255
+ for (const v of Object.values(FULL_VALUES)) assert.ok(html.includes(v), 'the answers are still readable');
256
+ });
257
+
258
+ await test('a promoted idea links to the task it became', async () => {
259
+ const r = render({ ...fullIdea, status: 'promoted', promoted_to_task_id: '1234' });
260
+ await settle();
261
+ assert.match(r.html(), /\/task\/1234/);
262
+ });
263
+
264
+ await test('a 404 says which idea is missing rather than failing blank', async () => {
265
+ const r = render({ id: '99' }, { ideaStatus: 404 });
266
+ await settle();
267
+ assert.match(r.html(), /No idea 99/);
268
+ });
269
+
270
+ console.log(`\nidea_detail_page: ${passed} passed, ${failed} failed`);
271
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,246 @@
1
+ // tests/idea_hall_composer.mjs
2
+ //
3
+ // task 1002928 (BV1.R152, goal 1000062) — the hall's Full Idea composer.
4
+ //
5
+ // The done-when this file exists to make mechanical: "the meter reflects the REAL
6
+ // scorer, not a second copy of the rules". The cheap way to violate that is not a
7
+ // re-implemented formula — it is five hand-written <textarea> elements in the
8
+ // page, which fork the R141 template one step before any scoring happens. So the
9
+ // central assertion here is a NEGATIVE one over the page source: no field key and
10
+ // no field label may appear in it, at all. If someone hard-codes the form, this
11
+ // fails, and the failure names the field they hard-coded.
12
+ //
13
+ // Also covered: the two routes the composer is built from (template projection,
14
+ // score delegation), and the declaration-order trap that would 400 the template
15
+ // read (`GET /inbox/:id` capturing the literal "template" — the third time this
16
+ // shape has come up in this goal, after /inbox/sparks and /inbox/awaiting-nod).
17
+ //
18
+ // Route halves are DB-free the idea_full_capture.mjs way; the page halves are a
19
+ // pure file read.
20
+ //
21
+ // Run: node tests/idea_hall_composer.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { createRequire } from 'node:module';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
32
+
33
+ const api = require('../src/module-api.js');
34
+ api.pool.query = async () => ({ rows: [] });
35
+
36
+ const { BASE_TEMPLATE, fieldOrder } = require('../modules/ideas/templates.js');
37
+ const { scoreFieldValues } = require('../modules/ideas/completeness.js');
38
+ const routesFactory = require('../modules/ideas/routes/inbox.js');
39
+ const router = routesFactory({});
40
+
41
+ const mounted = router.stack.filter((l) => l.route)
42
+ .map((l) => `${Object.keys(l.route.methods)[0].toUpperCase()} ${l.route.path}`);
43
+
44
+ function handlerFor(pathStr, method) {
45
+ const layer = router.stack.find((l) => l.route && l.route.path === pathStr && l.route.methods[method]);
46
+ assert.ok(layer, `route ${method.toUpperCase()} ${pathStr} is not mounted`);
47
+ const stack = layer.route.stack.map((s) => s.handle);
48
+ return stack[stack.length - 1];
49
+ }
50
+
51
+ function call(handler, { query = {}, body = {} } = {}) {
52
+ return new Promise((resolve, reject) => {
53
+ const req = { params: {}, query, body, builder: { id: '9', rank: 'xenos' } };
54
+ const res = {
55
+ statusCode: 200,
56
+ status(c) { this.statusCode = c; return this; },
57
+ json(payload) { resolve({ status: this.statusCode, payload }); },
58
+ fail(code, status, extra) {
59
+ const s = typeof status === 'number' ? status : (status && status.status) || 500;
60
+ resolve({ status: s, payload: { error: code, details: extra } });
61
+ },
62
+ };
63
+ Promise.resolve(handler(req, res, (e) => reject(e || new Error('next()')))).catch(reject);
64
+ });
65
+ }
66
+
67
+ const getTemplate = handlerFor('/inbox/template', 'get');
68
+ const postScore = handlerFor('/inbox/score', 'post');
69
+
70
+ const IDEAS_JS = fs.readFileSync(path.join(HALL, 'ideas.js'), 'utf8');
71
+ const IDEAS_HTML = fs.readFileSync(path.join(HALL, 'ideas.html'), 'utf8');
72
+
73
+ let passed = 0;
74
+ let failed = 0;
75
+ async function test(name, fn) {
76
+ try { await fn(); passed++; console.log(` ok ${name}`); }
77
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
78
+ }
79
+
80
+ // --- THE done-when: the page carries no copy of the rules ----------------
81
+
82
+ await test('the PAGE names no field key — a hard-coded form is a forked template', () => {
83
+ for (const f of BASE_TEMPLATE.fields) {
84
+ assert.ok(!IDEAS_JS.includes(f.key), `ideas.js hard-codes the field key "${f.key}"`);
85
+ assert.ok(!IDEAS_HTML.includes(f.key), `ideas.html hard-codes the field key "${f.key}"`);
86
+ }
87
+ });
88
+
89
+ await test('the PAGE names no field label either', () => {
90
+ for (const f of BASE_TEMPLATE.fields) {
91
+ assert.ok(!IDEAS_JS.includes(f.label), `ideas.js hard-codes the label "${f.label}"`);
92
+ assert.ok(!IDEAS_HTML.includes(f.label), `ideas.html hard-codes the label "${f.label}"`);
93
+ }
94
+ });
95
+
96
+ await test('the page does NOT restate the substance floor', () => {
97
+ // A floor has to be attached to a field to mean anything, and the two
98
+ // assertions above already forbid naming one — this pins the scorer's own
99
+ // vocabulary out of the page as well, so "substanceMin: 40" cannot creep in
100
+ // under a different field name.
101
+ // camelCase `substanceMin` is the SCORER's own internal property name — a
102
+ // re-implementation would carry it. The published snake_case `substance_min` is
103
+ // deliberately NOT forbidden: it is a field of the template response the page
104
+ // legitimately receives, and naming it in a comment that documents that shape
105
+ // is documentation, not a forked rule.
106
+ assert.ok(!IDEAS_JS.includes('substanceMin'), 'ideas.js restates the scorer\'s floor');
107
+ });
108
+
109
+ await test('the page gets both the FORM and the METER from the server', () => {
110
+ // The positive half: it is not rule-free because it renders nothing — it is
111
+ // rule-free because it asks. Both calls must actually be there.
112
+ assert.match(IDEAS_JS, /getInboxTemplate/, 'the form is rendered from GET /inbox/template');
113
+ assert.match(IDEAS_JS, /postInboxScore/, 'the meter is filled from POST /inbox/score');
114
+ assert.match(IDEAS_JS, /postInbox\b/, 'and it files through POST /inbox');
115
+ });
116
+
117
+ await test('the page does not print a pass bar — that rule lives in economy', () => {
118
+ // The ideas scorer is documented bar-AGNOSTIC; the pass bar sits with the credit
119
+ // it gates. A threshold printed here would be a second copy of a DIFFERENT rule.
120
+ const { IDEA_FULL_PASS_BAR } = require('../modules/economy/credits.js');
121
+ assert.ok(Number.isInteger(IDEA_FULL_PASS_BAR), 'the bar is a number in economy');
122
+ assert.ok(!new RegExp(`\\b${IDEA_FULL_PASS_BAR}\\b`).test(IDEAS_JS), 'ideas.js names the economy pass bar');
123
+ });
124
+
125
+ // --- drafts survive navigation ------------------------------------------
126
+
127
+ await test('every localStorage touch is guarded — storage that throws must not down the page', () => {
128
+ // A private window, cleared site data, or a browser set to block storage all
129
+ // THROW on access. Count the accesses and the guards rather than trusting a
130
+ // reading of the code.
131
+ const accesses = (IDEAS_JS.match(/localStorage\./g) || []).length;
132
+ assert.ok(accesses >= 3, 'the draft feature reads, writes and clears');
133
+ const tries = (IDEAS_JS.match(/try\s*\{/g) || []).length;
134
+ assert.ok(tries >= 3, `each localStorage access sits in its own try (found ${tries} for ${accesses} accesses)`);
135
+ });
136
+
137
+ await test('the draft is cleared only AFTER a successful file, never before', () => {
138
+ // A failed POST must not cost the text. Assert the ordering in the source: the
139
+ // clearDraft() call inside file() comes after the !r.ok early return.
140
+ const body = IDEAS_JS.slice(IDEAS_JS.indexOf('async function file('));
141
+ const bail = body.indexOf('if (!r.ok)');
142
+ const clear = body.indexOf('clearDraft()');
143
+ assert.ok(bail > 0 && clear > 0, 'both the failure bail and the clear are present');
144
+ assert.ok(clear > bail, 'clearDraft() must come after the failure path returns');
145
+ });
146
+
147
+ await test('a draft written against another template is dropped, not half-restored', () => {
148
+ assert.match(IDEAS_JS, /template_key/, 'the draft records which template it was written for');
149
+ });
150
+
151
+ // --- GET /inbox/template ------------------------------------------------
152
+
153
+ await test('GET /inbox/template is declared BEFORE GET /inbox/:id', () => {
154
+ const iT = mounted.indexOf('GET /inbox/template');
155
+ const iId = mounted.indexOf('GET /inbox/:id');
156
+ assert.ok(iT >= 0 && iId >= 0);
157
+ assert.ok(iT < iId, `with :id first, "/inbox/template" binds id="template" and parseId 400s it (template@${iT}, :id@${iId})`);
158
+ });
159
+
160
+ await test('the template read returns the R141 declaration, in fieldOrder order', async () => {
161
+ const r = await call(getTemplate);
162
+ assert.equal(r.status, 200);
163
+ const t = r.payload.template;
164
+ assert.equal(t.key, BASE_TEMPLATE.key);
165
+ assert.equal(t.label, BASE_TEMPLATE.label);
166
+ // fieldOrder returns KEYS, not field objects. Comparing two lists derived the
167
+ // same WRONG way proves nothing — the first run of this test passed while the
168
+ // endpoint was emitting five {key: undefined} descriptors, because both sides
169
+ // were [undefined × 5]. So assert against the declaration AND that the keys are
170
+ // real strings.
171
+ assert.deepEqual(t.fields.map((f) => f.key), fieldOrder(BASE_TEMPLATE),
172
+ 'display order comes from fieldOrder, never jsonb key order');
173
+ assert.ok(t.fields.length >= 1);
174
+ for (const f of t.fields) {
175
+ assert.equal(typeof f.key, 'string', 'a field descriptor carries a real key');
176
+ assert.ok(f.key.length > 0);
177
+ assert.equal(typeof f.label, 'string');
178
+ assert.ok(f.label.length > 0, 'and a real label — five unlabeled boxes is the bug this catches');
179
+ }
180
+ for (const f of t.fields) {
181
+ const src = BASE_TEMPLATE.fields.find((x) => x.key === f.key);
182
+ assert.equal(f.label, src.label);
183
+ assert.equal(f.required, !!src.required);
184
+ assert.equal(f.substance_min, src.substanceMin || 0, 'the floor is published so the form can say how much more is wanted');
185
+ }
186
+ });
187
+
188
+ await test('an unknown template is a 404, not an empty form', async () => {
189
+ const r = await call(getTemplate, { query: { template_key: 'no-such-template' } });
190
+ assert.equal(r.status, 404);
191
+ assert.equal(r.payload.error, 'unknown_template');
192
+ });
193
+
194
+ // --- POST /inbox/score --------------------------------------------------
195
+
196
+ await test('the score endpoint IS the scorer — identical output, not a reimplementation', async () => {
197
+ const cases = [
198
+ Object.fromEntries(BASE_TEMPLATE.fields.map((f) => [f.key, 'x'.repeat(80)])), // full
199
+ Object.fromEntries(BASE_TEMPLATE.fields.map((f) => [f.key, 'tiny'])), // thin
200
+ { [BASE_TEMPLATE.fields[0].key]: 'y'.repeat(200) }, // one field only
201
+ {}, // empty
202
+ ];
203
+ for (const field_values of cases) {
204
+ const r = await call(postScore, { body: { field_values } });
205
+ assert.equal(r.status, 200);
206
+ const direct = scoreFieldValues(field_values);
207
+ assert.equal(r.payload.score, direct.score, 'same score as calling the scorer directly');
208
+ assert.deepEqual(r.payload.fields, direct.fields, 'same per-field breakdown, same order');
209
+ }
210
+ });
211
+
212
+ await test('whitespace scores as the nothing it is, through the endpoint too', async () => {
213
+ const padded = Object.fromEntries(BASE_TEMPLATE.fields.map((f) => [f.key, ' \n\n\t ']));
214
+ const r = await call(postScore, { body: { field_values: padded } });
215
+ assert.equal(r.payload.score, 0);
216
+ });
217
+
218
+ await test('a half-written draft scores rather than 400ing', async () => {
219
+ // The composer scores on every keystroke, so most calls carry an incomplete set.
220
+ const r = await call(postScore, { body: { field_values: { [BASE_TEMPLATE.fields[0].key]: 'half a thought' } } });
221
+ assert.equal(r.status, 200);
222
+ assert.ok(Number.isInteger(r.payload.score));
223
+ });
224
+
225
+ await test('unscorable input is a null score, never an exception or a 400', async () => {
226
+ const r = await call(postScore, { body: { field_values: {}, template_key: 'no-such-template' } });
227
+ assert.equal(r.status, 200, 'R142 never throws into the caller; "cannot score" is a legal answer');
228
+ assert.equal(r.payload.score, null);
229
+ assert.deepEqual(r.payload.fields, []);
230
+ });
231
+
232
+ await test('field_values is required — scoring nothing is a client bug, not a 0', async () => {
233
+ const r = await call(postScore, { body: {} });
234
+ assert.equal(r.status, 400);
235
+ });
236
+
237
+ await test('neither read touches the database', async () => {
238
+ let touched = 0;
239
+ api.pool.query = async () => { touched += 1; return { rows: [] }; };
240
+ await call(getTemplate);
241
+ await call(postScore, { body: { field_values: { [BASE_TEMPLATE.fields[0].key]: 'a' } } });
242
+ assert.equal(touched, 0, 'both are pure — which is what makes a call per keystroke reasonable');
243
+ });
244
+
245
+ console.log(`\nidea_hall_composer: ${passed} passed, ${failed} failed`);
246
+ if (failed > 0) process.exit(1);