@bongos/core 1.19.933 → 1.19.935
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 +40 -30
- package/docs/copy-inventory.md +14 -14
- package/docs/copy-registry.json +14 -14
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/board.js +32 -7
- package/modules/government/migrations/government_017_board_section_keys_v2.sql +59 -0
- package/modules/hall-ui/public/board-room.js +72 -15
- package/modules/ideas/migrations/ideas_008_template_default_v2.sql +37 -0
- package/modules/ideas/projection.js +16 -3
- package/modules/ideas/templates.js +108 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_board_room_copy.mjs +12 -3
- package/tests/government_board_vote.mjs +53 -5
- package/tests/government_board_vote_ui.mjs +26 -6
- package/tests/government_seed.mjs +1 -0
- package/tests/helpers.mjs +23 -0
- package/tests/idea_completeness.mjs +59 -16
- package/tests/idea_full_capture.mjs +7 -1
- package/tests/idea_grades.mjs +7 -2
- package/tests/idea_hall_composer.mjs +45 -5
- package/tests/idea_ideate_full.mjs +13 -4
- package/tests/idea_resubmit.mjs +49 -10
- package/tests/idea_spark_hall.mjs +3 -1
- package/tests/idea_templates.mjs +129 -16
|
@@ -32,6 +32,19 @@ const COMPLETE = Object.fromEntries(
|
|
|
32
32
|
BASE_TEMPLATE.fields.map((f) => [f.key, `A real, substantive answer for ${f.key} that clears the declared floor easily.`]),
|
|
33
33
|
);
|
|
34
34
|
|
|
35
|
+
// The scorer is TEMPLATE-AGNOSTIC — that is the property BV1.R142 exists to have
|
|
36
|
+
// — so this suite names fields BY POSITION rather than by the keys one vintage
|
|
37
|
+
// happens to use. Before task 1004085 (BV2.R06) it spelled `purpose`,
|
|
38
|
+
// `big_picture` and `why_matters`, and versioning the questions reddened five
|
|
39
|
+
// cases that were making no claim about those names at all. A test that hardcodes
|
|
40
|
+
// the current template's keys is quietly asserting the template never changes.
|
|
41
|
+
const [FIELD_1, FIELD_2, , FIELD_4] = BASE_TEMPLATE.fields.map((f) => f.key);
|
|
42
|
+
|
|
43
|
+
// Since task 1004091 (BV2.R12) the floors DIFFER per field, so a fixture may no
|
|
44
|
+
// longer assume "40" — or, worse, assume that equal text in two sections is equal
|
|
45
|
+
// credit. Every expectation below is derived from the declaration.
|
|
46
|
+
const floorOf = (key) => BASE_TEMPLATE.fields.find((f) => f.key === key).substanceMin;
|
|
47
|
+
|
|
35
48
|
test('a complete idea scores 100, an empty one 0', () => {
|
|
36
49
|
assert.equal(C.completenessScore(COMPLETE), 100);
|
|
37
50
|
assert.equal(C.completenessScore({}), 0);
|
|
@@ -43,21 +56,33 @@ test('same input, same score — byte-for-byte determinism', () => {
|
|
|
43
56
|
assert.deepEqual(C.scoreFieldValues(COMPLETE), C.scoreFieldValues(COMPLETE));
|
|
44
57
|
});
|
|
45
58
|
|
|
46
|
-
test('a thin answer earns its proportion, not
|
|
47
|
-
const
|
|
59
|
+
test('a thin answer earns its proportion of ITS OWN floor, not a flat share', () => {
|
|
60
|
+
const TEN = 'ten chars.'; // exactly 10 characters of substance
|
|
61
|
+
const oneThin = { ...COMPLETE, [FIELD_1]: TEN };
|
|
48
62
|
const s = C.completenessScore(oneThin);
|
|
49
|
-
assert.ok(s > 80 && s < 100, `four full + one
|
|
50
|
-
const breakdown = C.scoreFieldValues(oneThin).fields.find((f) => f.key ===
|
|
63
|
+
assert.ok(s > 80 && s < 100, `four full fields + one part-credit field, got ${s}`);
|
|
64
|
+
const breakdown = C.scoreFieldValues(oneThin).fields.find((f) => f.key === FIELD_1);
|
|
51
65
|
assert.equal(breakdown.substance, 10);
|
|
52
|
-
assert.equal(breakdown.
|
|
66
|
+
assert.equal(breakdown.floor, floorOf(FIELD_1), 'the breakdown publishes the field\'s own floor');
|
|
67
|
+
assert.equal(breakdown.ratio, 10 / floorOf(FIELD_1));
|
|
68
|
+
|
|
69
|
+
// BV2.R12's actual deliverable: the same ten characters are worth DIFFERENT
|
|
70
|
+
// credit in different sections, because the questions are not interchangeable.
|
|
71
|
+
// A test that only ever looked at one field would pass just as happily against
|
|
72
|
+
// the flat-40 placeholder this task replaced.
|
|
73
|
+
const other = C.scoreFieldValues({ ...COMPLETE, [FIELD_2]: TEN })
|
|
74
|
+
.fields.find((f) => f.key === FIELD_2);
|
|
75
|
+
assert.equal(other.ratio, 10 / floorOf(FIELD_2));
|
|
76
|
+
assert.notEqual(other.ratio, breakdown.ratio,
|
|
77
|
+
'identical text scores lower where the question asks for more');
|
|
53
78
|
});
|
|
54
79
|
|
|
55
80
|
test('padding earns exactly nothing: whitespace and blank lines are not substance', () => {
|
|
56
|
-
const padded = { ...COMPLETE,
|
|
57
|
-
const b = C.scoreFieldValues(padded).fields.find((f) => f.key ===
|
|
81
|
+
const padded = { ...COMPLETE, [FIELD_1]: ' \n\n\t \n \n\n ' };
|
|
82
|
+
const b = C.scoreFieldValues(padded).fields.find((f) => f.key === FIELD_1);
|
|
58
83
|
assert.equal(b.substance, 0, 'a page of blank lines is the nothing it is');
|
|
59
|
-
const spaced = { ...COMPLETE,
|
|
60
|
-
const b2 = C.scoreFieldValues(spaced).fields.find((f) => f.key ===
|
|
84
|
+
const spaced = { ...COMPLETE, [FIELD_1]: 'a b\n\n\nc d e' }; // collapses to 9
|
|
85
|
+
const b2 = C.scoreFieldValues(spaced).fields.find((f) => f.key === FIELD_1);
|
|
61
86
|
assert.equal(b2.substance, 9, 'runs of whitespace collapse to one');
|
|
62
87
|
});
|
|
63
88
|
|
|
@@ -68,7 +93,13 @@ test('one missing field costs exactly its equal share', () => {
|
|
|
68
93
|
|
|
69
94
|
test('the breakdown comes back in TEMPLATE order, ready to render', () => {
|
|
70
95
|
const keys = C.scoreFieldValues(COMPLETE).fields.map((f) => f.key);
|
|
71
|
-
assert.deepEqual(keys,
|
|
96
|
+
assert.deepEqual(keys, BASE_TEMPLATE.fields.map((f) => f.key));
|
|
97
|
+
// The claim is that the breakdown follows the DECLARATION, not that it matches
|
|
98
|
+
// some other list derived the same way — so it is also checked against a shuffled
|
|
99
|
+
// input, which is the only thing that distinguishes declaration order from
|
|
100
|
+
// insertion order.
|
|
101
|
+
const shuffled = Object.fromEntries(Object.entries(COMPLETE).reverse());
|
|
102
|
+
assert.deepEqual(C.scoreFieldValues(shuffled).fields.map((f) => f.key), keys);
|
|
72
103
|
});
|
|
73
104
|
|
|
74
105
|
test('it never throws: malformed input is a null score, not an exception', () => {
|
|
@@ -77,7 +108,7 @@ test('it never throws: malformed input is a null score, not an exception', () =>
|
|
|
77
108
|
}
|
|
78
109
|
assert.equal(C.completenessScore(COMPLETE, { templateKey: 'no-such-template' }), null);
|
|
79
110
|
// a non-string FIELD does not throw either — it just earns nothing
|
|
80
|
-
assert.equal(typeof C.completenessScore({ ...COMPLETE,
|
|
111
|
+
assert.equal(typeof C.completenessScore({ ...COMPLETE, [FIELD_1]: 42 }), 'number');
|
|
81
112
|
});
|
|
82
113
|
|
|
83
114
|
test('the scorer walks the declaration — a different template scores by ITS fields', () => {
|
|
@@ -97,25 +128,37 @@ test('the scorer walks the declaration — a different template scores by ITS fi
|
|
|
97
128
|
// ── the sub-bar rejection payload (BV1.R09 of goal 1000069, criterion C2) ───
|
|
98
129
|
|
|
99
130
|
test('the weakest section is the LOWEST ratio, with ties going to template order', () => {
|
|
100
|
-
|
|
131
|
+
// A GENUINE tie takes different amounts of text in each field now that the
|
|
132
|
+
// floors differ (BV2.R12): two-fifths of each field's OWN floor is the same
|
|
133
|
+
// ratio, where two identical strings would not be. The fixture asserts it is
|
|
134
|
+
// really a tie before asserting how the tie breaks — otherwise this case
|
|
135
|
+
// quietly stops testing tie-breaking at all.
|
|
136
|
+
const twoFifths = (key) => 'x'.repeat(Math.round(floorOf(key) * 0.4));
|
|
137
|
+
const values = { ...COMPLETE, [FIELD_2]: twoFifths(FIELD_2), [FIELD_4]: twoFifths(FIELD_4) };
|
|
138
|
+
const broken = C.scoreFieldValues(values).fields;
|
|
139
|
+
assert.equal(broken.find((f) => f.key === FIELD_2).ratio, broken.find((f) => f.key === FIELD_4).ratio,
|
|
140
|
+
'the fixture really is a tie');
|
|
101
141
|
const worst = C.weakestField(values);
|
|
102
|
-
assert.equal(worst.key,
|
|
142
|
+
assert.equal(worst.key, FIELD_2, 'the earlier of two equal-thinnest fields, by template order');
|
|
103
143
|
// strictly-thinnest wins regardless of position
|
|
104
144
|
assert.equal(C.weakestField({ ...COMPLETE, fit: 'x' }).key, 'fit');
|
|
105
145
|
});
|
|
106
146
|
|
|
107
147
|
test('the rejection payload is board-objection-shaped, names the template LABEL, and states the shortfall', () => {
|
|
108
148
|
const flag = C.weakestSectionPayload({ ...COMPLETE, fit: 'too thin' });
|
|
149
|
+
const field = BASE_TEMPLATE.fields.find((f) => f.key === 'fit');
|
|
109
150
|
assert.equal(flag.section_key, 'fit', 'exactly one of the five section keys — the board-vote shape');
|
|
110
|
-
|
|
151
|
+
// The LABEL, read off the template rather than retyped: the claim is that the
|
|
152
|
+
// reason speaks the words the author actually saw, and those words now depend
|
|
153
|
+
// on which vintage the author wrote under.
|
|
154
|
+
assert.ok(flag.reason_md.includes(field.label), 'the reason speaks the words the author saw');
|
|
111
155
|
assert.ok(/\d+ characters? of substance against a floor of \d+/.test(flag.reason_md),
|
|
112
156
|
'the shortfall is stated in the scorer\'s own honest terms');
|
|
113
|
-
const field = BASE_TEMPLATE.fields.find((f) => f.key === 'fit');
|
|
114
157
|
assert.ok(flag.reason_md.includes(String(field.substanceMin)), 'the floor named is the template\'s real one');
|
|
115
158
|
});
|
|
116
159
|
|
|
117
160
|
test('the payload is DERIVED, deterministic, and null on unscorable input — never stored, never throws', () => {
|
|
118
|
-
const values = { ...COMPLETE,
|
|
161
|
+
const values = { ...COMPLETE, [FIELD_1]: 'meh' };
|
|
119
162
|
assert.deepEqual(C.weakestSectionPayload(values), C.weakestSectionPayload(values),
|
|
120
163
|
'same words, same flag — recomputable whenever R13 needs it, so storing it would only drift');
|
|
121
164
|
for (const bad of [null, undefined, 42, [], 'text']) {
|
|
@@ -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;
|
package/tests/idea_grades.mjs
CHANGED
|
@@ -37,6 +37,7 @@ api.pool.query = async (sql, params = []) => {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
const inbox = require('../modules/ideas/inbox.js');
|
|
40
|
+
const { DEFAULT_TEMPLATE_KEY } = inbox;
|
|
40
41
|
|
|
41
42
|
let passed = 0;
|
|
42
43
|
let failed = 0;
|
|
@@ -136,7 +137,7 @@ await test('a full idea round-trips its field values', async () => {
|
|
|
136
137
|
why_it_matters: 'no re-interviewing the person who had the thought',
|
|
137
138
|
how_it_fits: 'the first shipped goal of the ABI set',
|
|
138
139
|
};
|
|
139
|
-
nextRows = [{ idea_id: '1', grade: 'full', template_key:
|
|
140
|
+
nextRows = [{ idea_id: '1', grade: 'full', template_key: DEFAULT_TEMPLATE_KEY, field_values: fields, completeness_score: null }];
|
|
140
141
|
const row = await inbox.setIdeaGrade('1', { grade: 'full', fieldValues: fields });
|
|
141
142
|
const { sql, params } = queries[0];
|
|
142
143
|
assert.match(sql, /INSERT INTO ideas_grades/i);
|
|
@@ -145,7 +146,11 @@ await test('a full idea round-trips its field values', async () => {
|
|
|
145
146
|
// handing pg a bare JS object binds it as a Postgres record, not json.
|
|
146
147
|
assert.match(sql, /\$4::jsonb/, 'field_values is not cast to jsonb');
|
|
147
148
|
assert.equal(params[3], JSON.stringify(fields));
|
|
148
|
-
|
|
149
|
+
// The DEFAULT, read off the store rather than retyped. It moved 'base-v1' ->
|
|
150
|
+
// 'base-v2' in task 1004085 (BV2.R06) and will move again; what this asserts is
|
|
151
|
+
// that setIdeaGrade stamps the CURRENT default on a row whose caller named no
|
|
152
|
+
// template, which is the claim that stays true across every vintage.
|
|
153
|
+
assert.equal(params[2], DEFAULT_TEMPLATE_KEY, `the template key did not default to ${DEFAULT_TEMPLATE_KEY}`);
|
|
149
154
|
assert.deepEqual(row.field_values, fields);
|
|
150
155
|
});
|
|
151
156
|
|
|
@@ -25,7 +25,7 @@ import { createRequire } from 'node:module';
|
|
|
25
25
|
import fs from 'node:fs';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
|
-
import { assertCannotSwallow } from './helpers.mjs';
|
|
28
|
+
import { assertCannotSwallow, namesFieldKey } from './helpers.mjs';
|
|
29
29
|
|
|
30
30
|
const require = createRequire(import.meta.url);
|
|
31
31
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -34,7 +34,7 @@ const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
|
34
34
|
const api = require('../src/module-api.js');
|
|
35
35
|
api.pool.query = async () => ({ rows: [] });
|
|
36
36
|
|
|
37
|
-
const { BASE_TEMPLATE, fieldOrder } = require('../modules/ideas/templates.js');
|
|
37
|
+
const { BASE_TEMPLATE, BUILTIN_TEMPLATES, fieldOrder } = require('../modules/ideas/templates.js');
|
|
38
38
|
const { scoreFieldValues } = require('../modules/ideas/completeness.js');
|
|
39
39
|
const routesFactory = require('../modules/ideas/routes/inbox.js');
|
|
40
40
|
const router = routesFactory({});
|
|
@@ -81,9 +81,34 @@ async function test(name, fn) {
|
|
|
81
81
|
// --- THE done-when: the page carries no copy of the rules ----------------
|
|
82
82
|
|
|
83
83
|
await test('the PAGE names no field key — a hard-coded form is a forked template', () => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
// EVERY vintage, not just the current one: a page that forked base-v1's five
|
|
85
|
+
// is just as forked for having been left behind by the default moving on.
|
|
86
|
+
for (const t of Object.values(BUILTIN_TEMPLATES)) {
|
|
87
|
+
for (const f of t.fields) {
|
|
88
|
+
assert.ok(!namesFieldKey(IDEAS_JS, f.key), `ideas.js hard-codes the field key "${f.key}"`);
|
|
89
|
+
assert.ok(!namesFieldKey(IDEAS_HTML, f.key), `ideas.html hard-codes the field key "${f.key}"`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await test('the key test can still SEE a hard-coded field — it is not vacuous', () => {
|
|
95
|
+
// The predicate above got laxer to stop a false positive, so it is probed
|
|
96
|
+
// against sources that really do fork the template. A guard that admits
|
|
97
|
+
// everything reads exactly like a guard that finds nothing wrong.
|
|
98
|
+
const key = BASE_TEMPLATE.fields[0].key;
|
|
99
|
+
for (const forked of [
|
|
100
|
+
`const FIELDS = ['${key}', 'other'];`,
|
|
101
|
+
`<textarea name="${key}"></textarea>`,
|
|
102
|
+
`<textarea name=${key}></textarea>`,
|
|
103
|
+
`el.value = values.${key};`,
|
|
104
|
+
]) {
|
|
105
|
+
assert.ok(namesFieldKey(forked, key), `a forked form spelled as \`${forked}\` must be caught`);
|
|
106
|
+
}
|
|
107
|
+
for (const innocent of [
|
|
108
|
+
'// the suggester is scoped per version',
|
|
109
|
+
'// The protected-scope wall. The server wrote the reason',
|
|
110
|
+
]) {
|
|
111
|
+
assert.ok(!namesFieldKey(innocent, 'scope'), `prose must not read as a forked form: ${innocent}`);
|
|
87
112
|
}
|
|
88
113
|
});
|
|
89
114
|
|
|
@@ -197,6 +222,21 @@ await test('the template read returns the R141 declaration, in fieldOrder order'
|
|
|
197
222
|
}
|
|
198
223
|
});
|
|
199
224
|
|
|
225
|
+
await test('the PREVIEW FIXTURE is the template the route actually returns', async () => {
|
|
226
|
+
// scripts/hall-preview/fixtures/inbox__template.json is a hand-written copy of
|
|
227
|
+
// this route's answer — the harness has no server behind it — so it is exactly
|
|
228
|
+
// the kind of file that keeps demoing a retired vintage after the questions
|
|
229
|
+
// change. It did: task 1004085 (BV2.R06) moved the default to base-v2 and the
|
|
230
|
+
// fixture still described base-v1's five, which would have had every composer
|
|
231
|
+
// render in the harness show questions the live page no longer asks. The
|
|
232
|
+
// module's own note calls this class of gap out — a page that only ever
|
|
233
|
+
// renders the wrong state cannot be looked at.
|
|
234
|
+
const fixture = JSON.parse(fs.readFileSync(
|
|
235
|
+
path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'inbox__template.json'), 'utf8'));
|
|
236
|
+
const live = (await call(getTemplate)).payload;
|
|
237
|
+
assert.deepEqual(fixture, live, 'refresh the fixture from GET /inbox/template');
|
|
238
|
+
});
|
|
239
|
+
|
|
200
240
|
await test('an unknown template is a 404, not an empty form', async () => {
|
|
201
241
|
const r = await call(getTemplate, { query: { template_key: 'no-such-template' } });
|
|
202
242
|
assert.equal(r.status, 404);
|
|
@@ -33,6 +33,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
33
33
|
import fs from 'node:fs';
|
|
34
34
|
import path from 'node:path';
|
|
35
35
|
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { namesFieldKey } from './helpers.mjs';
|
|
36
37
|
|
|
37
38
|
const require = createRequire(import.meta.url);
|
|
38
39
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -43,7 +44,7 @@ const SKILL = fs.readFileSync(path.join(ROOT, 'docs/packs/ideator.md'), 'utf8');
|
|
|
43
44
|
// reflow that changed nothing. Structural assertions (code fences, list markers)
|
|
44
45
|
// still use SKILL itself.
|
|
45
46
|
const NORM = SKILL.replace(/\s+/g, ' ');
|
|
46
|
-
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
47
|
+
const { BASE_TEMPLATE, BUILTIN_TEMPLATES } = require('../modules/ideas/templates.js');
|
|
47
48
|
|
|
48
49
|
let passed = 0;
|
|
49
50
|
let failed = 0;
|
|
@@ -145,9 +146,17 @@ test('the builder blesses it; the skill files it', () => {
|
|
|
145
146
|
test('the skill names NO field key and NO field label', () => {
|
|
146
147
|
// Same rule the hall composer obeys (R152): the questions are declared once, in
|
|
147
148
|
// the R141 template, and a copy here would silently ignore a per-project one.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
// Every vintage, not just the current one — a skill that restates base-v1's
|
|
150
|
+
// five is just as forked for having been left behind by the default moving on.
|
|
151
|
+
// `namesFieldKey` rather than `includes`: base-v2's `scope` is an ordinary
|
|
152
|
+
// English word this skill uses in prose, and a substring match calls that a
|
|
153
|
+
// restatement (see the helper's own note). The LABEL is a whole sentence, so
|
|
154
|
+
// there `includes` is still the right test.
|
|
155
|
+
for (const t of Object.values(BUILTIN_TEMPLATES)) {
|
|
156
|
+
for (const f of t.fields) {
|
|
157
|
+
assert.ok(!namesFieldKey(SKILL, f.key), `restates the field key "${f.key}"`);
|
|
158
|
+
assert.ok(!SKILL.includes(f.label), `restates the label "${f.label}"`);
|
|
159
|
+
}
|
|
151
160
|
}
|
|
152
161
|
});
|
|
153
162
|
|
package/tests/idea_resubmit.mjs
CHANGED
|
@@ -48,7 +48,7 @@ api.pool.query = async (sql, params = []) => {
|
|
|
48
48
|
return { rows: [] };
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
51
|
+
const { BASE_TEMPLATE, BASE_V1_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
52
52
|
const routesFactory = require('../modules/ideas/routes/inbox.js');
|
|
53
53
|
const router = routesFactory({});
|
|
54
54
|
const layer = router.stack.find((l) => l.route && l.route.path === '/inbox/:id/resubmit' && l.route.methods.post);
|
|
@@ -75,9 +75,22 @@ function call(bodyIn, { builderId = '9', id = '77' } = {}) {
|
|
|
75
75
|
const FULL = Object.fromEntries(
|
|
76
76
|
BASE_TEMPLATE.fields.map((f) => [f.key, `A substantive answer for ${f.key}, long enough to clear the floor.`]),
|
|
77
77
|
);
|
|
78
|
+
// A stored idea written against whatever vintage is current — `FULL` above is
|
|
79
|
+
// derived from the same declaration, so the pair stays consistent as the default
|
|
80
|
+
// moves. The PREVIOUS vintage gets its own case at the bottom of this file
|
|
81
|
+
// (task 1004085 / BV2.R06), because resubmitting an old idea is precisely the
|
|
82
|
+
// path that breaks if this route ever reaches for the default instead of the
|
|
83
|
+
// row's own `template_key`.
|
|
84
|
+
// Two fields named BY POSITION. Nothing below is a claim about which words the
|
|
85
|
+
// current template uses — the cases are about a flagged section being reworked
|
|
86
|
+
// or not — so naming them positionally keeps the suite from reddening every time
|
|
87
|
+
// the questions change (task 1004085 / BV2.R06 reddened four cases that spelled
|
|
88
|
+
// `purpose` and `big_picture` while asserting nothing about either).
|
|
89
|
+
const [FIELD_1, FIELD_2] = BASE_TEMPLATE.fields.map((f) => f.key);
|
|
90
|
+
|
|
78
91
|
const fullIdea = (over = {}) => ({
|
|
79
92
|
id: '77', title: 'T', status: 'open', captured_by: '9',
|
|
80
|
-
grade: 'full', template_key:
|
|
93
|
+
grade: 'full', template_key: BASE_TEMPLATE.key, field_values: { ...FULL, fit: 'thin' }, completeness_score: 55,
|
|
81
94
|
...over,
|
|
82
95
|
});
|
|
83
96
|
|
|
@@ -106,11 +119,11 @@ await test('a quick idea cannot resubmit — the Full Idea loop only', async ()
|
|
|
106
119
|
|
|
107
120
|
await test('a board objection outranks the mechanical flag, and an ANSWERED section reads changed:true', async () => {
|
|
108
121
|
storedIdea = fullIdea();
|
|
109
|
-
rejectionAnswer = { source: 'board', item_id: '30', section_key:
|
|
110
|
-
const revised = { ...FULL,
|
|
122
|
+
rejectionAnswer = { source: 'board', item_id: '30', section_key: FIELD_1, reason_md: 'that section is vague', closed_at: 'T' };
|
|
123
|
+
const revised = { ...FULL, [FIELD_1]: 'A completely reworked answer that meets the objection head-on.' };
|
|
111
124
|
const r = await call({ field_values: revised });
|
|
112
125
|
assert.equal(r.status, 200);
|
|
113
|
-
assert.equal(r.payload.flagged_section,
|
|
126
|
+
assert.equal(r.payload.flagged_section, FIELD_1, 'the human objection wins over the weakest-section flag');
|
|
114
127
|
assert.equal(r.payload.flagged_section_changed, true);
|
|
115
128
|
assert.equal(r.payload.rejection_source, 'board');
|
|
116
129
|
// The pass rule rides along with the deadline (task 1003654): a clockless
|
|
@@ -122,8 +135,8 @@ await test('a board objection outranks the mechanical flag, and an ANSWERED sect
|
|
|
122
135
|
|
|
123
136
|
await test('reworking everything EXCEPT the flagged section reads changed:false', async () => {
|
|
124
137
|
storedIdea = fullIdea();
|
|
125
|
-
rejectionAnswer = { source: 'board', item_id: '30', section_key:
|
|
126
|
-
const revised = Object.fromEntries(Object.entries(FULL).map(([k, v]) => [k, k ===
|
|
138
|
+
rejectionAnswer = { source: 'board', item_id: '30', section_key: FIELD_1, reason_md: 'r', closed_at: 'T' };
|
|
139
|
+
const revised = Object.fromEntries(Object.entries(FULL).map(([k, v]) => [k, k === FIELD_1 ? v : `${v} — now even better.`]));
|
|
127
140
|
storedIdea = fullIdea({ field_values: FULL });
|
|
128
141
|
const r = await call({ field_values: revised });
|
|
129
142
|
assert.equal(r.payload.flagged_section_changed, false, 'told plainly: the objected-to section did not move');
|
|
@@ -142,21 +155,47 @@ await test('with no board return, the sub-bar weakest-section flag is the fallba
|
|
|
142
155
|
await test('a still-sub-bar resubmission gets FRESH feedback and no window', async () => {
|
|
143
156
|
storedIdea = fullIdea();
|
|
144
157
|
windowAnswer = { opened: false, reason: 'below_bar' };
|
|
145
|
-
const stillThin = { ...FULL,
|
|
158
|
+
const stillThin = { ...FULL, [FIELD_2]: 'x' }; // the new weakest section
|
|
146
159
|
const r = await call({ field_values: stillThin });
|
|
147
160
|
assert.equal(r.status, 200);
|
|
148
161
|
assert.equal(r.payload.board_window, undefined);
|
|
149
|
-
assert.equal(r.payload.idea.rejection.section_key,
|
|
162
|
+
assert.equal(r.payload.idea.rejection.section_key, FIELD_2, 'the NEW weakest section, not the old flag');
|
|
150
163
|
});
|
|
151
164
|
|
|
152
165
|
await test('an invalid field set is a 400 naming the field, before any write', async () => {
|
|
153
166
|
storedIdea = fullIdea();
|
|
154
|
-
const r = await call({ field_values: { ...FULL,
|
|
167
|
+
const r = await call({ field_values: { ...FULL, [FIELD_1]: 42 } });
|
|
155
168
|
assert.equal(r.status, 400);
|
|
156
169
|
assert.equal(r.payload.error, 'invalid_field_values');
|
|
157
170
|
assert.ok(!queries.some((q) => /INSERT INTO ideas_grades/i.test(q.sql)));
|
|
158
171
|
});
|
|
159
172
|
|
|
173
|
+
// ── an idea filed under an EARLIER vintage still resubmits (task 1004085) ────
|
|
174
|
+
|
|
175
|
+
await test('a base-v1 idea resubmits against base-v1, not against the current default', async () => {
|
|
176
|
+
// The whole reason the questions are versioned rather than edited. This route
|
|
177
|
+
// validates the submitted answers against the template the ROW names; if it
|
|
178
|
+
// ever read the default instead, every Full Idea filed before BV2.R06 would
|
|
179
|
+
// 400 on `invalid_field_values` the moment its author tried to answer the
|
|
180
|
+
// board's objection — the one action a returned idea exists to allow.
|
|
181
|
+
const v1Values = Object.fromEntries(
|
|
182
|
+
BASE_V1_TEMPLATE.fields.map((f) => [f.key, `A substantive v1 answer for ${f.key}, well past the floor.`]),
|
|
183
|
+
);
|
|
184
|
+
storedIdea = fullIdea({ template_key: 'base-v1', field_values: { ...v1Values, fit: 'thin' } });
|
|
185
|
+
rejectionAnswer = null;
|
|
186
|
+
const r = await call({ field_values: { ...v1Values, fit: 'A fit section with real substance now, past the floor.' } });
|
|
187
|
+
assert.equal(r.status, 200, 'a v1 payload against a v1 row is valid');
|
|
188
|
+
assert.equal(r.payload.flagged_section_changed, true);
|
|
189
|
+
|
|
190
|
+
// …and the CURRENT vintage's answers are not valid on that row, which is what
|
|
191
|
+
// makes the assertion above about the row's own template rather than about the
|
|
192
|
+
// validator having gone quiet.
|
|
193
|
+
storedIdea = fullIdea({ template_key: 'base-v1', field_values: v1Values });
|
|
194
|
+
const wrong = await call({ field_values: FULL });
|
|
195
|
+
assert.equal(wrong.status, 400, 'base-v2 answers are not a base-v1 Full Idea');
|
|
196
|
+
assert.equal(wrong.payload.error, 'invalid_field_values');
|
|
197
|
+
});
|
|
198
|
+
|
|
160
199
|
await test('a missing idea is 404', async () => {
|
|
161
200
|
storedIdea = null;
|
|
162
201
|
const r = await call({ field_values: FULL });
|
|
@@ -285,7 +285,9 @@ await test('the develop draft is stored under its OWN key', async () => {
|
|
|
285
285
|
await settle(); await settle(); await settle();
|
|
286
286
|
r.fill();
|
|
287
287
|
// The draft write is debounced behind an input event; call the saver's trigger.
|
|
288
|
-
|
|
288
|
+
// The first field of whatever vintage is current — the claim is about the
|
|
289
|
+
// DRAFT KEY, not about which question happens to sit at the top of the form.
|
|
290
|
+
r.el(`fi-f-${TEMPLATE.fields[0].key}`)._handlers.input();
|
|
289
291
|
await new Promise((res) => setTimeout(res, 600));
|
|
290
292
|
const keys = Object.keys(store);
|
|
291
293
|
assert.ok(keys.length >= 1, `something was stored (${JSON.stringify(keys)})`);
|
package/tests/idea_templates.mjs
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
// tests/idea_templates.mjs
|
|
2
2
|
//
|
|
3
3
|
// task 1002917 (BV1.R141, goal 1000062) — the Full Idea base template.
|
|
4
|
+
// task 1004085 (BV2.R06, goal 1000095) — the questions, VERSIONED: `base-v2`
|
|
5
|
+
// beside `base-v1`, and the §vintages block at the bottom.
|
|
4
6
|
//
|
|
5
7
|
// The claim these tests protect: THE TEMPLATE IS DATA. Validation follows the
|
|
6
8
|
// declaration, order comes from the declaration, and swapping the registry
|
|
7
9
|
// changes what is accepted with no code change — that is the task's done-when,
|
|
8
10
|
// and the "swap" test at the bottom is the proof (the R156 per-project seam).
|
|
9
11
|
//
|
|
12
|
+
// THE SECOND CLAIM, ADDED BY BV2.R06: an idea is scored on the questions it was
|
|
13
|
+
// actually asked. That is a statement about ALREADY-STORED rows, so the tests
|
|
14
|
+
// that matter most here are the ones that feed a v1 payload and watch it keep
|
|
15
|
+
// validating after the default moved past it. A suite written only against
|
|
16
|
+
// "the current template" would pass with base-v1 deleted from the registry.
|
|
17
|
+
//
|
|
10
18
|
// Pure module — no DB, no doorway, no harness. inbox.js is required transitively
|
|
11
19
|
// (templates.js reads DEFAULT_TEMPLATE_KEY / FIELD_VALUES_MAX from it), and it
|
|
12
20
|
// destructures `pool` off the doorway at require time without querying, so a
|
|
@@ -28,35 +36,87 @@ function test(name, fn) {
|
|
|
28
36
|
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
29
37
|
}
|
|
30
38
|
|
|
31
|
-
// A complete, well-formed
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
// A complete, well-formed payload for a template — DERIVED from the declaration
|
|
40
|
+
// rather than typed out, so a suite about "the template is data" is not itself a
|
|
41
|
+
// second hand-maintained copy of the fields.
|
|
42
|
+
function payloadFor(template) {
|
|
43
|
+
return Object.fromEntries(template.fields.map((f, i) =>
|
|
44
|
+
[f.key, `A real answer for ${f.key}, number ${i + 1}, long enough to read like one.`]));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The CURRENT vintage — what an unqualified validate() call is about.
|
|
48
|
+
const FULL = payloadFor(T.BASE_V2_TEMPLATE);
|
|
49
|
+
// The PREVIOUS vintage — what hundreds of stored rows still carry.
|
|
50
|
+
const FULL_V1 = payloadFor(T.BASE_V1_TEMPLATE);
|
|
39
51
|
|
|
40
52
|
// ── the declaration ─────────────────────────────────────────────────────────
|
|
41
53
|
|
|
42
54
|
test('base-v1 declares exactly the five owner fields, in brief order', () => {
|
|
43
|
-
assert.
|
|
44
|
-
assert.deepEqual(T.fieldOrder(T.BASE_TEMPLATE),
|
|
55
|
+
assert.deepEqual(T.fieldOrder(T.BASE_V1_TEMPLATE),
|
|
45
56
|
['purpose', 'big_picture', 'why_had', 'why_matters', 'fit']);
|
|
46
|
-
for (const f of T.
|
|
57
|
+
for (const f of T.BASE_V1_TEMPLATE.fields) {
|
|
47
58
|
assert.ok(f.label && f.required === true, `${f.key} carries a label and is required`);
|
|
48
59
|
assert.ok(Number.isInteger(f.substanceMin) && f.substanceMin > 0,
|
|
49
60
|
`${f.key} declares the substance floor the R142 scorer reads`);
|
|
50
61
|
}
|
|
51
62
|
});
|
|
52
63
|
|
|
64
|
+
test('base-v2 declares the owner\'s new five, in order, and IS the store default', () => {
|
|
65
|
+
assert.equal(T.BASE_TEMPLATE, T.BASE_V2_TEMPLATE, 'BASE_TEMPLATE aliases the current vintage');
|
|
66
|
+
assert.equal(T.BASE_TEMPLATE.key, DEFAULT_TEMPLATE_KEY, 'the current key IS the store default');
|
|
67
|
+
assert.equal(DEFAULT_TEMPLATE_KEY, 'base-v2');
|
|
68
|
+
assert.deepEqual(T.fieldOrder(T.BASE_V2_TEMPLATE),
|
|
69
|
+
['why_had', 'scope', 'build_context', 'fit', 'success']);
|
|
70
|
+
for (const f of T.BASE_V2_TEMPLATE.fields) {
|
|
71
|
+
assert.ok(f.label && f.required === true, `${f.key} carries a label and is required`);
|
|
72
|
+
assert.ok(Number.isInteger(f.substanceMin) && f.substanceMin > 0,
|
|
73
|
+
`${f.key} declares the substance floor the R142 scorer reads`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
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
|
+
|
|
53
111
|
test('the declaration is frozen — a consumer cannot fork it in place', () => {
|
|
54
112
|
assert.throws(() => { T.BASE_TEMPLATE.fields.push({ key: 'rogue' }); });
|
|
113
|
+
assert.throws(() => { T.BASE_V1_TEMPLATE.fields.push({ key: 'rogue' }); });
|
|
55
114
|
assert.throws(() => { 'use strict'; T.BUILTIN_TEMPLATES['base-v1'] = null; });
|
|
56
115
|
});
|
|
57
116
|
|
|
58
|
-
test('templateFor:
|
|
59
|
-
assert.equal(T.templateFor('base-v1'), T.
|
|
117
|
+
test('templateFor: both vintages resolve, unknown/blank/non-string do not', () => {
|
|
118
|
+
assert.equal(T.templateFor('base-v1'), T.BASE_V1_TEMPLATE);
|
|
119
|
+
assert.equal(T.templateFor('base-v2'), T.BASE_V2_TEMPLATE);
|
|
60
120
|
assert.equal(T.templateFor('no-such-template'), null);
|
|
61
121
|
assert.equal(T.templateFor(''), null);
|
|
62
122
|
assert.equal(T.templateFor(null), null);
|
|
@@ -97,8 +157,8 @@ test('a missing or blank required field is named, per field', () => {
|
|
|
97
157
|
const { fit, ...four } = FULL;
|
|
98
158
|
let r = T.validateFieldValues(four);
|
|
99
159
|
assert.deepEqual(r.errors, [{ field: 'fit', reason: 'missing_required' }]);
|
|
100
|
-
r = T.validateFieldValues({ ...FULL,
|
|
101
|
-
assert.deepEqual(r.errors, [{ field: '
|
|
160
|
+
r = T.validateFieldValues({ ...FULL, scope: ' ' });
|
|
161
|
+
assert.deepEqual(r.errors, [{ field: 'scope', reason: 'missing_required' }]);
|
|
102
162
|
});
|
|
103
163
|
|
|
104
164
|
test('a non-string value is refused once, not double-reported', () => {
|
|
@@ -115,7 +175,7 @@ test('a non-object payload and an unknown template are structured refusals', ()
|
|
|
115
175
|
});
|
|
116
176
|
|
|
117
177
|
test('a payload over the store byte cap is a named refusal, not a DB 500', () => {
|
|
118
|
-
const r = T.validateFieldValues({ ...FULL,
|
|
178
|
+
const r = T.validateFieldValues({ ...FULL, scope: 'x'.repeat(40000) });
|
|
119
179
|
assert.deepEqual(r.errors, [{ field: null, reason: 'too_large' }]);
|
|
120
180
|
});
|
|
121
181
|
|
|
@@ -125,6 +185,59 @@ test('substance is scored, not gated: a thin answer still validates', () => {
|
|
|
125
185
|
'scoring never blocks capture — substanceMin is the scorer\'s, not the validator\'s');
|
|
126
186
|
});
|
|
127
187
|
|
|
188
|
+
// ── vintages: an idea is judged on the questions it was asked (BV2.R06) ─────
|
|
189
|
+
|
|
190
|
+
test('an already-filed v1 payload still validates — against base-v1', () => {
|
|
191
|
+
// The whole promise of versioning rather than editing. If this ever fails,
|
|
192
|
+
// every Full Idea filed before BV2.R06 has lost its questions.
|
|
193
|
+
const r = T.validateFieldValues(FULL_V1, { templateKey: 'base-v1' });
|
|
194
|
+
assert.equal(r.ok, true, 'a stored v1 payload must keep validating forever');
|
|
195
|
+
assert.deepEqual(r.errors, []);
|
|
196
|
+
assert.equal(r.template, T.BASE_V1_TEMPLATE);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('the two vintages are NOT interchangeable — each refuses the other\'s fields', () => {
|
|
200
|
+
// Read together with the test above, this is what makes the first one mean
|
|
201
|
+
// something: v1 keeps validating because its OWN declaration is still there,
|
|
202
|
+
// not because the validator has become lax about field names.
|
|
203
|
+
const asV2 = T.validateFieldValues(FULL_V1);
|
|
204
|
+
assert.equal(asV2.ok, false, 'a v1 payload is not a v2 Full Idea');
|
|
205
|
+
const unknownUnderV2 = asV2.errors.filter((e) => e.reason === 'unknown_field').map((e) => e.field).sort();
|
|
206
|
+
assert.deepEqual(unknownUnderV2, ['big_picture', 'purpose', 'why_matters'],
|
|
207
|
+
'the three v1-only fields are refused by name under v2');
|
|
208
|
+
|
|
209
|
+
const asV1 = T.validateFieldValues(FULL, { templateKey: 'base-v1' });
|
|
210
|
+
assert.equal(asV1.ok, false, 'a v2 payload is not a v1 Full Idea');
|
|
211
|
+
const unknownUnderV1 = asV1.errors.filter((e) => e.reason === 'unknown_field').map((e) => e.field).sort();
|
|
212
|
+
assert.deepEqual(unknownUnderV1, ['build_context', 'scope', 'success'],
|
|
213
|
+
'the three v2-only fields are refused by name under v1');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('the two shared keys are shared, and their QUESTIONS are not', () => {
|
|
217
|
+
// `why_had` and `fit` are reused deliberately — that reuse is what makes this
|
|
218
|
+
// a registry entry rather than a data migration. The trap it creates is a
|
|
219
|
+
// renderer that flattens the vintages into one key->label map: it would then
|
|
220
|
+
// show one author the question a different author was asked. Different labels
|
|
221
|
+
// on the same key is the fact that forbids the flattening, so it is pinned.
|
|
222
|
+
const v1 = Object.fromEntries(T.BASE_V1_TEMPLATE.fields.map((f) => [f.key, f.label]));
|
|
223
|
+
const v2 = Object.fromEntries(T.BASE_V2_TEMPLATE.fields.map((f) => [f.key, f.label]));
|
|
224
|
+
const shared = Object.keys(v2).filter((k) => k in v1);
|
|
225
|
+
assert.deepEqual(shared.sort(), ['fit', 'why_had'], 'exactly two keys carry over');
|
|
226
|
+
for (const key of shared) {
|
|
227
|
+
assert.notEqual(v1[key], v2[key], `${key} asks a different question in each vintage`);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('a retired vintage is never dropped from the registry', () => {
|
|
232
|
+
// The load-time pin in templates.js says the same thing; asserting it here is
|
|
233
|
+
// what makes a "tidy up the old template" edit fail in the unit gate rather
|
|
234
|
+
// than at boot on somebody's instance.
|
|
235
|
+
for (const key of ['base-v1', 'base-v2']) {
|
|
236
|
+
assert.ok(Object.hasOwn(T.BUILTIN_TEMPLATES, key), `${key} stays registered`);
|
|
237
|
+
assert.ok(T.templateFor(key), `${key} still resolves`);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
128
241
|
// ── the done-when: swap the registry, behaviour follows (the R156 seam) ─────
|
|
129
242
|
|
|
130
243
|
test('a different registry changes what is accepted — no code change', () => {
|