@bongos/core 1.19.297 → 1.19.299
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 +60 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0185-spark-handoff-credit-split.md +129 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +128 -4
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +28 -13
- package/docs/copy-registry.json +162 -17
- package/docs/file-map.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/economy/credits.js +156 -31
- package/modules/hall-ui/public/ideas.css +120 -0
- package/modules/hall-ui/public/ideas.html +95 -0
- package/modules/hall-ui/public/ideas.js +335 -0
- package/modules/hall-ui/public/shell.js +8 -0
- package/modules/ideas/routes/inbox.js +121 -12
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/serve-internal.js +6 -1
- package/src/module-api.js +5 -1
- package/tests/hall_nav.mjs +6 -1
- package/tests/idea_credit_split.mjs +224 -0
- package/tests/idea_develop_spark.mjs +47 -10
- package/tests/idea_full_pass_credit.mjs +50 -18
- package/tests/idea_hall_composer.mjs +246 -0
|
@@ -28,23 +28,35 @@ async function test(name, fn) {
|
|
|
28
28
|
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
// A stub client whose credit_log "table" is an in-memory set of
|
|
32
|
-
// INSERT ... WHERE NOT EXISTS
|
|
33
|
-
//
|
|
34
|
-
|
|
31
|
+
// A stub client whose credit_log "table" is an in-memory set of (builder, reason)
|
|
32
|
+
// pairs, so the INSERT ... WHERE NOT EXISTS behaves exactly like Postgres: a
|
|
33
|
+
// second insert for the same pair is a no-op (rowCount 0).
|
|
34
|
+
//
|
|
35
|
+
// R147 (task 1002923) reshaped the write: the function now first LOOKS UP the
|
|
36
|
+
// idea's parties (captured_by + developed_by) so it — not its caller — decides
|
|
37
|
+
// who earns, and the INSERT carries task_id as a parameter, moving `reason` from
|
|
38
|
+
// $3 to $4. `developedBy` here defaults to null, i.e. an idea nobody developed,
|
|
39
|
+
// which is the case every test below was written for; the split itself is
|
|
40
|
+
// covered in tests/idea_credit_split.mjs.
|
|
41
|
+
function stubClient({ authorId = null, developedBy = null } = {}) {
|
|
35
42
|
const rows = [];
|
|
36
43
|
return {
|
|
37
44
|
rows,
|
|
38
45
|
async query(sql, params) {
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
// The R147 parties lookup — who authored this idea, and who developed it.
|
|
47
|
+
if (/FROM idea_inbox i/i.test(sql) && /developed_by/i.test(sql)) {
|
|
48
|
+
return { rows: authorId == null ? [] : [{ captured_by: authorId, developed_by: developedBy }] };
|
|
49
|
+
}
|
|
50
|
+
// The idempotency subquery must still scope by builder_id AND reason. That
|
|
51
|
+
// was an index concern before (idx_credit_log_builder); under R147 it is
|
|
52
|
+
// also load-bearing for correctness, because a split books two rows under
|
|
53
|
+
// ONE reason and a reason-only predicate would refuse the second payee.
|
|
42
54
|
const builderId = params[0];
|
|
43
|
-
const reason = params[
|
|
44
|
-
assert.match(sql, /builder_id = \$1 AND reason = \$
|
|
55
|
+
const reason = params[3];
|
|
56
|
+
assert.match(sql, /builder_id = \$1 AND reason = \$4/, 'idempotency check must scope by builder_id AND reason');
|
|
45
57
|
const exists = rows.some((r) => r.builder_id === builderId && r.reason === reason);
|
|
46
58
|
if (exists) return { rowCount: 0, rows: [] };
|
|
47
|
-
rows.push({ builder_id: builderId, delta: params[
|
|
59
|
+
rows.push({ builder_id: builderId, delta: params[2], reason, description: params[4] });
|
|
48
60
|
return { rowCount: 1, rows: [] };
|
|
49
61
|
},
|
|
50
62
|
};
|
|
@@ -54,7 +66,7 @@ const PASS = 72; // clears the 60 bar
|
|
|
54
66
|
const THIN = 40; // below the bar
|
|
55
67
|
|
|
56
68
|
await test('a passing Full Idea books exactly 5 credits to its author', async () => {
|
|
57
|
-
const c = stubClient();
|
|
69
|
+
const c = stubClient({ authorId: 3 });
|
|
58
70
|
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 9, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } });
|
|
59
71
|
assert.equal(r.credited, credits.IDEA_FULL_PASS_CREDIT);
|
|
60
72
|
assert.equal(r.reason, 'paid');
|
|
@@ -65,7 +77,7 @@ await test('a passing Full Idea books exactly 5 credits to its author', async ()
|
|
|
65
77
|
});
|
|
66
78
|
|
|
67
79
|
await test('re-running pays nothing — idempotent per idea', async () => {
|
|
68
|
-
const c = stubClient();
|
|
80
|
+
const c = stubClient({ authorId: 3 });
|
|
69
81
|
const opts = { ideaId: 9, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } };
|
|
70
82
|
const first = await credits.awardIdeaFullPassCredit(c, opts);
|
|
71
83
|
const second = await credits.awardIdeaFullPassCredit(c, opts);
|
|
@@ -78,7 +90,7 @@ await test('re-running pays nothing — idempotent per idea', async () => {
|
|
|
78
90
|
});
|
|
79
91
|
|
|
80
92
|
await test('a below-bar or null score books 0 — scoring gates the pay, not capture', async () => {
|
|
81
|
-
const c = stubClient();
|
|
93
|
+
const c = stubClient({ authorId: 3 });
|
|
82
94
|
assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 1, authorId: 3, completenessScore: THIN, config: { ideatorCredit: true } })).reason, 'below_bar');
|
|
83
95
|
assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 2, authorId: 3, completenessScore: null, config: { ideatorCredit: true } })).reason, 'below_bar');
|
|
84
96
|
assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 3, authorId: 3, completenessScore: undefined, config: { ideatorCredit: true } })).reason, 'below_bar');
|
|
@@ -90,13 +102,13 @@ await test('COST-PLUS-ONLY does NOT suppress it: the ideator lane pays regardles
|
|
|
90
102
|
const policy = credits.rewardPolicy({ mode: 'cost-plus-only' });
|
|
91
103
|
assert.equal(policy.perTaskCredit, false);
|
|
92
104
|
assert.equal(policy.ideatorCredit, true);
|
|
93
|
-
const c = stubClient();
|
|
105
|
+
const c = stubClient({ authorId: 3 });
|
|
94
106
|
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 5, authorId: 3, completenessScore: PASS, config: policy });
|
|
95
107
|
assert.equal(r.credited, 5, 'paid despite cost-plus-only — this is the whole point of ADR 0172');
|
|
96
108
|
});
|
|
97
109
|
|
|
98
110
|
await test('an instance that opts the ideator lane out pays nothing', async () => {
|
|
99
|
-
const c = stubClient();
|
|
111
|
+
const c = stubClient({ authorId: 3 });
|
|
100
112
|
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 6, authorId: 3, completenessScore: PASS, config: { ideatorCredit: false } });
|
|
101
113
|
assert.equal(r.credited, 0);
|
|
102
114
|
assert.equal(r.reason, 'ideator_lane_off');
|
|
@@ -104,9 +116,29 @@ await test('an instance that opts the ideator lane out pays nothing', async () =
|
|
|
104
116
|
});
|
|
105
117
|
|
|
106
118
|
await test('a missing author or idea is a safe 0, never a throw', async () => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
assert.equal((await credits.awardIdeaFullPassCredit(
|
|
119
|
+
// No ideaId at all: refused before the parties lookup even runs.
|
|
120
|
+
const noIdea = stubClient({ authorId: 3 });
|
|
121
|
+
assert.equal((await credits.awardIdeaFullPassCredit(noIdea, { ideaId: null, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } })).reason, 'missing_author_or_idea');
|
|
122
|
+
// An idea the lookup cannot see AND no caller-supplied author → still a safe 0.
|
|
123
|
+
const unknown = stubClient(); // lookup returns no row
|
|
124
|
+
assert.equal((await credits.awardIdeaFullPassCredit(unknown, { ideaId: 6, authorId: null, completenessScore: PASS, config: { ideatorCredit: true } })).reason, 'missing_author_or_idea');
|
|
125
|
+
assert.equal(unknown.rows.length, 0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('R147: the DB is authoritative for the author; the argument is only a fallback', async () => {
|
|
129
|
+
// The caller passing null no longer matters when the row is readable — the
|
|
130
|
+
// whole point of moving the payee decision into this module.
|
|
131
|
+
const known = stubClient({ authorId: 3 });
|
|
132
|
+
const r = await credits.awardIdeaFullPassCredit(known, { ideaId: 6, authorId: null, completenessScore: PASS, config: { ideatorCredit: true } });
|
|
133
|
+
assert.equal(r.credited, credits.IDEA_FULL_PASS_CREDIT);
|
|
134
|
+
assert.equal(known.rows[0].builder_id, 3, 'paid the author the DB names');
|
|
135
|
+
|
|
136
|
+
// ...and when the lookup finds nothing, the passed author is still honoured,
|
|
137
|
+
// so a caller holding an idea this module cannot see is not silently unpaid.
|
|
138
|
+
const invisible = stubClient();
|
|
139
|
+
const f = await credits.awardIdeaFullPassCredit(invisible, { ideaId: 6, authorId: 7, completenessScore: PASS, config: { ideatorCredit: true } });
|
|
140
|
+
assert.equal(f.credited, credits.IDEA_FULL_PASS_CREDIT);
|
|
141
|
+
assert.equal(invisible.rows[0].builder_id, 7);
|
|
110
142
|
});
|
|
111
143
|
|
|
112
144
|
console.log(`\nidea_full_pass_credit: ${passed} passed, ${failed} failed`);
|
|
@@ -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);
|