@bongos/core 1.19.934 → 1.19.936
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 +43 -33
- package/clients/bongos-client/index.d.ts +3 -3
- package/docs/api/openapi.json +12 -0
- package/docs/api-reference.md +3 -3
- package/docs/copy-inventory.md +36 -34
- package/docs/copy-registry.json +56 -38
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/board.js +4 -0
- package/modules/government/db.js +8 -1
- package/modules/hall-ui/public/board-room.css +6 -0
- package/modules/hall-ui/public/board-room.js +31 -0
- package/modules/hall-ui/public/ideas.css +27 -0
- package/modules/hall-ui/public/ideas.html +7 -0
- package/modules/hall-ui/public/ideas.js +99 -3
- package/modules/ideas/grades.js +43 -14
- package/modules/ideas/inbox.js +7 -0
- package/modules/ideas/migrations/ideas_009_recommendation.sql +62 -0
- package/modules/ideas/projection.js +45 -2
- package/modules/ideas/routes/inbox.js +72 -1
- package/modules/ideas/templates.js +38 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_board_view.mjs +5 -1
- package/tests/idea_completeness.mjs +31 -5
- package/tests/idea_develop_spark.mjs +6 -1
- package/tests/idea_full_capture.mjs +7 -1
- package/tests/idea_recommendation.mjs +381 -0
- package/tests/idea_templates.mjs +34 -0
|
@@ -61,8 +61,14 @@ function call(bodyIn) {
|
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// Since task 1004091 (BV2.R12) the floors differ per field, so one fixed
|
|
65
|
+
// sentence no longer clears every one of them — this suite wants a payload that
|
|
66
|
+
// scores 100, so each answer is padded to the field's OWN declared floor.
|
|
64
67
|
const FULL = Object.fromEntries(
|
|
65
|
-
BASE_TEMPLATE.fields.map((f) => [
|
|
68
|
+
BASE_TEMPLATE.fields.map((f) => [
|
|
69
|
+
f.key,
|
|
70
|
+
`A substantive answer for ${f.key}, long enough to clear the floor.`.padEnd(f.substanceMin, '.'),
|
|
71
|
+
]),
|
|
66
72
|
);
|
|
67
73
|
|
|
68
74
|
let passed = 0;
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// tests/idea_recommendation.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004092 (BV2.R13, spec R22, goal 1000095) — A Full Idea carries a
|
|
4
|
+
// RECOMMENDATION: goal or criterion, carried from filing through to the board
|
|
5
|
+
// item and shown to voters.
|
|
6
|
+
//
|
|
7
|
+
// The done-when names a CHAIN, so this suite walks it in order rather than
|
|
8
|
+
// testing three features that happen to share a word:
|
|
9
|
+
//
|
|
10
|
+
// 1. filing — POST /inbox, POST /:id/develop, POST /:id/resubmit each
|
|
11
|
+
// take it, refuse anything outside the vocabulary, and write
|
|
12
|
+
// it on the grade row.
|
|
13
|
+
// 2. the board item — government's subject projection carries it out of the
|
|
14
|
+
// same row the sections come from.
|
|
15
|
+
// 3. the voter — the Board Room renders it as a recommendation, in front of
|
|
16
|
+
// the ballot.
|
|
17
|
+
//
|
|
18
|
+
// THE THREE THINGS MOST LIKELY TO BE WRONG, each with a test that fails loudly:
|
|
19
|
+
//
|
|
20
|
+
// • A RESUBMISSION SILENTLY WITHDRAWING THE RECOMMENDATION. setIdeaGrade
|
|
21
|
+
// writes the WHOLE grade row by contract, so a resubmit that does not
|
|
22
|
+
// restate the recommendation stores NULL — and the request that did it
|
|
23
|
+
// never mentioned the field. That is the defect this file exists for.
|
|
24
|
+
// • ABSENCE RENDERED AS A VALUE. Every Full Idea filed before this shipped
|
|
25
|
+
// carries NULL and there is no backfill, so "the author did not say" has to
|
|
26
|
+
// survive the projection and reach the voter as itself.
|
|
27
|
+
// • A SECOND COPY OF THE VOCABULARY. The two values are a database CHECK; the
|
|
28
|
+
// route, the doorway re-export and the composer must all read the one
|
|
29
|
+
// declaration in projection.js.
|
|
30
|
+
//
|
|
31
|
+
// DB-free the idea_full_capture.mjs way: pool.query is patched on the doorway
|
|
32
|
+
// before the modules load, and the REAL route handlers are driven through a
|
|
33
|
+
// stub req/res. The Board Room half runs the REAL board-room.js in a vm, the
|
|
34
|
+
// way tests/government_board_vote_ui.mjs does.
|
|
35
|
+
//
|
|
36
|
+
// Run: node tests/idea_recommendation.mjs
|
|
37
|
+
|
|
38
|
+
import { strict as assert } from 'node:assert';
|
|
39
|
+
import { readFileSync } from 'node:fs';
|
|
40
|
+
import { createRequire } from 'node:module';
|
|
41
|
+
import vm from 'node:vm';
|
|
42
|
+
|
|
43
|
+
const require = createRequire(import.meta.url);
|
|
44
|
+
const api = require('../src/module-api.js');
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// the stub pool — every write echoes its own parameters back, so a test can
|
|
48
|
+
// assert what was actually BOUND rather than what the handler said it sent.
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
let queries = [];
|
|
52
|
+
// What getIdea sees. Reassigned per test — a resubmit reads the stored row to
|
|
53
|
+
// decide what to carry forward, which is the whole point of half of this file.
|
|
54
|
+
let storedIdea = null;
|
|
55
|
+
|
|
56
|
+
api.pool.query = async (sql, params = []) => {
|
|
57
|
+
queries.push({ sql, params });
|
|
58
|
+
if (/INSERT INTO ideas_grades/i.test(sql)) {
|
|
59
|
+
return { rows: [{
|
|
60
|
+
idea_id: params[0], grade: params[1], template_key: params[2],
|
|
61
|
+
field_values: JSON.parse(params[3]), completeness_score: params[4],
|
|
62
|
+
recommendation: params[5],
|
|
63
|
+
}] };
|
|
64
|
+
}
|
|
65
|
+
if (/UPDATE ideas_grades/i.test(sql)) {
|
|
66
|
+
return { rows: [{
|
|
67
|
+
idea_id: params[0], grade: 'full', template_key: params[1],
|
|
68
|
+
field_values: JSON.parse(params[2]), completeness_score: params[3],
|
|
69
|
+
developed_by: params[4], developed_at: 'T', recommendation: params[5],
|
|
70
|
+
}] };
|
|
71
|
+
}
|
|
72
|
+
if (/INSERT INTO idea_inbox/i.test(sql)) return { rows: [{ id: '77', title: params[0], status: 'open' }] };
|
|
73
|
+
// COUNT before the bare table match: countOpenIdeas also reads FROM
|
|
74
|
+
// idea_inbox, and answering it with a row list is a TypeError three frames
|
|
75
|
+
// down that surfaces as a 500 on a route that worked.
|
|
76
|
+
if (/SELECT COUNT/i.test(sql)) return { rows: [{ n: 3, count: 3 }] };
|
|
77
|
+
if (/FROM idea_inbox/i.test(sql)) return { rows: storedIdea ? [storedIdea] : [] };
|
|
78
|
+
return { rows: [] };
|
|
79
|
+
};
|
|
80
|
+
// No credit lane, no board port: this suite is about one column's journey.
|
|
81
|
+
api.resolveOptional = () => undefined;
|
|
82
|
+
|
|
83
|
+
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
84
|
+
const projection = require('../modules/ideas/projection.js');
|
|
85
|
+
const inbox = require('../modules/ideas/inbox.js');
|
|
86
|
+
const routesFactory = require('../modules/ideas/routes/inbox.js');
|
|
87
|
+
|
|
88
|
+
const router = routesFactory({});
|
|
89
|
+
function handlerFor(path, method) {
|
|
90
|
+
const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods[method]);
|
|
91
|
+
assert.ok(layer, `route ${method.toUpperCase()} ${path} exists`);
|
|
92
|
+
const stack = layer.route.stack.map((s) => s.handle);
|
|
93
|
+
return stack[stack.length - 1];
|
|
94
|
+
}
|
|
95
|
+
const postInbox = handlerFor('/inbox', 'post');
|
|
96
|
+
const getTemplate = handlerFor('/inbox/template', 'get');
|
|
97
|
+
const postDevelop = handlerFor('/inbox/:id/develop', 'post');
|
|
98
|
+
const postResubmit = handlerFor('/inbox/:id/resubmit', 'post');
|
|
99
|
+
|
|
100
|
+
function drive(handler, { body = {}, params = {}, builderId = '9' } = {}) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const req = { body, params, query: {}, builder: { id: builderId, rank: 'metic' } };
|
|
103
|
+
const res = {
|
|
104
|
+
statusCode: 200,
|
|
105
|
+
status(c) { this.statusCode = c; return this; },
|
|
106
|
+
json(payload) { resolve({ status: this.statusCode, payload }); },
|
|
107
|
+
fail(code, status, extra) {
|
|
108
|
+
const s = typeof status === 'number' ? status : (status && status.status) || 500;
|
|
109
|
+
resolve({ status: s, payload: { error: code, ...(extra || {}) } });
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
Promise.resolve(handler(req, res)).catch(reject);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Each answer padded to its OWN field's floor — since task 1004091 the floors
|
|
117
|
+
// differ per field, so one fixed sentence no longer clears every one of them.
|
|
118
|
+
const FULL = Object.fromEntries(
|
|
119
|
+
BASE_TEMPLATE.fields.map((f) => [
|
|
120
|
+
f.key,
|
|
121
|
+
`A substantive answer for ${f.key}, long enough to clear the floor.`.padEnd(f.substanceMin, '.'),
|
|
122
|
+
]),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const gradeInsert = () => queries.find((q) => /INSERT INTO ideas_grades/i.test(q.sql));
|
|
126
|
+
const gradeUpdate = () => queries.find((q) => /UPDATE ideas_grades/i.test(q.sql));
|
|
127
|
+
|
|
128
|
+
let passed = 0;
|
|
129
|
+
let failed = 0;
|
|
130
|
+
async function test(name, fn) {
|
|
131
|
+
queries = [];
|
|
132
|
+
storedIdea = null;
|
|
133
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
134
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// 0. the vocabulary is declared ONCE
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
await test('the vocabulary is exactly goal and criterion, declared in one place', async () => {
|
|
142
|
+
assert.deepEqual(projection.VALID_RECOMMENDATIONS, ['goal', 'criterion']);
|
|
143
|
+
// Re-exported through the module facade, so the routes and the template read
|
|
144
|
+
// do not each name their own pair.
|
|
145
|
+
assert.equal(inbox.VALID_RECOMMENDATIONS, projection.VALID_RECOMMENDATIONS);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('absence is legal at every layer; anything else is not', async () => {
|
|
149
|
+
for (const ok of [undefined, null, 'goal', 'criterion']) {
|
|
150
|
+
assert.equal(projection.isValidRecommendation(ok), true, `${String(ok)} is accepted`);
|
|
151
|
+
}
|
|
152
|
+
for (const bad of ['Goal', 'version', '', 'criterion ', 0, true, {}]) {
|
|
153
|
+
assert.equal(projection.isValidRecommendation(bad), false, `${JSON.stringify(bad)} is refused`);
|
|
154
|
+
}
|
|
155
|
+
// '' is how an unpicked radio reaches the wire; it normalises to "not given"
|
|
156
|
+
// rather than being sent on as a value the CHECK constraint would reject.
|
|
157
|
+
assert.equal(projection.normalizeRecommendation(''), null);
|
|
158
|
+
assert.equal(projection.normalizeRecommendation(undefined), null);
|
|
159
|
+
assert.equal(projection.normalizeRecommendation('goal'), 'goal');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// 1. filing
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
await test('a Full Idea files WITH its recommendation, and it reaches the grade row', async () => {
|
|
167
|
+
const r = await drive(postInbox, { body: { title: 'a full idea', grade: 'full', field_values: FULL, recommendation: 'goal' } });
|
|
168
|
+
assert.equal(r.status, 201);
|
|
169
|
+
assert.equal(r.payload.idea.recommendation, 'goal', 'the response states what was stored');
|
|
170
|
+
assert.equal(gradeInsert().params[5], 'goal', 'bound on the write, not just echoed');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await test('a Full Idea files WITHOUT one, and the absence is stored as absence', async () => {
|
|
174
|
+
const r = await drive(postInbox, { body: { title: 'a full idea', grade: 'full', field_values: FULL } });
|
|
175
|
+
assert.equal(r.status, 201);
|
|
176
|
+
assert.equal(r.payload.idea.recommendation, null);
|
|
177
|
+
assert.equal(gradeInsert().params[5], null, 'NULL, never the string "undefined" and never a default');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await test('a value outside the vocabulary is a named 400, before any write', async () => {
|
|
181
|
+
const r = await drive(postInbox, { body: { title: 'x', grade: 'full', field_values: FULL, recommendation: 'version' } });
|
|
182
|
+
assert.equal(r.status, 400);
|
|
183
|
+
assert.equal(r.payload.error, 'bad_recommendation');
|
|
184
|
+
assert.deepEqual(r.payload.allowed, ['goal', 'criterion']);
|
|
185
|
+
assert.equal(queries.length, 0, 'refused before any write — no orphan idea');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await test('a recommendation on a QUICK capture is refused, not silently dropped', async () => {
|
|
189
|
+
// A quick idea writes no grade row, so accepting one would answer 201 for a
|
|
190
|
+
// recommendation that was never stored anywhere — the worst of the three
|
|
191
|
+
// possible behaviours, because the caller is told it worked.
|
|
192
|
+
const r = await drive(postInbox, { body: { title: 'quick', recommendation: 'goal' } });
|
|
193
|
+
assert.equal(r.status, 400);
|
|
194
|
+
assert.equal(r.payload.error, 'recommendation_needs_full_grade');
|
|
195
|
+
assert.equal(queries.length, 0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await test('the quick path is untouched when no recommendation is sent', async () => {
|
|
199
|
+
const r = await drive(postInbox, { body: { title: 'quick' } });
|
|
200
|
+
assert.equal(r.status, 201);
|
|
201
|
+
assert.ok(!queries.some((q) => /ideas_grades/i.test(q.sql)), 'still no grade row');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await test('developing a spark carries the developer’s recommendation', async () => {
|
|
205
|
+
storedIdea = { id: '77', grade: 'spark', status: 'open', captured_by: '9', template_key: 'base-v2', field_values: {} };
|
|
206
|
+
const r = await drive(postDevelop, { params: { id: '77' }, body: { field_values: FULL, recommendation: 'criterion' }, builderId: '42' });
|
|
207
|
+
assert.equal(r.status, 200);
|
|
208
|
+
assert.equal(r.payload.idea.recommendation, 'criterion');
|
|
209
|
+
assert.equal(gradeUpdate().params[5], 'criterion');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await test('developing refuses a value outside the vocabulary too', async () => {
|
|
213
|
+
storedIdea = { id: '77', grade: 'spark', status: 'open', captured_by: '9', template_key: 'base-v2', field_values: {} };
|
|
214
|
+
const r = await drive(postDevelop, { params: { id: '77' }, body: { field_values: FULL, recommendation: 'epic' } });
|
|
215
|
+
assert.equal(r.status, 400);
|
|
216
|
+
assert.equal(r.payload.error, 'bad_recommendation');
|
|
217
|
+
assert.equal(queries.length, 0, 'refused before the idea is even read');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// --- the defect this file exists for --------------------------------------
|
|
221
|
+
|
|
222
|
+
await test('A RESUBMISSION THAT SAYS NOTHING KEEPS THE STORED RECOMMENDATION', async () => {
|
|
223
|
+
// setIdeaGrade writes the WHOLE row by contract. Without an explicit carry
|
|
224
|
+
// forward, answering a board objection would store NULL — silently
|
|
225
|
+
// withdrawing the scope the board was shown, on a request that never
|
|
226
|
+
// mentioned it.
|
|
227
|
+
storedIdea = {
|
|
228
|
+
id: '77', grade: 'full', status: 'open', captured_by: '9',
|
|
229
|
+
template_key: 'base-v2', field_values: FULL, recommendation: 'goal',
|
|
230
|
+
};
|
|
231
|
+
const r = await drive(postResubmit, { params: { id: '77' }, body: { field_values: FULL } });
|
|
232
|
+
assert.equal(r.status, 200);
|
|
233
|
+
assert.equal(gradeInsert().params[5], 'goal', 'carried forward from the stored row');
|
|
234
|
+
assert.equal(r.payload.idea.recommendation, 'goal');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await test('a resubmission MAY change it — the author revised their own reading', async () => {
|
|
238
|
+
storedIdea = {
|
|
239
|
+
id: '77', grade: 'full', status: 'open', captured_by: '9',
|
|
240
|
+
template_key: 'base-v2', field_values: FULL, recommendation: 'goal',
|
|
241
|
+
};
|
|
242
|
+
const r = await drive(postResubmit, { params: { id: '77' }, body: { field_values: FULL, recommendation: 'criterion' } });
|
|
243
|
+
assert.equal(r.status, 200);
|
|
244
|
+
assert.equal(gradeInsert().params[5], 'criterion', 'the body wins over the stored value');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await test('resubmitting an idea that never had one still stores none', async () => {
|
|
248
|
+
storedIdea = {
|
|
249
|
+
id: '77', grade: 'full', status: 'open', captured_by: '9',
|
|
250
|
+
template_key: 'base-v2', field_values: FULL, recommendation: null,
|
|
251
|
+
};
|
|
252
|
+
const r = await drive(postResubmit, { params: { id: '77' }, body: { field_values: FULL } });
|
|
253
|
+
assert.equal(r.status, 200);
|
|
254
|
+
assert.equal(gradeInsert().params[5], null, 'null carried forward is still null — never invented');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// the composer's question comes from the server
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
await test('GET /inbox/template serves the choice beside the fields, not inside them', async () => {
|
|
262
|
+
const r = await drive(getTemplate);
|
|
263
|
+
assert.equal(r.status, 200);
|
|
264
|
+
const spec = r.payload.template.recommendation;
|
|
265
|
+
assert.ok(spec, 'the composer renders the question from here, not from a hard-coded pair');
|
|
266
|
+
assert.deepEqual(spec.options.map((o) => o.value), ['goal', 'criterion']);
|
|
267
|
+
assert.ok(spec.options.every((o) => o.label && o.label !== o.value), 'each option carries words a person reads');
|
|
268
|
+
// NOT a sixth field: it is not prose, nothing scores it, and adding it to
|
|
269
|
+
// `fields` would change what a Full Idea costs to file.
|
|
270
|
+
assert.ok(!r.payload.template.fields.some((f) => f.key === 'recommendation'));
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await test('the hall composer reads the options rather than restating them', async () => {
|
|
274
|
+
const src = readFileSync(new URL('../modules/hall-ui/public/ideas.js', import.meta.url), 'utf8');
|
|
275
|
+
assert.ok(/template\.recommendation/.test(src), 'the spec comes from the template read');
|
|
276
|
+
// The one place the page may name a value is nowhere: a hard-coded pair here
|
|
277
|
+
// is the same forked-declaration defect as a hard-coded field list.
|
|
278
|
+
assert.ok(!/['"]criterion['"]/.test(src), 'the page does not name a vocabulary member');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// 2 + 3. the board item, and the voter
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
// board-room.js is a self-contained IIFE over the DOM with an `init()` at the
|
|
286
|
+
// bottom, so it cannot be imported and booting the whole page to read one line
|
|
287
|
+
// would be a harness larger than the feature. Its own suites
|
|
288
|
+
// (government_board_vote_ui.mjs, government_board_room_copy.mjs) slice the file
|
|
289
|
+
// by FUNCTION-NAME LANDMARKS — the file's header calls that out and asks that
|
|
290
|
+
// the function order be left alone for exactly this reason — so this does the
|
|
291
|
+
// same, and then RUNS what it sliced. A source pin could show the words are in
|
|
292
|
+
// the file; only running it shows which of the three branches produces them.
|
|
293
|
+
const BOARD_ROOM = readFileSync(new URL('../modules/hall-ui/public/board-room.js', import.meta.url), 'utf8');
|
|
294
|
+
|
|
295
|
+
function bootBoardRoom() {
|
|
296
|
+
const from = BOARD_ROOM.indexOf('const RECOMMENDATION_WORDS');
|
|
297
|
+
const to = BOARD_ROOM.indexOf("// ---- the sitting's rules");
|
|
298
|
+
assert.ok(from > 0 && to > from, 'the recommendation renderer is where its landmarks say it is');
|
|
299
|
+
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
300
|
+
const sandbox = { escapeHtml, out: null };
|
|
301
|
+
vm.createContext(sandbox);
|
|
302
|
+
vm.runInContext(`${BOARD_ROOM.slice(from, to)}\nout = recommendationHtml;`, sandbox);
|
|
303
|
+
return { recommendationHtml: sandbox.out };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const openItem = (subject) => ({
|
|
307
|
+
id: '41',
|
|
308
|
+
subject_type: 'full_idea',
|
|
309
|
+
subject_id: '1000412',
|
|
310
|
+
subject,
|
|
311
|
+
opened_by: '1',
|
|
312
|
+
opened_at: 'T0',
|
|
313
|
+
closes_at: null,
|
|
314
|
+
remaining_seconds: null,
|
|
315
|
+
expired: false,
|
|
316
|
+
closed_at: null,
|
|
317
|
+
outcome: null,
|
|
318
|
+
constitution: { membership: 'rank:metic+', pass_rule: 'consent', window_minutes: null, close_early_on_full_turnout: false },
|
|
319
|
+
votes: [],
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await test('the board SUBJECT carries the recommendation out of the same row as the sections', async () => {
|
|
323
|
+
// The projection is government's, over the row modules/government/db.js reads.
|
|
324
|
+
const boardSrc = readFileSync(new URL('../modules/government/board.js', import.meta.url), 'utf8');
|
|
325
|
+
const dbSrc = readFileSync(new URL('../modules/government/db.js', import.meta.url), 'utf8');
|
|
326
|
+
assert.ok(/g\.recommendation/.test(dbSrc), 'getIdeaSubjects selects it');
|
|
327
|
+
assert.ok(/recommendation: r\.recommendation \?\? null/.test(boardSrc), 'and the subject projects it, absence included');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await test('a voter sees the recommendation, in words, above the ballot', async () => {
|
|
331
|
+
const T = bootBoardRoom();
|
|
332
|
+
const html = T.recommendationHtml(openItem({ title: 'x', author_id: '9', template_key: 'base-v2', completeness_score: 100, field_values: { why_had: 'w' }, recommendation: 'goal' }));
|
|
333
|
+
assert.match(html, /a goal of its own/, 'the value is rendered as words, not as a raw key');
|
|
334
|
+
assert.match(html, /recommends/i, 'framed as a recommendation');
|
|
335
|
+
assert.match(html, /board decides/i, 'and the decision is named as the board’s — spec decision 9');
|
|
336
|
+
assert.ok(!/\bcriterion\b/.test(html), 'only the recommended one is shown');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await test('the criterion reading renders its own words', async () => {
|
|
340
|
+
const T = bootBoardRoom();
|
|
341
|
+
const html = T.recommendationHtml(openItem({ title: 'x', author_id: '9', template_key: 'base-v2', field_values: {}, recommendation: 'criterion' }));
|
|
342
|
+
assert.match(html, /a criterion inside an existing goal/);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
await test('AN ABSENT RECOMMENDATION IS STATED, NOT HIDDEN', async () => {
|
|
346
|
+
// The alternative — rendering nothing — leaves a voter unable to tell
|
|
347
|
+
// "recommended as a criterion" from "never asked", which is exactly the
|
|
348
|
+
// confusion every pre-BV2 idea would cause.
|
|
349
|
+
const T = bootBoardRoom();
|
|
350
|
+
const html = T.recommendationHtml(openItem({ title: 'x', author_id: '9', template_key: 'base-v1', field_values: { purpose: 'p' }, recommendation: null }));
|
|
351
|
+
assert.match(html, /no scope recommendation/i);
|
|
352
|
+
assert.ok(!/a goal of its own|a criterion inside/.test(html), 'and no value is invented for it');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await test('an amendment has no author recommendation and renders no line', async () => {
|
|
356
|
+
const T = bootBoardRoom();
|
|
357
|
+
const item = { ...openItem({ proposed: {}, rationale_md: 'r', author_id: '1' }), subject_type: 'constitutional_amendment' };
|
|
358
|
+
assert.equal(T.recommendationHtml(item), '');
|
|
359
|
+
assert.equal(T.recommendationHtml({ ...openItem(null) }), '', 'nor does a deleted subject');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await test('a vocabulary member this page has never heard of is printed, not dropped', async () => {
|
|
363
|
+
// The CHECK constraint can widen in a later migration while this page is
|
|
364
|
+
// still cached. Printing the raw key is ugly and honest; dropping it hides a
|
|
365
|
+
// recommendation from the board being asked to rule on it.
|
|
366
|
+
const T = bootBoardRoom();
|
|
367
|
+
const html = T.recommendationHtml(openItem({ title: 'x', author_id: '9', template_key: 'base-v2', field_values: {}, recommendation: 'version' }));
|
|
368
|
+
assert.match(html, /version/);
|
|
369
|
+
assert.match(html, /recommends/i);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
await test('the line sits ON the card, ABOVE the idea’s own words', async () => {
|
|
373
|
+
const card = BOARD_ROOM.slice(BOARD_ROOM.indexOf('function itemCard'), BOARD_ROOM.indexOf('async function loadBoard'));
|
|
374
|
+
const rec = card.indexOf('recommendationHtml(item)');
|
|
375
|
+
const sections = card.indexOf('ideaSectionsHtml(item)');
|
|
376
|
+
assert.ok(rec > 0, 'the card renders it');
|
|
377
|
+
assert.ok(rec < sections, 'a voter reads what was proposed before they read the case for it');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
console.log(`\nidea_recommendation: ${passed} passed, ${failed} failed`);
|
|
381
|
+
if (failed) process.exit(1);
|
package/tests/idea_templates.mjs
CHANGED
|
@@ -74,6 +74,40 @@ test('base-v2 declares the owner\'s new five, in order, and IS the store default
|
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
// ── the substance floors (task 1004091, BV2.R12, spec R21) ──────────────────
|
|
78
|
+
//
|
|
79
|
+
// The floors are a PRICE, not a style note: `completeness_score` is the mean of
|
|
80
|
+
// per-field ratios, and that number gates the ideator credit (economy's
|
|
81
|
+
// IDEA_FULL_PASS_BAR) and whether a Full Idea reaches the board at all. So the
|
|
82
|
+
// two claims worth pinning are the two that would be silently reverted: that the
|
|
83
|
+
// floors actually differ per question, and that redistributing them did not
|
|
84
|
+
// change what filing a Full Idea costs in total.
|
|
85
|
+
|
|
86
|
+
test('BV2.R12: v2\'s floors differ per question, and total exactly what v1 cost', () => {
|
|
87
|
+
const total = (t) => t.fields.reduce((sum, f) => sum + f.substanceMin, 0);
|
|
88
|
+
assert.equal(total(T.BASE_V2_TEMPLATE), total(T.BASE_V1_TEMPLATE),
|
|
89
|
+
're-pricing what a thought costs to file is the owner\'s call, not this template\'s — '
|
|
90
|
+
+ 'R12 distributes v1\'s total across questions that are not interchangeable, it does not move it');
|
|
91
|
+
|
|
92
|
+
const floors = T.BASE_V2_TEMPLATE.fields.map((f) => f.substanceMin);
|
|
93
|
+
assert.ok(new Set(floors).size > 1,
|
|
94
|
+
'flat floors are the placeholder BV2.R12 replaced — the questions are not interchangeable');
|
|
95
|
+
|
|
96
|
+
// Named by key, because the REASONING is what a future edit would undo: the
|
|
97
|
+
// field that invites the longest answer carries the highest floor, and the
|
|
98
|
+
// anti-ghost-writing field — the one nobody but the filer can answer — carries
|
|
99
|
+
// the lowest, so no floor ever pushes an agent to pad it (the Ideator pack).
|
|
100
|
+
const floorOf = (key) => T.BASE_V2_TEMPLATE.fields.find((f) => f.key === key).substanceMin;
|
|
101
|
+
assert.equal(Math.max(...floors), floorOf('scope'), '"scope it as best as you can" asks for the most');
|
|
102
|
+
assert.equal(Math.min(...floors), floorOf('why_had'), '"what made you have the idea" asks for the least');
|
|
103
|
+
|
|
104
|
+
// ADR 0186's interim karma-eligibility guard, restated here so a later
|
|
105
|
+
// reshuffle cannot drop the template below its own economics.
|
|
106
|
+
assert.ok(T.BASE_V2_TEMPLATE.fields.filter((f) => f.required).length >= 3
|
|
107
|
+
&& total(T.BASE_V2_TEMPLATE) >= 120,
|
|
108
|
+
'ADR 0186 T5: ≥3 required fields and ≥120 total substanceMin to stay karma-eligible');
|
|
109
|
+
});
|
|
110
|
+
|
|
77
111
|
test('the declaration is frozen — a consumer cannot fork it in place', () => {
|
|
78
112
|
assert.throws(() => { T.BASE_TEMPLATE.fields.push({ key: 'rogue' }); });
|
|
79
113
|
assert.throws(() => { T.BASE_V1_TEMPLATE.fields.push({ key: 'rogue' }); });
|