@bongos/core 1.19.932 → 1.19.934

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.
@@ -21,21 +21,41 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
21
  // with the room onto its own page. The name stays `CLIENT` because that is what
22
22
  // each assertion below reads — this file has exactly one source.
23
23
  const CLIENT = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'board-room.js'), 'utf8');
24
- const { BASE_TEMPLATE } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
24
+ const { BASE_V1_TEMPLATE, BASE_V2_TEMPLATE } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
25
25
  const board = require(path.join(ROOT, 'modules', 'government', 'board.js'));
26
26
 
27
- test('the five section labels ARE the template declaration — key and label, in order', () => {
28
- const block = CLIENT.slice(CLIENT.indexOf('const SECTION_LABELS'), CLIENT.indexOf('function ideaSectionsHtml'));
27
+ test('the section labels ARE the template declarations — every vintage, key and label, in order', () => {
28
+ // Since task 1004085 (BV2.R06) there are TWO declarations, and the page holds
29
+ // one map per vintage. The order of the maps in the source is the order of the
30
+ // vintages here, so the flattened pair list is compared against the flattened
31
+ // declarations — which is what catches a label edited on one side only.
32
+ const block = CLIENT.slice(CLIENT.indexOf('const SECTION_LABELS_BY_TEMPLATE'), CLIENT.indexOf('function ideaSectionsHtml'));
29
33
  const declared = [...block.matchAll(/(\w+): '([^']+)'/g)].map((m) => [m[1], m[2]]);
30
- const template = BASE_TEMPLATE.fields.map((f) => [f.key, f.label]);
34
+ const template = [...BASE_V1_TEMPLATE.fields, ...BASE_V2_TEMPLATE.fields].map((f) => [f.key, f.label]);
31
35
  assert.deepEqual(declared, template,
32
- 'the voter reads the same five section names the author wrote under — held equal here because browser code cannot import the server module');
36
+ 'the voter reads the same section names the author wrote under — held equal here because browser code cannot import the server module');
37
+ });
38
+
39
+ test('the labels are keyed BY VINTAGE, never flattened into one map', () => {
40
+ // `why_had` and `fit` exist in both vintages carrying DIFFERENT questions, so a
41
+ // single merged key->label map would show one author the question a different
42
+ // author was asked. This is the assertion that forbids the tidy-up.
43
+ assert.match(CLIENT, /SECTION_LABELS_BY_TEMPLATE\s*=\s*\{[^]*'base-v1'\s*:[^]*'base-v2'\s*:/,
44
+ 'one map per template key');
45
+ assert.match(CLIENT, /item\.subject\.template_key/,
46
+ 'the vintage comes from the SUBJECT — never a page-level default');
47
+ // The shared keys really are shared and really do differ, so the test above is
48
+ // guarding something rather than restating a shape.
49
+ const v1 = new Map(BASE_V1_TEMPLATE.fields.map((f) => [f.key, f.label]));
50
+ for (const f of BASE_V2_TEMPLATE.fields) {
51
+ if (v1.has(f.key)) assert.notEqual(v1.get(f.key), f.label, `${f.key} asks differently per vintage`);
52
+ }
33
53
  });
34
54
 
35
55
  test('the idea is in front of the voter: sections render from the subject, in template order', () => {
36
56
  assert.match(CLIENT, /item\.subject\.field_values|subject && item\.subject\.field_values/,
37
57
  'the sections come from the board view payload, never a second fetch');
38
- assert.match(CLIENT, /Object\.keys\(SECTION_LABELS\)\.map/,
58
+ assert.match(CLIENT, /Object\.keys\(labels\)/,
39
59
  'rendered in the declared order — jsonb key order is never trusted');
40
60
  const card = CLIENT.slice(CLIENT.indexOf('function itemCard'), CLIENT.indexOf('async function loadBoard'));
41
61
  assert.match(card, /ideaSectionsHtml\(item\)/, 'the sections sit ON the item card, above the ballot');
@@ -41,6 +41,7 @@ const NON_GRANT_MIGRATIONS = new Set([
41
41
  'government_006_returned_items_index.sql', // R13 returned-item lookup index (task 1003026)
42
42
  'government_007_amendments.sql', // R14 amendment proposals table (task 1003027)
43
43
  'government_008_closed_items_index.sql', // R16 closed-history index (task 1003029)
44
+ 'government_017_board_section_keys_v2.sql', // BV2.R06 board section_key CHECK widening (task 1004085)
44
45
  ]);
45
46
 
46
47
  // Permission keys RENAMED after the grant migrations that first granted them.
package/tests/helpers.mjs CHANGED
@@ -976,3 +976,26 @@ export function bootSkyPage({
976
976
  },
977
977
  };
978
978
  }
979
+
980
+ // namesFieldKey — does this source USE `key` as a Full Idea field key, as
981
+ // opposed to merely containing the word? (task 1004085 / BV2.R06.)
982
+ //
983
+ // Two suites assert that a surface carries no copy of the template — the hall
984
+ // composer (`tests/idea_hall_composer.mjs`, R152) and the ideate-full skill
985
+ // (`tests/idea_ideate_full.mjs`). Both did it with `src.includes(key)`, which
986
+ // was sound while every key was a compound like `big_picture` and broke the day
987
+ // base-v2 introduced `scope`: ideas.js already said "scoped per version" and
988
+ // "the protected-scope wall", and the skill prose says it too. Both reported a
989
+ // hard-coded form that does not exist.
990
+ //
991
+ // The three shapes below are how a surface could really name a field — a string
992
+ // literal (or a markdown backtick span, which is the same characters), a
993
+ // property, or an unquoted HTML attribute value. Everything else is English.
994
+ // It lives here rather than in either suite because two copies of a predicate
995
+ // this fiddly are two chances for one to be loosened alone.
996
+ export function namesFieldKey(src, key) {
997
+ const k = String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
998
+ return new RegExp(`(['"\`])${k}\\1`).test(src)
999
+ || new RegExp(`\\.${k}\\b`).test(src)
1000
+ || new RegExp(`=\\s*${k}\\b`).test(src);
1001
+ }
@@ -32,6 +32,14 @@ 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
+
35
43
  test('a complete idea scores 100, an empty one 0', () => {
36
44
  assert.equal(C.completenessScore(COMPLETE), 100);
37
45
  assert.equal(C.completenessScore({}), 0);
@@ -44,20 +52,20 @@ test('same input, same score — byte-for-byte determinism', () => {
44
52
  });
45
53
 
46
54
  test('a thin answer earns its proportion, not the full share', () => {
47
- const oneThin = { ...COMPLETE, purpose: 'ten chars.' }; // 10 of the 40-char floor
55
+ const oneThin = { ...COMPLETE, [FIELD_1]: 'ten chars.' }; // 10 of the 40-char floor
48
56
  const s = C.completenessScore(oneThin);
49
57
  assert.ok(s > 80 && s < 100, `four full + one quarter-credit field ≈ 85, got ${s}`);
50
- const breakdown = C.scoreFieldValues(oneThin).fields.find((f) => f.key === 'purpose');
58
+ const breakdown = C.scoreFieldValues(oneThin).fields.find((f) => f.key === FIELD_1);
51
59
  assert.equal(breakdown.substance, 10);
52
60
  assert.equal(breakdown.ratio, 0.25);
53
61
  });
54
62
 
55
63
  test('padding earns exactly nothing: whitespace and blank lines are not substance', () => {
56
- const padded = { ...COMPLETE, purpose: ' \n\n\t \n \n\n ' };
57
- const b = C.scoreFieldValues(padded).fields.find((f) => f.key === 'purpose');
64
+ const padded = { ...COMPLETE, [FIELD_1]: ' \n\n\t \n \n\n ' };
65
+ const b = C.scoreFieldValues(padded).fields.find((f) => f.key === FIELD_1);
58
66
  assert.equal(b.substance, 0, 'a page of blank lines is the nothing it is');
59
- const spaced = { ...COMPLETE, purpose: 'a b\n\n\nc d e' }; // collapses to 9
60
- const b2 = C.scoreFieldValues(spaced).fields.find((f) => f.key === 'purpose');
67
+ const spaced = { ...COMPLETE, [FIELD_1]: 'a b\n\n\nc d e' }; // collapses to 9
68
+ const b2 = C.scoreFieldValues(spaced).fields.find((f) => f.key === FIELD_1);
61
69
  assert.equal(b2.substance, 9, 'runs of whitespace collapse to one');
62
70
  });
63
71
 
@@ -68,7 +76,13 @@ test('one missing field costs exactly its equal share', () => {
68
76
 
69
77
  test('the breakdown comes back in TEMPLATE order, ready to render', () => {
70
78
  const keys = C.scoreFieldValues(COMPLETE).fields.map((f) => f.key);
71
- assert.deepEqual(keys, ['purpose', 'big_picture', 'why_had', 'why_matters', 'fit']);
79
+ assert.deepEqual(keys, BASE_TEMPLATE.fields.map((f) => f.key));
80
+ // The claim is that the breakdown follows the DECLARATION, not that it matches
81
+ // some other list derived the same way — so it is also checked against a shuffled
82
+ // input, which is the only thing that distinguishes declaration order from
83
+ // insertion order.
84
+ const shuffled = Object.fromEntries(Object.entries(COMPLETE).reverse());
85
+ assert.deepEqual(C.scoreFieldValues(shuffled).fields.map((f) => f.key), keys);
72
86
  });
73
87
 
74
88
  test('it never throws: malformed input is a null score, not an exception', () => {
@@ -77,7 +91,7 @@ test('it never throws: malformed input is a null score, not an exception', () =>
77
91
  }
78
92
  assert.equal(C.completenessScore(COMPLETE, { templateKey: 'no-such-template' }), null);
79
93
  // a non-string FIELD does not throw either — it just earns nothing
80
- assert.equal(typeof C.completenessScore({ ...COMPLETE, purpose: 42 }), 'number');
94
+ assert.equal(typeof C.completenessScore({ ...COMPLETE, [FIELD_1]: 42 }), 'number');
81
95
  });
82
96
 
83
97
  test('the scorer walks the declaration — a different template scores by ITS fields', () => {
@@ -97,25 +111,28 @@ test('the scorer walks the declaration — a different template scores by ITS fi
97
111
  // ── the sub-bar rejection payload (BV1.R09 of goal 1000069, criterion C2) ───
98
112
 
99
113
  test('the weakest section is the LOWEST ratio, with ties going to template order', () => {
100
- const values = { ...COMPLETE, big_picture: 'thin', why_matters: 'thin' }; // two equally thin
114
+ const values = { ...COMPLETE, [FIELD_2]: 'thin', [FIELD_4]: 'thin' }; // two equally thin
101
115
  const worst = C.weakestField(values);
102
- assert.equal(worst.key, 'big_picture', 'the earlier of two equal-thinnest fields, by template order');
116
+ assert.equal(worst.key, FIELD_2, 'the earlier of two equal-thinnest fields, by template order');
103
117
  // strictly-thinnest wins regardless of position
104
118
  assert.equal(C.weakestField({ ...COMPLETE, fit: 'x' }).key, 'fit');
105
119
  });
106
120
 
107
121
  test('the rejection payload is board-objection-shaped, names the template LABEL, and states the shortfall', () => {
108
122
  const flag = C.weakestSectionPayload({ ...COMPLETE, fit: 'too thin' });
123
+ const field = BASE_TEMPLATE.fields.find((f) => f.key === 'fit');
109
124
  assert.equal(flag.section_key, 'fit', 'exactly one of the five section keys — the board-vote shape');
110
- assert.ok(flag.reason_md.includes('How does the Idea Fit'), 'the reason speaks the words the author saw');
125
+ // The LABEL, read off the template rather than retyped: the claim is that the
126
+ // reason speaks the words the author actually saw, and those words now depend
127
+ // on which vintage the author wrote under.
128
+ assert.ok(flag.reason_md.includes(field.label), 'the reason speaks the words the author saw');
111
129
  assert.ok(/\d+ characters? of substance against a floor of \d+/.test(flag.reason_md),
112
130
  'the shortfall is stated in the scorer\'s own honest terms');
113
- const field = BASE_TEMPLATE.fields.find((f) => f.key === 'fit');
114
131
  assert.ok(flag.reason_md.includes(String(field.substanceMin)), 'the floor named is the template\'s real one');
115
132
  });
116
133
 
117
134
  test('the payload is DERIVED, deterministic, and null on unscorable input — never stored, never throws', () => {
118
- const values = { ...COMPLETE, purpose: 'meh' };
135
+ const values = { ...COMPLETE, [FIELD_1]: 'meh' };
119
136
  assert.deepEqual(C.weakestSectionPayload(values), C.weakestSectionPayload(values),
120
137
  'same words, same flag — recomputable whenever R13 needs it, so storing it would only drift');
121
138
  for (const bad of [null, undefined, 42, [], 'text']) {
@@ -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: 'base-v1', field_values: fields, completeness_score: null }];
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
- assert.equal(params[2], 'base-v1', 'the template key did not default to base-v1');
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
- for (const f of BASE_TEMPLATE.fields) {
85
- assert.ok(!IDEAS_JS.includes(f.key), `ideas.js hard-codes the field key "${f.key}"`);
86
- assert.ok(!IDEAS_HTML.includes(f.key), `ideas.html hard-codes the field key "${f.key}"`);
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
- for (const f of BASE_TEMPLATE.fields) {
149
- assert.ok(!SKILL.includes(f.key), `restates the field key "${f.key}"`);
150
- assert.ok(!SKILL.includes(f.label), `restates the label "${f.label}"`);
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
 
@@ -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: 'base-v1', field_values: { ...FULL, fit: 'thin' }, completeness_score: 55,
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: 'purpose', reason_md: 'purpose is vague', closed_at: 'T' };
110
- const revised = { ...FULL, purpose: 'A completely reworked purpose that answers the objection head-on.' };
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, 'purpose', 'the human objection wins over the weakest-section flag');
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: 'purpose', reason_md: 'r', closed_at: 'T' };
126
- const revised = Object.fromEntries(Object.entries(FULL).map(([k, v]) => [k, k === 'purpose' ? v : `${v} — now even better.`]));
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, big_picture: 'x' }; // new weakest is big_picture
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, 'big_picture', 'the NEW weakest section, not the old flag');
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, purpose: 42 } });
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
- r.el('fi-f-purpose')._handlers.input();
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)})`);
@@ -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,22 +36,38 @@ 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 base payload — reused across tests.
32
- const FULL = {
33
- purpose: 'A concrete purpose, written out.',
34
- big_picture: 'What the world looks like once this exists.',
35
- why_had: 'The observation that sparked it.',
36
- why_matters: 'Why anyone should spend hours on it.',
37
- fit: 'Where it sits in the project arc.',
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.equal(T.BASE_TEMPLATE.key, DEFAULT_TEMPLATE_KEY, 'the base key IS the store default');
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.BASE_TEMPLATE.fields) {
57
+ for (const f of T.BASE_V1_TEMPLATE.fields) {
58
+ assert.ok(f.label && f.required === true, `${f.key} carries a label and is required`);
59
+ assert.ok(Number.isInteger(f.substanceMin) && f.substanceMin > 0,
60
+ `${f.key} declares the substance floor the R142 scorer reads`);
61
+ }
62
+ });
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) {
47
71
  assert.ok(f.label && f.required === true, `${f.key} carries a label and is required`);
48
72
  assert.ok(Number.isInteger(f.substanceMin) && f.substanceMin > 0,
49
73
  `${f.key} declares the substance floor the R142 scorer reads`);
@@ -52,11 +76,13 @@ test('base-v1 declares exactly the five owner fields, in brief order', () => {
52
76
 
53
77
  test('the declaration is frozen — a consumer cannot fork it in place', () => {
54
78
  assert.throws(() => { T.BASE_TEMPLATE.fields.push({ key: 'rogue' }); });
79
+ assert.throws(() => { T.BASE_V1_TEMPLATE.fields.push({ key: 'rogue' }); });
55
80
  assert.throws(() => { 'use strict'; T.BUILTIN_TEMPLATES['base-v1'] = null; });
56
81
  });
57
82
 
58
- test('templateFor: base key resolves, unknown/blank/non-string do not', () => {
59
- assert.equal(T.templateFor('base-v1'), T.BASE_TEMPLATE);
83
+ test('templateFor: both vintages resolve, unknown/blank/non-string do not', () => {
84
+ assert.equal(T.templateFor('base-v1'), T.BASE_V1_TEMPLATE);
85
+ assert.equal(T.templateFor('base-v2'), T.BASE_V2_TEMPLATE);
60
86
  assert.equal(T.templateFor('no-such-template'), null);
61
87
  assert.equal(T.templateFor(''), null);
62
88
  assert.equal(T.templateFor(null), null);
@@ -97,8 +123,8 @@ test('a missing or blank required field is named, per field', () => {
97
123
  const { fit, ...four } = FULL;
98
124
  let r = T.validateFieldValues(four);
99
125
  assert.deepEqual(r.errors, [{ field: 'fit', reason: 'missing_required' }]);
100
- r = T.validateFieldValues({ ...FULL, purpose: ' ' });
101
- assert.deepEqual(r.errors, [{ field: 'purpose', reason: 'missing_required' }]);
126
+ r = T.validateFieldValues({ ...FULL, scope: ' ' });
127
+ assert.deepEqual(r.errors, [{ field: 'scope', reason: 'missing_required' }]);
102
128
  });
103
129
 
104
130
  test('a non-string value is refused once, not double-reported', () => {
@@ -115,7 +141,7 @@ test('a non-object payload and an unknown template are structured refusals', ()
115
141
  });
116
142
 
117
143
  test('a payload over the store byte cap is a named refusal, not a DB 500', () => {
118
- const r = T.validateFieldValues({ ...FULL, purpose: 'x'.repeat(40000) });
144
+ const r = T.validateFieldValues({ ...FULL, scope: 'x'.repeat(40000) });
119
145
  assert.deepEqual(r.errors, [{ field: null, reason: 'too_large' }]);
120
146
  });
121
147
 
@@ -125,6 +151,59 @@ test('substance is scored, not gated: a thin answer still validates', () => {
125
151
  'scoring never blocks capture — substanceMin is the scorer\'s, not the validator\'s');
126
152
  });
127
153
 
154
+ // ── vintages: an idea is judged on the questions it was asked (BV2.R06) ─────
155
+
156
+ test('an already-filed v1 payload still validates — against base-v1', () => {
157
+ // The whole promise of versioning rather than editing. If this ever fails,
158
+ // every Full Idea filed before BV2.R06 has lost its questions.
159
+ const r = T.validateFieldValues(FULL_V1, { templateKey: 'base-v1' });
160
+ assert.equal(r.ok, true, 'a stored v1 payload must keep validating forever');
161
+ assert.deepEqual(r.errors, []);
162
+ assert.equal(r.template, T.BASE_V1_TEMPLATE);
163
+ });
164
+
165
+ test('the two vintages are NOT interchangeable — each refuses the other\'s fields', () => {
166
+ // Read together with the test above, this is what makes the first one mean
167
+ // something: v1 keeps validating because its OWN declaration is still there,
168
+ // not because the validator has become lax about field names.
169
+ const asV2 = T.validateFieldValues(FULL_V1);
170
+ assert.equal(asV2.ok, false, 'a v1 payload is not a v2 Full Idea');
171
+ const unknownUnderV2 = asV2.errors.filter((e) => e.reason === 'unknown_field').map((e) => e.field).sort();
172
+ assert.deepEqual(unknownUnderV2, ['big_picture', 'purpose', 'why_matters'],
173
+ 'the three v1-only fields are refused by name under v2');
174
+
175
+ const asV1 = T.validateFieldValues(FULL, { templateKey: 'base-v1' });
176
+ assert.equal(asV1.ok, false, 'a v2 payload is not a v1 Full Idea');
177
+ const unknownUnderV1 = asV1.errors.filter((e) => e.reason === 'unknown_field').map((e) => e.field).sort();
178
+ assert.deepEqual(unknownUnderV1, ['build_context', 'scope', 'success'],
179
+ 'the three v2-only fields are refused by name under v1');
180
+ });
181
+
182
+ test('the two shared keys are shared, and their QUESTIONS are not', () => {
183
+ // `why_had` and `fit` are reused deliberately — that reuse is what makes this
184
+ // a registry entry rather than a data migration. The trap it creates is a
185
+ // renderer that flattens the vintages into one key->label map: it would then
186
+ // show one author the question a different author was asked. Different labels
187
+ // on the same key is the fact that forbids the flattening, so it is pinned.
188
+ const v1 = Object.fromEntries(T.BASE_V1_TEMPLATE.fields.map((f) => [f.key, f.label]));
189
+ const v2 = Object.fromEntries(T.BASE_V2_TEMPLATE.fields.map((f) => [f.key, f.label]));
190
+ const shared = Object.keys(v2).filter((k) => k in v1);
191
+ assert.deepEqual(shared.sort(), ['fit', 'why_had'], 'exactly two keys carry over');
192
+ for (const key of shared) {
193
+ assert.notEqual(v1[key], v2[key], `${key} asks a different question in each vintage`);
194
+ }
195
+ });
196
+
197
+ test('a retired vintage is never dropped from the registry', () => {
198
+ // The load-time pin in templates.js says the same thing; asserting it here is
199
+ // what makes a "tidy up the old template" edit fail in the unit gate rather
200
+ // than at boot on somebody's instance.
201
+ for (const key of ['base-v1', 'base-v2']) {
202
+ assert.ok(Object.hasOwn(T.BUILTIN_TEMPLATES, key), `${key} stays registered`);
203
+ assert.ok(T.templateFor(key), `${key} still resolves`);
204
+ }
205
+ });
206
+
128
207
  // ── the done-when: swap the registry, behaviour follows (the R156 seam) ─────
129
208
 
130
209
  test('a different registry changes what is accepted — no code change', () => {