@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.
- package/.bongos-core.json +44 -34
- 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 +80 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +48 -5
- package/scripts/gds/upgrade.js +9 -4
- 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 +30 -13
- 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 +95 -16
- package/tests/update_subscription_engine.mjs +67 -5
- package/tests/upgrade.mjs +52 -0
- package/tests/upgrade_persist_pin.mjs +97 -3
|
@@ -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 {
|
|
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
|
|
28
|
-
|
|
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 =
|
|
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
|
|
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\(
|
|
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,
|
|
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 ===
|
|
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,
|
|
57
|
-
const b = C.scoreFieldValues(padded).fields.find((f) => f.key ===
|
|
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,
|
|
60
|
-
const b2 = C.scoreFieldValues(spaced).fields.find((f) => f.key ===
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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']) {
|
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,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
|
|
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) {
|
|
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:
|
|
59
|
-
assert.equal(T.templateFor('base-v1'), T.
|
|
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,
|
|
101
|
-
assert.deepEqual(r.errors, [{ field: '
|
|
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,
|
|
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', () => {
|