@bongos/core 1.19.773 → 1.19.775
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 +195 -80
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/config/modules.neutral.json +1 -1
- package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
- package/docs/adr/0177-goal-membership-kind.md +26 -2
- package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
- package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
- package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
- package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +102 -11
- package/docs/api-reference.md +5 -4
- package/docs/copy-inventory.md +205 -190
- package/docs/copy-registry.json +435 -269
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -1
- package/modules/builder-settings/render-prefs.js +281 -33
- package/modules/builder-settings/routes/settings.js +20 -12
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/dev-box/module.json +4 -0
- package/modules/hall-ui/public/settings-render.js +138 -0
- package/modules/hall-ui/public/settings.html +16 -9
- package/modules/hall-ui/public/settings.js +3 -70
- package/modules/lifecycle/ship-card.js +49 -13
- package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
- package/modules/platform-identity/platform-identity.js +8 -19
- package/modules/platform-identity/project-catalog.js +24 -0
- package/modules/platform-identity/routes/sso.js +11 -14
- package/modules/platform-identity/tests/platform-identity.mjs +7 -9
- package/modules/provisioning/capacity-gate.js +54 -0
- package/modules/provisioning/catalog-bridge.js +2 -0
- package/modules/provisioning/disconnect.js +35 -0
- package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +3 -3
- package/modules/provisioning/routes/disconnect.js +63 -0
- package/modules/provisioning/routes/provisioning.js +6 -6
- package/modules/provisioning/starter-bundles.js +32 -30
- package/modules/public-landing/public/projects.html +116 -11
- package/modules/public-landing/public/projects.probes.json +3 -0
- package/modules/ui-design/kit/serve.js +5 -5
- package/modules/ui-design/module.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/address-park-sweep.sh +78 -0
- package/scripts/gds/cli-lib.js +37 -0
- package/scripts/gds/fitness-migration-contraction.js +50 -0
- package/scripts/gds/fitness.js +18 -0
- package/scripts/gds/land-watch.js +8 -2
- package/scripts/gds/provision-config.js +4 -4
- package/scripts/gds/provision-disconnect.js +90 -0
- package/scripts/gds/provision-teardown.js +108 -0
- package/scripts/gds/provision.js +6 -65
- package/scripts/gds/seam-wiring-guard.js +273 -0
- package/scripts/gds/ship-finish.js +17 -10
- package/scripts/gds/ship-flow.js +5 -5
- package/scripts/gds/start.js +13 -7
- package/src/bongos/auth-admission.js +7 -8
- package/src/bongos/auth-config.js +3 -2
- package/src/bongos/goal-scope-check.js +23 -8
- package/src/bongos/module-scope-map.js +40 -0
- package/src/module-api.js +1 -1
- package/src/module-loader/manifest-schema.js +79 -1
- package/tests/catalog_only_client.mjs +8 -5
- package/tests/fitness.mjs +22 -0
- package/tests/goal_scope_check.mjs +63 -1
- package/tests/hall_settings_world.mjs +57 -1
- package/tests/hub_disconnect_ui.mjs +105 -0
- package/tests/land_watch.mjs +44 -0
- package/tests/membership_kind_lockstep.mjs +86 -27
- package/tests/module-scope-map.mjs +39 -0
- package/tests/module_exports_unique_keys.mjs +70 -0
- package/tests/module_manifest.mjs +85 -0
- package/tests/project_catalog_forget.mjs +83 -0
- package/tests/project_type_editable.mjs +8 -8
- package/tests/projects_hub.mjs +10 -4
- package/tests/projects_hub_pre_uat.mjs +5 -3
- package/tests/provision_disconnect.mjs +231 -0
- package/tests/provision_teardown_address.mjs +230 -0
- package/tests/provisioning_capacity_gate.mjs +192 -0
- package/tests/provisioning_disconnect.mjs +231 -0
- package/tests/provisioning_module_selection.mjs +14 -14
- package/tests/provisioning_recommendations.mjs +17 -25
- package/tests/provisioning_restart.mjs +5 -5
- package/tests/provisioning_starter_bundles.mjs +31 -9
- package/tests/public_landing_projects.mjs +4 -3
- package/tests/render_prefs.mjs +152 -0
- package/tests/seam_wiring_guard.mjs +234 -0
- package/tests/ship_card.mjs +67 -1
- package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
|
@@ -235,12 +235,12 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
|
|
|
235
235
|
|
|
236
236
|
// Act — the body the wizard files
|
|
237
237
|
const res = await post('/provisioning/instances', {
|
|
238
|
-
slug: 'mercury', hosting_shape: 'standalone', type: 'game', modules: ['
|
|
238
|
+
slug: 'mercury', hosting_shape: 'standalone', type: 'game', modules: ['agents', 'discord'],
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
// Assert
|
|
242
242
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
243
|
-
assert.deepEqual(lastCreateArgs && lastCreateArgs.modules, ['
|
|
243
|
+
assert.deepEqual(lastCreateArgs && lastCreateArgs.modules, ['agents', 'discord']);
|
|
244
244
|
});
|
|
245
245
|
|
|
246
246
|
await ta('POST /instances: an omitted modules key is still a valid create — skipping is supported, not an error', async () => {
|
|
@@ -324,17 +324,17 @@ await ta('PATCH /detail: the reply carries modules, resolved from the row the UP
|
|
|
324
324
|
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
325
325
|
lastUpdatedRow = null;
|
|
326
326
|
|
|
327
|
-
// Act — the owner says
|
|
328
|
-
const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: '
|
|
327
|
+
// Act — the owner says an open community is building it
|
|
328
|
+
const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'community' } });
|
|
329
329
|
|
|
330
330
|
// Assert — the field is there, and it is the POST-patch resolution: a route
|
|
331
|
-
// that answered off `inst` instead of `updated` would still
|
|
331
|
+
// that answered off `inst` instead of `updated` would still omit 'discord'.
|
|
332
332
|
assert.equal(res.status, 200, res.raw);
|
|
333
333
|
assert.ok(lastUpdatedRow, 'the route ran the update');
|
|
334
334
|
assert.deepEqual(res.body.modules, effectiveModules(lastUpdatedRow));
|
|
335
|
-
assert.deepEqual(res.body.modules.selected, ['discord'], '
|
|
335
|
+
assert.deepEqual(res.body.modules.selected, ['discord'], 'community keeps the gathering place in the game bundle');
|
|
336
336
|
assert.equal(res.body.modules.source, 'bundle');
|
|
337
|
-
assert.
|
|
337
|
+
assert.deepEqual(res.body.modules.adjustments, [], 'the game bundle already carried it, so nothing is claimed');
|
|
338
338
|
detailRow = null; lastUpdatedRow = null;
|
|
339
339
|
});
|
|
340
340
|
|
|
@@ -387,14 +387,14 @@ await ta('PATCH /detail: an owner who skipped the picker can add one module late
|
|
|
387
387
|
await ta('PATCH /detail: a module can be REMOVED later too — the list replaces, it never merges', async () => {
|
|
388
388
|
// The half a merging write would make impossible, which is why
|
|
389
389
|
// updateInstanceDetail replaces `modules` and merges `detail`.
|
|
390
|
-
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['
|
|
390
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['agents', 'discord'], detail: {} };
|
|
391
391
|
lastUpdatedRow = null;
|
|
392
392
|
|
|
393
393
|
const res = await patch('/provisioning/instances/41/detail', { modules: ['discord'] });
|
|
394
394
|
|
|
395
395
|
assert.equal(res.status, 200, res.raw);
|
|
396
396
|
assert.deepEqual(lastUpdatedRow.modules, ['discord']);
|
|
397
|
-
assert.deepEqual(res.body.modules.selected, ['discord'], '
|
|
397
|
+
assert.deepEqual(res.body.modules.selected, ['discord'], 'agents is gone, not still merged in');
|
|
398
398
|
assert.equal(res.body.modules.source, 'chosen');
|
|
399
399
|
detailRow = null; lastUpdatedRow = null;
|
|
400
400
|
});
|
|
@@ -517,17 +517,17 @@ await ta('PATCH /detail: another builder’s project is a 404, not a module chan
|
|
|
517
517
|
// — not merely "close to one".
|
|
518
518
|
|
|
519
519
|
await ta('PATCH /detail: answer then CLEAR — the row reads back exactly as one that was never asked', async () => {
|
|
520
|
-
// Arrange — a
|
|
520
|
+
// Arrange — a research project that skipped the picker, so its modules resolve from
|
|
521
521
|
// the bundle and the interview answer is the only thing that can move them.
|
|
522
|
-
const virgin = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: '
|
|
522
|
+
const virgin = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'research', modules: null, detail: {} };
|
|
523
523
|
const neverAnswered = effectiveModules(virgin);
|
|
524
524
|
assert.equal(neverAnswered.source, 'bundle', 'the fixture really is an unanswered project');
|
|
525
525
|
|
|
526
526
|
detailRow = { ...virgin };
|
|
527
527
|
lastUpdatedRow = null;
|
|
528
|
-
const answered = await patch('/provisioning/instances/41/detail', { detail: { team_shape: '
|
|
528
|
+
const answered = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'community' } });
|
|
529
529
|
assert.equal(answered.status, 200, answered.raw);
|
|
530
|
-
assert.deepEqual(answered.body.detail, { team_shape: '
|
|
530
|
+
assert.deepEqual(answered.body.detail, { team_shape: 'community' });
|
|
531
531
|
assert.notDeepEqual(answered.body.modules.selected, neverAnswered.selected,
|
|
532
532
|
'the fixture is only meaningful if the answer really moved the bundle');
|
|
533
533
|
|
|
@@ -543,7 +543,7 @@ await ta('PATCH /detail: answer then CLEAR — the row reads back exactly as one
|
|
|
543
543
|
assert.deepEqual(cleared.body.detail, {}, 'and the read surface says the interview is unanswered');
|
|
544
544
|
assert.deepEqual(cleared.body.modules, neverAnswered, 'source, preset and adjustments are the never-answered ones');
|
|
545
545
|
assert.equal(cleared.body.modules.source, 'bundle');
|
|
546
|
-
assert.deepEqual(cleared.body.modules.selected, bundleForProjectType('
|
|
546
|
+
assert.deepEqual(cleared.body.modules.selected, bundleForProjectType('research').bundle, 'the UNADJUSTED preset');
|
|
547
547
|
assert.deepEqual(cleared.body.modules.adjustments, [], 'no rule fired, so nothing claims to have');
|
|
548
548
|
assert.deepEqual(cleared.body.publish, answered.body.publish, 'clearing an answer is not a publish change');
|
|
549
549
|
detailRow = null; lastUpdatedRow = null;
|
|
@@ -101,42 +101,34 @@ test('an open community adds somewhere to gather to a bundle that lacks one', ()
|
|
|
101
101
|
assert.ok(recs.bundle.adjustments[0].reason.length > 0, 'an adjustment always says why');
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
test('a
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// Assert
|
|
112
|
-
assert.deepEqual(recs.bundle.bundle, ['agents', 'dev-box']);
|
|
113
|
-
assert.deepEqual(recs.bundle.adjustments.map((a) => a.module), ['dev-box']);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test('a solo builder drops the shared build environment from the bundle', () => {
|
|
117
|
-
// Arrange — the business bundle is `agents` + `dev-box`, so the removal bites.
|
|
118
|
-
const answers = { type: 'business', team_shape: 'solo' };
|
|
104
|
+
test('a community adds the gathering place to a bundle that lacks it', () => {
|
|
105
|
+
// The surviving bundle rule's ADD path. The two rules that added and removed
|
|
106
|
+
// `dev-box` by team shape went with the module (task 1003886), which leaves
|
|
107
|
+
// `effect: 'remove'` declared in adjustBundle but named by no live rule - it is
|
|
108
|
+
// kept for the next rule that needs it, not because anything exercises it today.
|
|
109
|
+
// Arrange - the research bundle is `agents` alone, so the addition bites.
|
|
110
|
+
const answers = { type: 'research', team_shape: 'community' };
|
|
119
111
|
|
|
120
112
|
// Act
|
|
121
113
|
const recs = recommendationsForDetails(answers);
|
|
122
114
|
|
|
123
115
|
// Assert
|
|
124
|
-
assert.deepEqual(recs.bundle.bundle, ['agents']);
|
|
125
|
-
assert.
|
|
126
|
-
assert.equal(recs.bundle.adjustments[0].effect, '
|
|
127
|
-
assert.equal(recs.bundle.adjustments[0].module, 'dev-box');
|
|
116
|
+
assert.deepEqual(recs.bundle.bundle, ['agents', 'discord']);
|
|
117
|
+
assert.deepEqual(recs.bundle.adjustments.map((a) => a.module), ['discord']);
|
|
118
|
+
assert.equal(recs.bundle.adjustments[0].effect, 'add');
|
|
128
119
|
});
|
|
129
120
|
|
|
130
121
|
test('a rule does not fire for a team shape it does not name', () => {
|
|
131
|
-
// Arrange
|
|
132
|
-
|
|
122
|
+
// Arrange - `community` adds discord; `solo` names no rule, so the business
|
|
123
|
+
// bundle must come back exactly as declared.
|
|
124
|
+
const answers = { type: 'business', team_shape: 'solo' };
|
|
133
125
|
|
|
134
126
|
// Act
|
|
135
127
|
const recs = recommendationsForDetails(answers);
|
|
136
128
|
|
|
137
129
|
// Assert
|
|
138
|
-
assert.
|
|
139
|
-
assert.deepEqual(recs.bundle.adjustments
|
|
130
|
+
assert.deepEqual(recs.bundle.bundle, ['agents'], 'the community addition did not leak onto another shape');
|
|
131
|
+
assert.deepEqual(recs.bundle.adjustments, [], 'and nothing is claimed');
|
|
140
132
|
});
|
|
141
133
|
|
|
142
134
|
test('a rule whose module the bundle already carries records no adjustment', () => {
|
|
@@ -304,8 +296,8 @@ test('a caller may mutate the result without corrupting the declaration', () =>
|
|
|
304
296
|
|
|
305
297
|
// Assert
|
|
306
298
|
const second = recommendationsForDetails({ type: 'game', team_shape: 'small-team' });
|
|
307
|
-
assert.deepEqual(second.bundle.bundle, ['
|
|
308
|
-
assert.deepEqual(second.bundle.modules, [...ALWAYS_ON_CORE, '
|
|
299
|
+
assert.deepEqual(second.bundle.bundle, ['discord']);
|
|
300
|
+
assert.deepEqual(second.bundle.modules, [...ALWAYS_ON_CORE, 'discord']);
|
|
309
301
|
assert.equal(second.invites.seeking.limit, 3);
|
|
310
302
|
});
|
|
311
303
|
|
|
@@ -56,11 +56,11 @@ t("INTENT_ACTIONS includes 'restart', in lockstep with the provisioning_011 CHEC
|
|
|
56
56
|
const sql = readFileSync(new URL('../modules/provisioning/migrations/provisioning_011_restart.sql', import.meta.url), 'utf8');
|
|
57
57
|
const m = /CHECK \(action IN \(([^)]*)\)\)/.exec(sql);
|
|
58
58
|
assert.ok(m, 'the migration widens the action CHECK');
|
|
59
|
-
const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, ''))
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
assert.
|
|
59
|
+
const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, ''));
|
|
60
|
+
assert.ok(inSql.includes('restart'), 'this migration is the one that admitted it');
|
|
61
|
+
// The NEWEST action migration owns the strict set equality (provisioning_016,
|
|
62
|
+
// tests/provisioning_disconnect.mjs); an older one is a prefix of the set.
|
|
63
|
+
for (const a of inSql) assert.ok(provisioning.INTENT_ACTIONS.includes(a), `${a} is still an INTENT_ACTION`);
|
|
64
64
|
assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('provisioning_011_restart'\)/, 'self-registers');
|
|
65
65
|
});
|
|
66
66
|
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
OPTIONAL_MODULES,
|
|
38
38
|
STARTER_BUNDLES,
|
|
39
39
|
HUB_ONLY_MODULES,
|
|
40
|
+
RETIRED_MODULES,
|
|
40
41
|
RULED_TEAM_SHAPES,
|
|
41
42
|
bundleForProjectType,
|
|
42
43
|
allStarterBundles,
|
|
@@ -335,6 +336,9 @@ test('the opt-in catalog declared for the picker IS the on-disk opt-in set', ()
|
|
|
335
336
|
const onDisk = manifestsOnDisk();
|
|
336
337
|
const optInOnDisk = [...onDisk.values()]
|
|
337
338
|
.filter((m) => m.default !== true && !HUB_ONLY_MODULES.includes(m.key))
|
|
339
|
+
// A RETIRED module is still on disk while its removal lands, and must already
|
|
340
|
+
// be gone from the catalog — so it is the one permitted absence (task 1003886).
|
|
341
|
+
.filter((m) => !RETIRED_MODULES.includes(m.key))
|
|
338
342
|
.map((m) => m.key)
|
|
339
343
|
.sort();
|
|
340
344
|
|
|
@@ -346,6 +350,24 @@ test('the opt-in catalog declared for the picker IS the on-disk opt-in set', ()
|
|
|
346
350
|
'the picker\'s catalog has drifted from the manifests — a module was added, removed, or flipped its default');
|
|
347
351
|
});
|
|
348
352
|
|
|
353
|
+
test('a RETIRED module is withdrawn everywhere an owner could still reach it', () => {
|
|
354
|
+
// The retirement is only real if it holds at every offer point at once. A key
|
|
355
|
+
// left in any one of them is still a module a new project can end up with,
|
|
356
|
+
// which is exactly what the withdrawal exists to stop (task 1003886).
|
|
357
|
+
for (const key of RETIRED_MODULES) {
|
|
358
|
+
assert.ok(!OPTIONAL_MODULES.some((m) => m.key === key),
|
|
359
|
+
`retired module "${key}" is still a row the creation picker offers`);
|
|
360
|
+
for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
|
|
361
|
+
assert.ok(!bundle.modules.includes(key),
|
|
362
|
+
`retired module "${key}" is still preselected by the "${type}" bundle`);
|
|
363
|
+
}
|
|
364
|
+
// Posting it directly is a real answer that resolves to nothing, not a
|
|
365
|
+
// non-answer: the key is DROPPED, exactly as an unknown one would be.
|
|
366
|
+
assert.deepEqual(normalizeModuleSelection([key]), [],
|
|
367
|
+
`retired module "${key}" is still accepted when an owner posts it directly`);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
349
371
|
test('every opt-in module carries owner-facing copy, and no module jargon rides along', () => {
|
|
350
372
|
for (const row of OPTIONAL_MODULES) {
|
|
351
373
|
assert.ok(row.label && row.label.length <= 40, `"${row.key}" needs a short label`);
|
|
@@ -411,24 +433,24 @@ test('effectiveModules: an answered picker wins over the bundle, including "none
|
|
|
411
433
|
});
|
|
412
434
|
|
|
413
435
|
test('effectiveModules: a skipped picker after an ANSWERED interview yields the TAILORED bundle', () => {
|
|
414
|
-
// BV1.R22 / ADR 0243 — the property ADR 0240
|
|
436
|
+
// BV1.R22 / ADR 0243 — the property ADR 0240 leans the skip on ("the
|
|
415
437
|
// preselect and the fallback are the same declaration"), now that the panel
|
|
416
438
|
// preselects the team-shape-adjusted set. Without this the picker would show a
|
|
417
|
-
//
|
|
418
|
-
const eff = effectiveModules({ type: '
|
|
439
|
+
// community owner one thing and the project would quietly get another.
|
|
440
|
+
const eff = effectiveModules({ type: 'research', modules: null, detail: { team_shape: 'community' } });
|
|
419
441
|
|
|
420
|
-
assert.deepEqual(eff.selected, ['discord'], 'the
|
|
442
|
+
assert.deepEqual(eff.selected, ['agents', 'discord'], 'the gathering place the rule adds is really there');
|
|
421
443
|
assert.equal(eff.source, 'bundle', 'and it is still an UNANSWERED set, not a stored one');
|
|
422
|
-
assert.deepEqual(eff.adjustments.map((a) => a.module), ['
|
|
423
|
-
assert.match(eff.adjustments[0].reason, /
|
|
444
|
+
assert.deepEqual(eff.adjustments.map((a) => a.module), ['discord']);
|
|
445
|
+
assert.match(eff.adjustments[0].reason, /open community/, 'carrying the reason the panel showed');
|
|
424
446
|
});
|
|
425
447
|
|
|
426
448
|
test("effectiveModules: the owner's own answer is never re-adjusted by a rule", () => {
|
|
427
449
|
// A rule corrects a PRESELECT the owner did not make. Once they have toggled,
|
|
428
450
|
// re-applying it would overrule them with an answer they had already refused.
|
|
429
|
-
const eff = effectiveModules({ type: '
|
|
451
|
+
const eff = effectiveModules({ type: 'research', modules: [], detail: { team_shape: 'community' } });
|
|
430
452
|
|
|
431
|
-
assert.deepEqual(eff.selected, [
|
|
453
|
+
assert.deepEqual(eff.selected, [], 'community adds discord, and the owner switched everything off');
|
|
432
454
|
assert.equal(eff.source, 'chosen');
|
|
433
455
|
assert.deepEqual(eff.adjustments, [], 'and no reason is handed back for a choice they made themselves');
|
|
434
456
|
});
|
|
@@ -439,7 +461,7 @@ test('effectiveModules: a detail that says nothing usable adjusts nothing', () =
|
|
|
439
461
|
// importing it.
|
|
440
462
|
for (const detail of [undefined, null, {}, { team_shape: null }, { team_shape: 'a-huge-corporation' }, 'not-an-object', []]) {
|
|
441
463
|
const eff = effectiveModules({ type: 'game', modules: null, detail });
|
|
442
|
-
assert.deepEqual(eff.selected, ['
|
|
464
|
+
assert.deepEqual(eff.selected, ['discord'], `detail=${JSON.stringify(detail)} falls back to the type's bundle`);
|
|
443
465
|
assert.deepEqual(eff.adjustments, []);
|
|
444
466
|
}
|
|
445
467
|
});
|
|
@@ -138,7 +138,8 @@ test('the delete section is served live, and never claims to erase (task 1003042
|
|
|
138
138
|
assert.match(body, /id="mDelWhat"/, 'the teardown-vs-delete fold is served');
|
|
139
139
|
assert.match(body, /does <b>not<\/b> erase the project/i,
|
|
140
140
|
'the distinction survives to the wire, not just the source');
|
|
141
|
-
assert.match(body, /
|
|
141
|
+
assert.match(body, /use <b>Disconnect from Bongos entirely<\/b> below/, 'the true delete is its own card (task 1003249), and the fold says where');
|
|
142
|
+
assert.match(body, /id="mDisconnect"/, 'and that card is served');
|
|
142
143
|
// the confirmation asks for the project's own name — the heaviest gate on the page
|
|
143
144
|
assert.match(body, /Type the project’s name to confirm/, 'the typed confirmation is served');
|
|
144
145
|
assert.match(body, /data-del-yes disabled/, 'and the act starts unreachable');
|
|
@@ -146,8 +147,8 @@ test('the delete section is served live, and never claims to erase (task 1003042
|
|
|
146
147
|
|
|
147
148
|
test('the served teardown copy no longer claims the address goes quiet (task 1003042)', async () => {
|
|
148
149
|
const { body } = await request('/projects', 'game.test.local');
|
|
149
|
-
// teardown
|
|
150
|
-
//
|
|
150
|
+
// teardown parks the hostname and removes its Caddy block (task 1002897) but
|
|
151
|
+
// keeps the address for a revive, so "reserved" stays and "goes quiet" never did.
|
|
151
152
|
assert.doesNotMatch(body, /the address stops answering/,
|
|
152
153
|
'no served copy may promise a silence the runner does not deliver');
|
|
153
154
|
assert.match(body, /stays reserved for this project/,
|
package/tests/render_prefs.mjs
CHANGED
|
@@ -118,5 +118,157 @@ t('describeForApi reflects an explicit OFF override', () => {
|
|
|
118
118
|
assert.equal(d.override, false);
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
// ---- the card-richness ladder (task 1002518) --------------------------------
|
|
122
|
+
// The boolean became four rungs, and the rung a builder gets with no override is
|
|
123
|
+
// DERIVED from their interaction profile. These pin both halves, plus the
|
|
124
|
+
// compatibility surface every pinned CLI still reads.
|
|
125
|
+
|
|
126
|
+
console.log('\nthe ladder:');
|
|
127
|
+
|
|
128
|
+
t('LEVELS is ordered least → most rich, and rank follows the order', () => {
|
|
129
|
+
assert.deepEqual(r.LEVELS, ['minimal', 'text', 'standard', 'rich']);
|
|
130
|
+
assert.ok(r.levelRank('minimal') < r.levelRank('text'));
|
|
131
|
+
assert.ok(r.levelRank('text') < r.levelRank('standard'));
|
|
132
|
+
assert.ok(r.levelRank('standard') < r.levelRank('rich'));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
t('the predicates say what each rung draws', () => {
|
|
136
|
+
// minimal: nothing but the confirmation line.
|
|
137
|
+
assert.equal(r.showsCard('minimal'), false);
|
|
138
|
+
assert.equal(r.showsWidget('minimal'), false);
|
|
139
|
+
// text: the monospace card, no HTML.
|
|
140
|
+
assert.equal(r.showsCard('text'), true);
|
|
141
|
+
assert.equal(r.showsWidget('text'), false);
|
|
142
|
+
// standard: the widget, summary folded.
|
|
143
|
+
assert.equal(r.showsWidget('standard'), true);
|
|
144
|
+
assert.equal(r.widgetExpanded('standard'), false);
|
|
145
|
+
// rich: the widget, summary open.
|
|
146
|
+
assert.equal(r.showsWidget('rich'), true);
|
|
147
|
+
assert.equal(r.widgetExpanded('rich'), true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
t('cardFlags bundles the three answers a card emitter needs', () => {
|
|
151
|
+
assert.deepEqual(r.cardFlags('rich'), { level: 'rich', showCard: true, showWidget: true, expanded: true });
|
|
152
|
+
assert.deepEqual(r.cardFlags('minimal'), { level: 'minimal', showCard: false, showWidget: false, expanded: false });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
t('an unknown level rank-resolves to standard rather than throwing', () => {
|
|
156
|
+
// Defensive: a level string from an instance ahead of this code must degrade,
|
|
157
|
+
// not crash a ship. standard is the historical default rendering.
|
|
158
|
+
assert.equal(r.levelRank('nonsense'), r.levelRank('standard'));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
console.log('\nderivation from the interaction profile:');
|
|
162
|
+
|
|
163
|
+
const withProfile = (interaction) => ({ interaction });
|
|
164
|
+
|
|
165
|
+
t('the DEFAULT builder (non-technical, concise) derives the RICHEST card', () => {
|
|
166
|
+
// The point of the task: the reader who most needs explanation, and who by
|
|
167
|
+
// definition never opened Settings, must not get the terser card.
|
|
168
|
+
assert.equal(r.derivedLevel({}), 'rich');
|
|
169
|
+
assert.equal(r.derivedLevel(null), 'rich');
|
|
170
|
+
assert.equal(r.resolveLevel({}), 'rich');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
t('technical_level is the primary axis', () => {
|
|
174
|
+
assert.equal(r.derivedLevel(withProfile({ technical_level: 'non_technical' })), 'rich');
|
|
175
|
+
assert.equal(r.derivedLevel(withProfile({ technical_level: 'mixed' })), 'standard');
|
|
176
|
+
assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer' })), 'text');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
t('machinery:quiet drops a rung — an expert who asked for less gets terse or none', () => {
|
|
180
|
+
assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer', machinery: 'quiet' })), 'minimal');
|
|
181
|
+
assert.equal(r.derivedLevel(withProfile({ technical_level: 'non_technical', machinery: 'quiet' })), 'standard');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
t('verbosity:detailed raises a rung', () => {
|
|
185
|
+
assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer', verbosity: 'detailed' })), 'standard');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
t('verbosity:concise does NOT drop a rung (it is the default — see the derivation note)', () => {
|
|
189
|
+
// Taxing the default would push the accessible-by-default builder from rich
|
|
190
|
+
// down to standard, inverting the whole point of deriving from the profile.
|
|
191
|
+
assert.equal(
|
|
192
|
+
r.derivedLevel(withProfile({ technical_level: 'non_technical', verbosity: 'concise' })),
|
|
193
|
+
r.derivedLevel(withProfile({ technical_level: 'non_technical' })),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
t('the ladder clamps at both ends', () => {
|
|
198
|
+
// non_technical (rich) + detailed would be rank 4; engineer (text) + quiet
|
|
199
|
+
// twice over would go below 0. Neither may fall off the ladder.
|
|
200
|
+
assert.equal(r.derivedLevel(withProfile({ technical_level: 'non_technical', verbosity: 'detailed' })), 'rich');
|
|
201
|
+
assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer', machinery: 'quiet', verbosity: 'concise' })), 'minimal');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
t('derivedReason names the fields that actually moved the level', () => {
|
|
205
|
+
const plain = r.derivedReason({});
|
|
206
|
+
assert.match(plain, /technical level: non-technical/);
|
|
207
|
+
assert.ok(!/machinery/.test(plain), 'a default field is not listed as a reason');
|
|
208
|
+
const quiet = r.derivedReason(withProfile({ technical_level: 'engineer', machinery: 'quiet' }));
|
|
209
|
+
assert.match(quiet, /machinery: quiet/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
console.log('\noverrides beat derivation:');
|
|
213
|
+
|
|
214
|
+
t('an explicit level override wins over the profile', () => {
|
|
215
|
+
const eng = { interaction: { technical_level: 'engineer' }, widgets: 'rich' };
|
|
216
|
+
assert.equal(r.derivedLevel(eng), 'text', 'the profile would have said text');
|
|
217
|
+
assert.equal(r.resolveLevel(eng), 'rich', 'the override wins');
|
|
218
|
+
assert.equal(r.overrideLevel(eng), 'rich');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
t('clearing the override falls back to the derived level', () => {
|
|
222
|
+
const cleared = r.applyPatch({ widgets: 'minimal', interaction: { technical_level: 'mixed' } }, { widgets: null });
|
|
223
|
+
assert.ok(!('widgets' in cleared));
|
|
224
|
+
assert.equal(r.resolveLevel(cleared), 'standard', 'back to what the profile says');
|
|
225
|
+
assert.equal(r.overrideLevel(cleared), null);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
console.log('\nlegacy boolean compatibility (a pinned CLI / an older hall build):');
|
|
229
|
+
|
|
230
|
+
t('a stored legacy boolean keeps the exact card it always meant', () => {
|
|
231
|
+
assert.equal(r.overrideLevel({ widgets: true }), 'standard');
|
|
232
|
+
assert.equal(r.overrideLevel({ widgets: false }), 'text');
|
|
233
|
+
// And it still beats the profile, because it IS an explicit choice.
|
|
234
|
+
assert.equal(r.resolveLevel({ widgets: false, interaction: { technical_level: 'non_technical' } }), 'text');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
t('validatePatch still accepts booleans, normalising them to a level on the way in', () => {
|
|
238
|
+
assert.equal(r.validatePatch({ widgets: true }).patch.widgets, 'standard');
|
|
239
|
+
assert.equal(r.validatePatch({ widgets: false }).patch.widgets, 'text');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
t('validatePatch accepts every level and rejects a non-level string', () => {
|
|
243
|
+
for (const lvl of r.LEVELS) {
|
|
244
|
+
assert.equal(r.validatePatch({ widgets: lvl }).ok, true, `accept ${lvl}`);
|
|
245
|
+
}
|
|
246
|
+
const bad = r.validatePatch({ widgets: 'verbose' });
|
|
247
|
+
assert.equal(bad.ok, false);
|
|
248
|
+
assert.equal(bad.errors[0].error, 'invalid_value');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
t('widgetsEnabled — the boolean every pinned CLI reads — tracks the ladder', () => {
|
|
252
|
+
assert.equal(r.widgetsEnabled({ widgets: 'minimal' }), false);
|
|
253
|
+
assert.equal(r.widgetsEnabled({ widgets: 'text' }), false);
|
|
254
|
+
assert.equal(r.widgetsEnabled({ widgets: 'standard' }), true);
|
|
255
|
+
assert.equal(r.widgetsEnabled({ widgets: 'rich' }), true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
t('describeForApi keeps the legacy keys AND adds the ladder', () => {
|
|
259
|
+
const d = r.describeForApi({ interaction: { technical_level: 'engineer' } });
|
|
260
|
+
// legacy half — an older CLI reads only these and is correct
|
|
261
|
+
assert.equal(d.widgets_enabled, false, 'engineer derives text, so no widget');
|
|
262
|
+
assert.equal(d.default_enabled, true);
|
|
263
|
+
assert.equal(d.override, null, 'derived, not chosen');
|
|
264
|
+
// ladder half
|
|
265
|
+
assert.equal(d.level, 'text');
|
|
266
|
+
assert.equal(d.derived_level, 'text');
|
|
267
|
+
assert.equal(d.override_level, null);
|
|
268
|
+
assert.match(d.derived_reason, /technical level: engineer/);
|
|
269
|
+
assert.deepEqual(d.levels.map((l) => l.value), r.LEVELS);
|
|
270
|
+
assert.ok(d.levels.every((l) => l.label && l.blurb), 'every rung is labelled for the UI');
|
|
271
|
+
});
|
|
272
|
+
|
|
121
273
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
122
274
|
process.exit(failed === 0 ? 0 : 1);
|