@bongos/core 1.19.514 → 1.19.516
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 +81 -36
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +22 -2
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/adr/0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md +157 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +362 -6
- package/docs/api-reference.md +12 -2
- package/docs/copy-inventory.md +224 -218
- package/docs/copy-registry.json +357 -291
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +12 -0
- package/modules/government/catalog.js +6 -0
- package/modules/government/migrations/government_009_mingle_manage.sql +55 -0
- package/modules/lifecycle/migrations/lifecycle_007_mingle.sql +134 -0
- package/modules/lifecycle/mingle.js +337 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/mingle.js +116 -0
- package/modules/provisioning/migrations/provisioning_014_module_selection.sql +60 -0
- package/modules/provisioning/provisioning.js +24 -4
- package/modules/provisioning/routes/provisioning.js +59 -2
- package/modules/provisioning/starter-bundles.js +91 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/projects.html +230 -35
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +5 -4
- package/modules/ui-design/kit/serve.js +32 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_seed.mjs +35 -0
- package/tests/mingle.mjs +370 -0
- package/tests/projects_hub.mjs +72 -7
- package/tests/projects_hub_module_picker.mjs +369 -0
- package/tests/provisioning_module_selection.mjs +286 -0
- package/tests/provisioning_starter_bundles.mjs +131 -0
|
@@ -182,3 +182,38 @@ test('migration objects are all governance_-namespaced (ADR 0083 §5)', () => {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
// THE GAP THE DRIFT GUARD ABOVE CANNOT SEE (found by the task 1003177 grader).
|
|
187
|
+
//
|
|
188
|
+
// The union model is a regex over `SELECT '<rank>', unnest(ARRAY[…])`. It reads
|
|
189
|
+
// the KEYS and never looks at the table those keys are inserted INTO — so a new
|
|
190
|
+
// grant migration copied from a `governance_*` sibling passes it while writing to
|
|
191
|
+
// `governance_role_permissions (role_key, …)`, the PRE-rename names.
|
|
192
|
+
//
|
|
193
|
+
// government_001_rename_from_governance renamed that table to
|
|
194
|
+
// `government_rank_permissions` and its `role_key` column to `rank_key`, with no
|
|
195
|
+
// compatibility shim. Migrations apply in filename order, so on any database —
|
|
196
|
+
// a fresh install included — a `government_*`-prefixed file writes AFTER the
|
|
197
|
+
// rename and must use the new names or error at apply time. The permission would
|
|
198
|
+
// then never resolve, and its routes would 403 for every rank, Archon included.
|
|
199
|
+
//
|
|
200
|
+
// Scoped to the `government_`-prefixed files on purpose: the historical
|
|
201
|
+
// `governance_*` migrations legitimately name the pre-rename tables (they ran
|
|
202
|
+
// before the rename, and renaming an applied migration re-runs it — the standing
|
|
203
|
+
// rule in modules/government/CLAUDE.md).
|
|
204
|
+
test('a post-rename grant migration writes to the RENAMED table, not the old one', () => {
|
|
205
|
+
const postRename = grantSqlByFile.filter(([f]) => f.startsWith('government_'));
|
|
206
|
+
assert.ok(postRename.length > 0, 'there is at least one government_-prefixed grant migration');
|
|
207
|
+
|
|
208
|
+
for (const [file, sql] of postRename) {
|
|
209
|
+
const ddl = sql.replace(/^\s*--.*$/gm, ''); // a comment may CITE the old name
|
|
210
|
+
assert.ok(!/governance_role_permissions/.test(ddl),
|
|
211
|
+
`${file}: writes to governance_role_permissions — renamed to government_rank_permissions by government_001`);
|
|
212
|
+
assert.ok(!/\brole_key\b/.test(ddl),
|
|
213
|
+
`${file}: names the role_key column — renamed to rank_key by government_001`);
|
|
214
|
+
if (/INSERT INTO/.test(ddl)) {
|
|
215
|
+
assert.match(ddl, /INSERT INTO government_rank_permissions \(rank_key, permission_key\)/,
|
|
216
|
+
`${file}: a grant insert must target government_rank_permissions (rank_key, permission_key)`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
package/tests/mingle.mjs
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// tests/mingle.mjs — mingle is two switches, cross-craft, and a decline that
|
|
2
|
+
// costs nothing (task 1003177 / C07, goal 1000075; criterion
|
|
3
|
+
// collab-mingle-two-switches, ADR 0187 §5).
|
|
4
|
+
//
|
|
5
|
+
// The criterion names three testable things, and the owner attached the third as
|
|
6
|
+
// a condition of the feature rather than a follow-up:
|
|
7
|
+
// 1. a pair is only ever CROSS-CRAFT;
|
|
8
|
+
// 2. disabling EITHER switch prevents pairing;
|
|
9
|
+
// 3. a decline carries NO PENALTY — "and that has to be verifiable, not just
|
|
10
|
+
// stated, or 'without penalty' is a claim rather than a property".
|
|
11
|
+
//
|
|
12
|
+
// (3) is the reason this file drives the REAL functions against a RECORDING pool
|
|
13
|
+
// rather than asserting over prose. "No penalty" is a claim about what the code
|
|
14
|
+
// does NOT do, and the only honest way to test an absence is to capture every
|
|
15
|
+
// statement the code issues and assert on the whole set.
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const mingle = require(path.join(ROOT, 'modules', 'lifecycle', 'mingle.js'));
|
|
26
|
+
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
|
|
27
|
+
|
|
28
|
+
const SRC = read('modules', 'lifecycle', 'mingle.js');
|
|
29
|
+
const ROUTES = read('modules', 'lifecycle', 'routes', 'mingle.js');
|
|
30
|
+
const MIGRATION = read('modules', 'lifecycle', 'migrations', 'lifecycle_007_mingle.sql');
|
|
31
|
+
|
|
32
|
+
// A pool that answers the module's real queries and RECORDS every one of them.
|
|
33
|
+
// The record is what makes the no-penalty test possible: it is an assertion about
|
|
34
|
+
// the complete set of statements a decline issues, not about the one we expected.
|
|
35
|
+
function recordingPool({ eligible = [], openPairs = [], pairRow = null } = {}) {
|
|
36
|
+
const queries = [];
|
|
37
|
+
return {
|
|
38
|
+
queries,
|
|
39
|
+
async query(sql, params) {
|
|
40
|
+
queries.push({ sql, params });
|
|
41
|
+
if (/FROM lifecycle_mingle_optin o/.test(sql)) {
|
|
42
|
+
return { rows: eligible.map((b) => ({ builder_id: b.builder_id, crafts: b.crafts })) };
|
|
43
|
+
}
|
|
44
|
+
if (/SELECT opted_in, updated_at FROM lifecycle_mingle_optin/.test(sql)) {
|
|
45
|
+
return { rows: [{ opted_in: true, updated_at: '2026-09-03T00:00:00Z' }] };
|
|
46
|
+
}
|
|
47
|
+
if (/INSERT INTO lifecycle_mingle_optin/.test(sql)) {
|
|
48
|
+
return { rows: [{ builder_id: params[0], opted_in: params[1], updated_at: 'now' }] };
|
|
49
|
+
}
|
|
50
|
+
if (/SELECT builder_lo::text AS lo/.test(sql)) {
|
|
51
|
+
return { rows: openPairs.map(([lo, hi]) => ({ lo, hi })) };
|
|
52
|
+
}
|
|
53
|
+
if (/INSERT INTO lifecycle_mingle_pairs/.test(sql)) {
|
|
54
|
+
// The round writes ONE bulk insert via unnest(), so the params are four
|
|
55
|
+
// parallel arrays rather than four scalars.
|
|
56
|
+
const [los, his, cl, ch] = params;
|
|
57
|
+
return {
|
|
58
|
+
rows: los.map((lo, i) => ({
|
|
59
|
+
id: i + 1, builder_lo: lo, builder_hi: his[i], craft_lo: cl[i], craft_hi: ch[i],
|
|
60
|
+
status: 'proposed', created_at: 'now',
|
|
61
|
+
})),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (/UPDATE lifecycle_mingle_pairs/.test(sql)) {
|
|
65
|
+
return { rows: pairRow ? [pairRow] : [] };
|
|
66
|
+
}
|
|
67
|
+
if (/FROM lifecycle_mingle_pairs p/.test(sql)) return { rows: [] };
|
|
68
|
+
throw new Error(`unexpected query: ${sql.slice(0, 70)}`);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const on = () => async () => ({ value: 'true' });
|
|
74
|
+
const off = () => async () => null;
|
|
75
|
+
|
|
76
|
+
// ── 1. A PAIR IS ONLY EVER CROSS-CRAFT ──────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
test('THE RULE: crafts must be DISJOINT, not merely different', () => {
|
|
79
|
+
assert.equal(mingle.isCrossCraft(['builder'], ['artist']), true);
|
|
80
|
+
assert.equal(mingle.isCrossCraft(['builder'], ['builder']), false);
|
|
81
|
+
// The case a "different primary craft" rule would get wrong: someone who is
|
|
82
|
+
// both shares a craft with a pure engineer, so pairing them puts two of the
|
|
83
|
+
// same in a room and calls it cross-craft.
|
|
84
|
+
assert.equal(mingle.isCrossCraft(['artist', 'builder'], ['builder']), false);
|
|
85
|
+
assert.equal(mingle.isCrossCraft(['artist', 'builder'], ['ideator']), true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a builder with NO craft is never pairable', () => {
|
|
89
|
+
// Belonging to a craft is a claim about yourself the system must not assume —
|
|
90
|
+
// the same fail-closed answer help-requests gives.
|
|
91
|
+
assert.equal(mingle.isCrossCraft([], ['artist']), false);
|
|
92
|
+
assert.equal(mingle.isCrossCraft(['artist'], []), false);
|
|
93
|
+
const pairs = mingle.planPairs([
|
|
94
|
+
{ builder_id: '1', crafts: [] },
|
|
95
|
+
{ builder_id: '2', crafts: ['artist'] },
|
|
96
|
+
]);
|
|
97
|
+
assert.deepEqual(pairs, []);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('EVERY pair the planner emits is cross-craft', () => {
|
|
101
|
+
const eligible = [
|
|
102
|
+
{ builder_id: '10', crafts: ['builder'] },
|
|
103
|
+
{ builder_id: '20', crafts: ['builder'] },
|
|
104
|
+
{ builder_id: '30', crafts: ['artist'] },
|
|
105
|
+
{ builder_id: '40', crafts: ['ideator'] },
|
|
106
|
+
{ builder_id: '50', crafts: ['artist', 'builder'] },
|
|
107
|
+
];
|
|
108
|
+
const byId = new Map(eligible.map((b) => [b.builder_id, b.crafts]));
|
|
109
|
+
const pairs = mingle.planPairs(eligible);
|
|
110
|
+
assert.ok(pairs.length > 0, 'the planner found pairs at all');
|
|
111
|
+
for (const p of pairs) {
|
|
112
|
+
assert.equal(mingle.isCrossCraft(byId.get(p.builder_lo), byId.get(p.builder_hi)), true,
|
|
113
|
+
`${p.builder_lo}/${p.builder_hi} share a craft`);
|
|
114
|
+
assert.notEqual(p.craft_lo, p.craft_hi, 'the recorded crafts differ');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('nobody is put in two pairs in one round', () => {
|
|
119
|
+
const pairs = mingle.planPairs([
|
|
120
|
+
{ builder_id: '1', crafts: ['builder'] },
|
|
121
|
+
{ builder_id: '2', crafts: ['artist'] },
|
|
122
|
+
{ builder_id: '3', crafts: ['ideator'] },
|
|
123
|
+
{ builder_id: '4', crafts: ['ui'] },
|
|
124
|
+
]);
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
for (const p of pairs) {
|
|
127
|
+
for (const id of [p.builder_lo, p.builder_hi]) {
|
|
128
|
+
assert.ok(!seen.has(id), `${id} appears twice`);
|
|
129
|
+
seen.add(id);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('CROSS-CRAFT IS ALSO A DATABASE CONSTRAINT, not only a matcher rule', () => {
|
|
135
|
+
// Enforcing it solely in the matcher leaves any future writer — a backfill, a
|
|
136
|
+
// second caller, a fixture — free to violate the property the feature is named
|
|
137
|
+
// for.
|
|
138
|
+
assert.match(MIGRATION, /CHECK \(craft_lo <> craft_hi\)/);
|
|
139
|
+
assert.match(MIGRATION, /CHECK \(builder_lo <> builder_hi\)/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('the matching rule is RECORDED, and the route serves it verbatim', () => {
|
|
143
|
+
// "chosen deliberately and recorded" — a reader can fetch the rule rather than
|
|
144
|
+
// infer it from SQL.
|
|
145
|
+
const m = mingle.matchingModel();
|
|
146
|
+
assert.equal(m.rule, 'cross_craft');
|
|
147
|
+
assert.equal(m.overlap, 'none');
|
|
148
|
+
assert.ok(m.description && m.consent && m.decline);
|
|
149
|
+
assert.match(m.rejected, /same_craft/, 'the road not taken is recorded too');
|
|
150
|
+
assert.match(ROUTES, /matching: mingle\.matchingModel\(\)/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── 2. DISABLING EITHER SWITCH PREVENTS PAIRING ─────────────────────────────
|
|
154
|
+
|
|
155
|
+
test('SWITCH 1 OFF: the project disabled means no pairing, and no write at all', async () => {
|
|
156
|
+
const pool = recordingPool({ eligible: [
|
|
157
|
+
{ builder_id: '1', crafts: ['builder'] },
|
|
158
|
+
{ builder_id: '2', crafts: ['artist'] },
|
|
159
|
+
] });
|
|
160
|
+
const out = await mingle.runMingleRound({}, { pool, getSetting: off() });
|
|
161
|
+
assert.deepEqual(out.paired, []);
|
|
162
|
+
assert.equal(out.skipped, 'project_disabled');
|
|
163
|
+
// Not "paired then discarded" — it never got as far as reading who is eligible.
|
|
164
|
+
assert.equal(pool.queries.length, 0, 'a disabled project issues no query');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('SWITCH 2 OFF: eligibility is opted-in only, enforced in SQL', async () => {
|
|
168
|
+
const pool = recordingPool({ eligible: [] });
|
|
169
|
+
await mingle.eligibleBuilders({ pool });
|
|
170
|
+
const sql = pool.queries[0].sql;
|
|
171
|
+
assert.match(sql, /o\.opted_in = true/, 'only people who opted in');
|
|
172
|
+
assert.match(sql, /b\.status = 'active'/, 'and only active builders');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('BOTH ON is the only state that pairs', async () => {
|
|
176
|
+
const pool = recordingPool({ eligible: [
|
|
177
|
+
{ builder_id: '1', crafts: ['builder'] },
|
|
178
|
+
{ builder_id: '2', crafts: ['artist'] },
|
|
179
|
+
] });
|
|
180
|
+
const out = await mingle.runMingleRound({}, { pool, getSetting: on() });
|
|
181
|
+
assert.equal(out.skipped, null);
|
|
182
|
+
assert.equal(out.paired.length, 1);
|
|
183
|
+
assert.equal(out.paired[0].builder_lo, '1');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('a round is ONE insert, so a partial round cannot be written', () => {
|
|
187
|
+
const pool = recordingPool({ eligible: [
|
|
188
|
+
{ builder_id: '1', crafts: ['builder'] },
|
|
189
|
+
{ builder_id: '2', crafts: ['artist'] },
|
|
190
|
+
{ builder_id: '3', crafts: ['ideator'] },
|
|
191
|
+
{ builder_id: '4', crafts: ['ui'] },
|
|
192
|
+
] });
|
|
193
|
+
return mingle.runMingleRound({}, { pool, getSetting: on() }).then((out) => {
|
|
194
|
+
assert.equal(out.paired.length, 2, 'two pairs from four builders');
|
|
195
|
+
const inserts = pool.queries.filter((q) => /INSERT INTO lifecycle_mingle_pairs/.test(q.sql));
|
|
196
|
+
assert.equal(inserts.length, 1, 'one statement for the whole round, not one per pair');
|
|
197
|
+
assert.match(inserts[0].sql, /unnest\(\$1::bigint\[\]/);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('an empty plan writes nothing at all', async () => {
|
|
202
|
+
// Two builders of the SAME craft are not a pair, so there is nothing to insert
|
|
203
|
+
// — and no empty INSERT should be issued to discover that.
|
|
204
|
+
const pool = recordingPool({ eligible: [
|
|
205
|
+
{ builder_id: '1', crafts: ['builder'] },
|
|
206
|
+
{ builder_id: '2', crafts: ['builder'] },
|
|
207
|
+
] });
|
|
208
|
+
const out = await mingle.runMingleRound({}, { pool, getSetting: on() });
|
|
209
|
+
assert.deepEqual(out.paired, []);
|
|
210
|
+
assert.equal(pool.queries.filter((q) => /INSERT/.test(q.sql)).length, 0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('FAIL CLOSED: the project switch opens only on an explicit yes', () => {
|
|
214
|
+
for (const yes of ['true', 'TRUE', ' on ', '1', 'yes']) {
|
|
215
|
+
assert.equal(mingle.parseEnabled(yes), true, `${yes} is a yes`);
|
|
216
|
+
}
|
|
217
|
+
// Absent, blank, garbage, and a value that was valid before someone tightened
|
|
218
|
+
// this are all NO. A consent gate that opens on an unreadable value is not a gate.
|
|
219
|
+
for (const no of [null, undefined, '', 'false', 'maybe', 'enabled', '2', 'off']) {
|
|
220
|
+
assert.equal(mingle.parseEnabled(no), false, `${String(no)} is not a yes`);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('FAIL CLOSED: an unreadable project switch is not permission', async () => {
|
|
225
|
+
const enabled = await mingle.projectEnabled({ getSetting: async () => { throw new Error('db down'); } });
|
|
226
|
+
assert.equal(enabled, false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('the two switches are reported SEPARATELY, so a builder can see which is off', async () => {
|
|
230
|
+
// "You cannot mingle" with no reason is a dead end for the person who could fix
|
|
231
|
+
// half of it.
|
|
232
|
+
const pool = recordingPool();
|
|
233
|
+
const state = await mingle.mingleStateFor(7, { pool, getSetting: off() });
|
|
234
|
+
assert.equal(state.enabled, false);
|
|
235
|
+
assert.equal(state.project_enabled, false);
|
|
236
|
+
assert.equal(state.opted_in, true, 'my half is still reported as mine');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ── 3. A DECLINE CARRIES NO PENALTY ─────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
// Anything that would make declining cost something. Matched against the SQL a
|
|
242
|
+
// decline actually issues AND against the module source.
|
|
243
|
+
const PENALTY_SURFACES = /credit|karma|rank|streak|achievement|reward|score|penalt|cooldown|strike/i;
|
|
244
|
+
|
|
245
|
+
test('THE NO-PENALTY PROPERTY: a decline writes ONE row in ONE table', async () => {
|
|
246
|
+
const pool = recordingPool({ pairRow: { id: 5, builder_lo: '1', builder_hi: '2', craft_lo: 'builder', craft_hi: 'artist', status: 'declined', decided_by: '1', decided_at: 'now' } });
|
|
247
|
+
const out = await mingle.decidePair(5, 1, 'declined', { pool });
|
|
248
|
+
assert.equal(out.pair.status, 'declined');
|
|
249
|
+
|
|
250
|
+
assert.equal(pool.queries.length, 1, 'exactly one statement');
|
|
251
|
+
const { sql } = pool.queries[0];
|
|
252
|
+
assert.match(sql, /UPDATE lifecycle_mingle_pairs/);
|
|
253
|
+
// The whole point: nothing else was touched. This is an assertion about the
|
|
254
|
+
// COMPLETE set of statements, which is the only honest way to test an absence.
|
|
255
|
+
assert.ok(!PENALTY_SURFACES.test(sql), `a decline touched a penalty surface: ${sql}`);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('NO FUTURE MATCHING WEIGHT: the eligibility read cannot see pair history', async () => {
|
|
259
|
+
// This is the structural half, and it is what makes "no penalty" a property
|
|
260
|
+
// rather than a promise: the read that decides who may be paired has no way to
|
|
261
|
+
// learn that someone declined, so a decline cannot lower their odds.
|
|
262
|
+
const pool = recordingPool({ eligible: [] });
|
|
263
|
+
await mingle.eligibleBuilders({ pool });
|
|
264
|
+
const sql = pool.queries[0].sql;
|
|
265
|
+
assert.ok(!/lifecycle_mingle_pairs/.test(sql),
|
|
266
|
+
'eligibleBuilders must never join the pairs table');
|
|
267
|
+
assert.ok(!PENALTY_SURFACES.test(sql), 'eligibility reads no penalty surface either');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('a declined pair may be introduced again — a decline is not a permanent split', () => {
|
|
271
|
+
// The only thing pair history gates is a second OPEN proposal for the same two
|
|
272
|
+
// people (the unique index's job). A settled pair is absent from that set, so
|
|
273
|
+
// it can come back. A permanent exclusion would be a consequence, and declining
|
|
274
|
+
// is free.
|
|
275
|
+
assert.match(MIGRATION, /CREATE UNIQUE INDEX[\s\S]{0,200}lifecycle_mingle_pairs_open_uniq[\s\S]{0,120}WHERE status = 'proposed'/);
|
|
276
|
+
const eligible = [
|
|
277
|
+
{ builder_id: '1', crafts: ['builder'] },
|
|
278
|
+
{ builder_id: '2', crafts: ['artist'] },
|
|
279
|
+
];
|
|
280
|
+
// With the pair already OPEN, it is skipped...
|
|
281
|
+
assert.deepEqual(mingle.planPairs(eligible, { excludePairs: new Set(['1:2']) }), []);
|
|
282
|
+
// ...and once settled it is not in the exclude set at all, so it returns.
|
|
283
|
+
assert.equal(mingle.planPairs(eligible, { excludePairs: new Set() }).length, 1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('the exclude set is built from OPEN pairs only, never from declines', async () => {
|
|
287
|
+
const pool = recordingPool({ openPairs: [['1', '2']] });
|
|
288
|
+
await mingle.openPairKeys({ pool });
|
|
289
|
+
assert.match(pool.queries[0].sql, /WHERE status = 'proposed'/);
|
|
290
|
+
assert.ok(!/declined/.test(pool.queries[0].sql), 'declines are not read back');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('NOTHING in the module reaches a penalty surface', () => {
|
|
294
|
+
// The source-level backstop for the two behavioural tests above: a future
|
|
295
|
+
// "small" addition — bump a counter, dock some karma — fails here even if it
|
|
296
|
+
// never runs in a test.
|
|
297
|
+
//
|
|
298
|
+
// TWO EXEMPTIONS, AND THEY ARE THE SAME ONE. Comments discuss penalties, and so
|
|
299
|
+
// does the MATCHING literal — its whole job is to record, in words a surface can
|
|
300
|
+
// render, WHICH standing a decline does not touch. Naming a surface in order to
|
|
301
|
+
// disclaim it is the opposite of reaching for it, so both are stripped before
|
|
302
|
+
// the scan. This is the codebase's standing convention: a line that CITES a rule
|
|
303
|
+
// is the only thing exempt from it (scripts/gds/fitness.js says so of the
|
|
304
|
+
// category-advisory guard). Everything else — every identifier, every line of
|
|
305
|
+
// SQL — is scanned.
|
|
306
|
+
const code = SRC
|
|
307
|
+
.replace(/^\s*\/\/.*$/gm, '')
|
|
308
|
+
.replace(/const MATCHING = Object\.freeze\(\{[\s\S]*?\n\}\);/, '');
|
|
309
|
+
assert.ok(!/MATCHING = Object\.freeze/.test(code), 'the MATCHING literal was actually stripped');
|
|
310
|
+
assert.ok(!/credits|karma|streak|achievement/i.test(code),
|
|
311
|
+
'mingle.js must not touch a credit/karma/streak/achievement surface');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('the schema carries no penalty column', () => {
|
|
315
|
+
// A table that recorded a decline anywhere but in its own status column would be
|
|
316
|
+
// the penalty the criterion forbids — the same posture lifecycle_005 takes.
|
|
317
|
+
const ddl = MIGRATION.replace(/^\s*--.*$/gm, '');
|
|
318
|
+
assert.ok(!/credit|karma|streak|cooldown|penalt|count/i.test(ddl),
|
|
319
|
+
'no counter, cooldown or penalty column');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ── the routes ──────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
test('consent is keyed by the SESSION, never a body field', () => {
|
|
325
|
+
// Consent you can give on someone else's behalf is not consent.
|
|
326
|
+
assert.match(ROUTES, /mingle\.setOptIn\(req\.builder\.id/);
|
|
327
|
+
assert.ok(!/setOptIn\(\s*req\.body/.test(ROUTES));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('the operator routes gate on the ATOM; my own switch gates on nothing', () => {
|
|
331
|
+
// BV1.R105: a modules/*/routes gate is a permission atom, never a rank —
|
|
332
|
+
// tests/government_require_permission fails the build on a new requireRank, and
|
|
333
|
+
// the two files still spelling it that way are documented holdouts.
|
|
334
|
+
const projectRoute = ROUTES.slice(ROUTES.indexOf("router.put('/mingle/project'"));
|
|
335
|
+
assert.match(projectRoute.slice(0, 220), /requirePermission\('mingle\.manage'\)/);
|
|
336
|
+
const roundRoute = ROUTES.slice(ROUTES.indexOf("router.post('/mingle/round'"));
|
|
337
|
+
assert.match(roundRoute.slice(0, 220), /requirePermission\('mingle\.manage'\)/);
|
|
338
|
+
|
|
339
|
+
const optIn = ROUTES.slice(ROUTES.indexOf("router.put('/mingle/opt-in'"), ROUTES.indexOf("router.put('/mingle/project'"));
|
|
340
|
+
assert.ok(!/requirePermission|requireRank/.test(optIn),
|
|
341
|
+
'opting YOURSELF in is not a privileged act');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('the atom is declared in the catalog, and a permission no route consumes does not exist', () => {
|
|
345
|
+
const catalog = read('modules', 'government', 'catalog.js');
|
|
346
|
+
assert.match(catalog, /key: 'mingle\.manage'/, 'the atom is declared');
|
|
347
|
+
assert.match(catalog, /key: 'mingle\.manage'[^\n]*floor: 'metic'/, 'at the discord.channels.manage floor');
|
|
348
|
+
assert.match(catalog, /key: 'mingle\.manage'[^\n]*guards: '[^']*\/mingle\/project/, 'and it names the routes it guards');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('you can only answer an introduction you are in, and the check is in SQL', () => {
|
|
352
|
+
const fn = SRC.slice(SRC.indexOf('async function decidePair'));
|
|
353
|
+
const body = fn.slice(0, fn.indexOf('\n}'));
|
|
354
|
+
assert.match(body, /builder_lo = \$2 OR builder_hi = \$2/,
|
|
355
|
+
'membership is enforced by the UPDATE, not by a check a later caller could skip');
|
|
356
|
+
assert.match(body, /status = 'proposed'/, 'and a settled pair cannot be re-decided');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('the route is registered in the module manifest', () => {
|
|
360
|
+
const manifest = JSON.parse(read('modules', 'lifecycle', 'module.json'));
|
|
361
|
+
assert.ok(manifest.contributes.routes.includes('mingle'));
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('the craft vocabulary is CONSUMED from help-requests, never redefined', () => {
|
|
365
|
+
// Two spellings of the same craft ('builder' the craft is 'engineer' the
|
|
366
|
+
// discipline) would silently match nobody.
|
|
367
|
+
assert.match(SRC, /require\('\.\/help-requests'\)/);
|
|
368
|
+
assert.ok(!/CRAFT_TO_DISCIPLINE\s*=|DISCIPLINE_TO_CRAFT\s*=/.test(SRC),
|
|
369
|
+
'mingle must not declare its own craft map');
|
|
370
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -706,14 +706,15 @@ test('the About answers travel on the create request, and an empty one is OMITTE
|
|
|
706
706
|
});
|
|
707
707
|
|
|
708
708
|
test('a renumbering of the wizard invalidates old drafts — the key carries the version', () => {
|
|
709
|
-
// task 1002772 renumbered once and bumped to -v2; task 1002334 renumbered again
|
|
710
|
-
//
|
|
711
|
-
//
|
|
709
|
+
// task 1002772 renumbered once and bumped to -v2; task 1002334 renumbered again;
|
|
710
|
+
// task 1002339 (the module picker) is the third. A draft replayed under a
|
|
711
|
+
// different numbering lands on the wrong panel with the rail already marking
|
|
712
|
+
// the skipped steps done.
|
|
712
713
|
const key = (SCRIPT.match(/var DRAFT_KEY = '([^']+)'/) || [, ''])[1];
|
|
713
714
|
assert.match(key, /-v\d+$/, 'the draft key is versioned');
|
|
714
715
|
const panels = (SCRIPT.match(/var panels = \{([^}]*)\}/) || [, ''])[1];
|
|
715
716
|
const count = [...panels.matchAll(/\d+:\s*'panel-/g)].length;
|
|
716
|
-
assert.equal(count,
|
|
717
|
+
assert.equal(count, 9, 'gate + seven numbered steps + done — bump DRAFT_KEY when this changes');
|
|
717
718
|
});
|
|
718
719
|
|
|
719
720
|
test('every row lays the same five parts — the ledger has no ragged row', () => {
|
|
@@ -2058,7 +2059,9 @@ test('the wizard takes the world by scope: no card ground, the six-step strip as
|
|
|
2058
2059
|
assert.ok(at > -1, 'the #view-new block exists in cosmos.css');
|
|
2059
2060
|
const block = COSMOS.slice(at);
|
|
2060
2061
|
assert.match(block, /#view-new \.card\{background:none;border-radius:0;box-shadow:none;/, 'no card ground');
|
|
2061
|
-
|
|
2062
|
+
// seven tracks since task 1002339 added the Modules step; the count is the
|
|
2063
|
+
// rail's own, so it moves with the panels map rather than being a magic number
|
|
2064
|
+
assert.match(block, /#view-new \.steps\{display:grid;grid-template-columns:repeat\(7,1fr\);/, 'the strip lays a track per numbered step');
|
|
2062
2065
|
assert.match(block, /#view-new \.steps li::before\{content:"";position:absolute;top:-4px;left:0;right:auto;width:7px;height:7px;/, 'the connector becomes the 7px dot');
|
|
2063
2066
|
assert.match(block, /#view-new \.steps li\.now::before\{background:var\(--accent\);border-color:var\(--accent\);outline:4px solid/, 'the lit step carries the accent and an outline halo');
|
|
2064
2067
|
assert.match(block, /#view-new \.panel h1\{font-weight:300;[^}]*text-transform:lowercase;/, 'the step title in the display voice, lowercase');
|
|
@@ -2105,7 +2108,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
|
|
|
2105
2108
|
assert.match(block, /\.addrBig code\{display:block;font-family:var\(--font-display\);/, 'the address stands in the display voice');
|
|
2106
2109
|
assert.doesNotMatch(block, /linear-gradient/, 'no tinted plates');
|
|
2107
2110
|
assert.doesNotMatch(block, /box-shadow:(?!none)/, 'no box shadow in the wizard block');
|
|
2108
|
-
assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="
|
|
2111
|
+
assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="6">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
|
|
2109
2112
|
assert.match(HUB, /<h2 class="stepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style');
|
|
2110
2113
|
// what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
|
|
2111
2114
|
assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
|
|
@@ -2114,7 +2117,69 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
|
|
|
2114
2117
|
assert.match(SCRIPT, /onboard_mode: state\.mode === 'adopt' \? 'adopt' : 'greenfield'/, 'the onboard mode is unchanged');
|
|
2115
2118
|
// the kit walks every step and files against the stub
|
|
2116
2119
|
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
|
|
2117
|
-
for (const s of ['type', 'about', 'home', 'address', 'review', 'done']) assert.ok(states['in-new-' + s], `the ${s} step has a kit state`);
|
|
2120
|
+
for (const s of ['type', 'about', 'modules', 'home', 'address', 'review', 'done']) assert.ok(states['in-new-' + s], `the ${s} step has a kit state`);
|
|
2118
2121
|
const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
|
|
2119
2122
|
assert.ok(probes.some((p) => /the wizard driver \(task 1003346\)/.test(p.name) && p.steps.some((st) => st[1] === '#doCreate')), 'the wizard driver files a project end to end');
|
|
2120
2123
|
});
|
|
2124
|
+
|
|
2125
|
+
test('the Modules step ships as real markup and is skippable like About (task 1002339)', () => {
|
|
2126
|
+
const panel = (HUB.match(/<section class="panel" id="panel-modules"[\s\S]*?<\/section>/) || [''])[0];
|
|
2127
|
+
assert.ok(panel, '#panel-modules ships as real markup, not built by script');
|
|
2128
|
+
assert.match(panel, /id="modCards"/, 'the band the picker paints its rows into');
|
|
2129
|
+
assert.match(panel, /class="typeCards"/, 'and it is the SAME band the type and team questions use — no new grammar');
|
|
2130
|
+
assert.match(panel, /id="modCoreNote"/, 'the always-on core is stated on the panel');
|
|
2131
|
+
// the skip must be its own control, not "leave it and press Next" — the rule
|
|
2132
|
+
// task 1002334 wrote for the one other optional step
|
|
2133
|
+
assert.match(panel, /id="skipModules"/, 'the skip is a control, not an inference');
|
|
2134
|
+
assert.match(panel, /Optional/, 'and the panel says so before it asks');
|
|
2135
|
+
// nothing in validateStep may gate it
|
|
2136
|
+
const validate = SCRIPT.slice(SCRIPT.indexOf('function validateStep('));
|
|
2137
|
+
const map = SCRIPT.match(/var panels = \{([^}]*)\}/)[1];
|
|
2138
|
+
const modStep = Number((map.match(/(\d+):\s*'panel-modules'/) || [, 0])[1]);
|
|
2139
|
+
assert.ok(modStep > 0, 'the Modules panel is in the map');
|
|
2140
|
+
assert.ok(!validate.slice(0, validate.indexOf('function ', 1)).includes(`if (step === ${modStep}) {`),
|
|
2141
|
+
'the optional step has no validation clause — Next past an untouched picker is the point');
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
test('the picker reads ONE public declaration, and it is the one the bundles come from (task 1002339)', () => {
|
|
2145
|
+
// A second source for "what may be chosen" is exactly what would let a
|
|
2146
|
+
// preselect name a module the panel has no row for (ADR 0237 §5).
|
|
2147
|
+
assert.match(SCRIPT, /fetch\(API \+ '\/provisioning\/starter-bundles'/, 'the picker reads the starter-bundles route');
|
|
2148
|
+
assert.equal((SCRIPT.match(/'\/provisioning\/starter-bundles'/g) || []).length, 1, 'exactly once');
|
|
2149
|
+
// and it is a public read — no other module-catalog call joins it
|
|
2150
|
+
assert.doesNotMatch(SCRIPT, /fetch\(API \+ '\/modules'/, 'no signed-in catalog read from a signed-out wizard');
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
test('the picker\'s answer rides the create request, and an untouched picker sends nothing (task 1002339)', () => {
|
|
2154
|
+
// absent means SKIPPED, and the server then applies the same starter bundle
|
|
2155
|
+
// the panel preselected — the same contract `description` and `detail` carry
|
|
2156
|
+
const body = SCRIPT.slice(SCRIPT.indexOf('body: JSON.stringify({'));
|
|
2157
|
+
assert.ok(body.includes('modules: modulesForCreate(),'), 'the create body carries the picker\'s ids');
|
|
2158
|
+
assert.match(SCRIPT, /function modulesForCreate\(\) \{\s*return state\.modules === null \? undefined : state\.modules\.slice\(\);/,
|
|
2159
|
+
'and modulesForCreate() omits the key while nothing has been said');
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
test('the review names what the project will actually come with (task 1002339)', () => {
|
|
2163
|
+
// The picker is optional, so the last screen before Create must show the set
|
|
2164
|
+
// the owner is about to get — their picks, or the preselect they walked past.
|
|
2165
|
+
const render = SCRIPT.slice(SCRIPT.indexOf('function renderReview()'));
|
|
2166
|
+
const rows = render.slice(0, render.indexOf('$(\'reviewList\').innerHTML'));
|
|
2167
|
+
assert.match(rows, /\['Extras', extrasRow\(\)\]/, 'the ledger carries an Extras row');
|
|
2168
|
+
// What that row SAYS has three cases (picked / preselect / catalog not loaded)
|
|
2169
|
+
// and they live with the picker's own rules, executed by
|
|
2170
|
+
// tests/projects_hub_module_picker.mjs. This pins that the review defers to them.
|
|
2171
|
+
assert.match(SCRIPT, /function extrasRow\(\) \{/, 'and it is the picker that decides what it says');
|
|
2172
|
+
assert.match(SCRIPT, /'None — just the core'/, 'an empty selection reads as a fact, not a blank');
|
|
2173
|
+
});
|
|
2174
|
+
|
|
2175
|
+
test('the kit stub answers the picker\'s read, so the panel renders in the design kit (task 1002339)', () => {
|
|
2176
|
+
// The stub is not the server (this module's CLAUDE.md trap): it serves
|
|
2177
|
+
// fixtures. A 404 here would have rendered the couldn't-load clause in every
|
|
2178
|
+
// state and probe, and nobody would have seen the picker at all.
|
|
2179
|
+
const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
|
|
2180
|
+
assert.match(stub, /rest === '\/provisioning\/starter-bundles'/, 'the stub serves the picker\'s read');
|
|
2181
|
+
const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
|
|
2182
|
+
assert.ok(probes.some((p) => /the wizard driver \(task 1003346\)/.test(p.name)
|
|
2183
|
+
&& p.steps.some((st) => st[1] === '#panel-modules button[type=submit]')),
|
|
2184
|
+
'and the end-to-end driver walks the new step rather than stopping at it');
|
|
2185
|
+
});
|