@bongos/core 1.20.53 → 1.20.54
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 +39 -29
- 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 +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +92 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +21 -12
- package/docs/copy-registry.json +95 -14
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +52 -40
- package/modules/government/board.js +15 -21
- package/modules/government/recommendations.js +218 -0
- package/modules/government/routes/government.js +47 -1
- package/modules/hall-ui/public/government.js +125 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/route-rank-check.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/government_charter_library.mjs +11 -5
- package/tests/government_constitution_view.mjs +4 -1
- package/tests/government_recommendations.mjs +119 -0
- package/tests/government_routes.mjs +35 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// tests/government_recommendations.mjs — recommended governments and base-rank
|
|
2
|
+
// sets by project TYPE and SIZE (task 1004400, criterion wa7-government).
|
|
3
|
+
//
|
|
4
|
+
// The rules held here, each because the live site once broke it:
|
|
5
|
+
// • a recommendation only ever names a charter that can be ADOPTED — the
|
|
6
|
+
// ladder used to recommend Presidential (~9) and Federation (150+), and the
|
|
7
|
+
// live project, at 10 builders, was told to adopt one nobody can;
|
|
8
|
+
// • the type really changes the answer, not just the wording;
|
|
9
|
+
// • every grant a recommended rank carries is one a custom rank can hold;
|
|
10
|
+
// • a Foundation is always recommended together with the council rank it is
|
|
11
|
+
// governed through, and that rank makes a valid charter proposal.
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const reco = require(path.join(ROOT, 'modules', 'government', 'recommendations.js'));
|
|
22
|
+
const charter = require(path.join(ROOT, 'modules', 'government', 'charter.js'));
|
|
23
|
+
const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
|
|
24
|
+
const provisioning = require(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'));
|
|
25
|
+
|
|
26
|
+
const TYPES = reco.PROJECT_TYPES.map((t) => t.key);
|
|
27
|
+
// Every band edge, plus nonsense — the edges are where a ladder goes wrong.
|
|
28
|
+
const HEADCOUNTS = [0, 1, 2, 3, 8, 9, 10, 49, 50, 149, 150, 151, 1000, -4, 'nonsense', null];
|
|
29
|
+
const CUSTOM_RANK = { is_system: false, seeded_rank: null };
|
|
30
|
+
|
|
31
|
+
// The hall's charter library says which charters are selectable; read it from
|
|
32
|
+
// the client source, since browser code cannot be required.
|
|
33
|
+
function hallSelectableKeys() {
|
|
34
|
+
const client = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
|
|
35
|
+
const lib = client.slice(client.indexOf('const CHARTERS'), client.indexOf('const TRAJECTORIES'));
|
|
36
|
+
return [...lib.matchAll(/key: '([a-z-]+)', name: '[^']+', selectable: true/g)].map((m) => m[1]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('the project types are exactly the ones a project declares at creation', () => {
|
|
40
|
+
assert.deepEqual(TYPES, provisioning.PROJECT_TYPES);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('every type and size recommends a charter that can be adopted today', () => {
|
|
44
|
+
const available = charter.FORMS.filter((f) => f.available).map((f) => f.key);
|
|
45
|
+
const selectable = hallSelectableKeys();
|
|
46
|
+
assert.ok(selectable.length >= 4, 'the hall library parse found its selectable charters');
|
|
47
|
+
for (const type of [...TYPES, undefined, 'spaceship']) {
|
|
48
|
+
for (const n of HEADCOUNTS) {
|
|
49
|
+
const c = reco.recommendedCharter(n, type);
|
|
50
|
+
assert.ok(available.includes(c.key), `${type}@${n} recommends "${c.key}", which charter.js cannot adopt`);
|
|
51
|
+
assert.ok(selectable.includes(c.key), `${type}@${n} recommends "${c.key}", which the hall marks not selectable`);
|
|
52
|
+
if (c.later) assert.ok(!available.includes(c.later.key), 'a `later` shape is one that cannot be adopted yet');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('the live case: a project of 10 is no longer told to adopt Presidential', () => {
|
|
58
|
+
const c = reco.recommendedCharter(10, 'not-sure');
|
|
59
|
+
assert.equal(c.key, 'timocratic-republic');
|
|
60
|
+
assert.equal(c.later.key, 'presidential', 'what the ladder would name is still said, as coming later');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the project type changes the answer, not just the words', () => {
|
|
64
|
+
assert.equal(reco.recommendedCharter(20, 'game').key, 'timocratic-republic');
|
|
65
|
+
assert.equal(reco.recommendedCharter(20, 'non-profit').key, 'foundation', 'a non-profit is governed by trustees early');
|
|
66
|
+
assert.equal(reco.recommendedRankSet('game', 5).ranks[0].rank_key, 'playtester');
|
|
67
|
+
assert.equal(reco.recommendedRankSet('research', 5).ranks[0].rank_key, 'peer-reviewer');
|
|
68
|
+
assert.equal(reco.recommendedCharter(20, 'spaceship').type, 'not-sure', 'an unknown type reads as not sure');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the rank set grows with size', () => {
|
|
72
|
+
const sizes = [1, 5, 20, 80, 200].map((n) => reco.recommendedRankSet('business', n).ranks.length);
|
|
73
|
+
assert.deepEqual(sizes, [0, 1, 2, 3, 3]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('every recommended rank is creatable and every grant is one a custom rank can hold', () => {
|
|
77
|
+
for (const type of TYPES) {
|
|
78
|
+
for (const n of HEADCOUNTS) {
|
|
79
|
+
for (const r of reco.recommendedRankSet(type, n).ranks) {
|
|
80
|
+
assert.match(r.rank_key, /^[a-z][a-z0-9-]*$/, `${r.rank_key} must be a slug the rank API accepts`);
|
|
81
|
+
assert.ok(r.rank_key.length >= 2 && r.rank_key.length <= 64);
|
|
82
|
+
assert.ok(!catalog.RANK_ORDER.includes(r.rank_key), `${r.rank_key} must not collide with a standard rank`);
|
|
83
|
+
assert.ok(r.label && r.description);
|
|
84
|
+
assert.ok(r.permissions.length > 0);
|
|
85
|
+
for (const key of r.permissions) {
|
|
86
|
+
assert.ok(catalog.byKey(key), `${r.rank_key}: ${key} is not in the catalog`);
|
|
87
|
+
assert.ok(catalog.isGrantableTo(key, CUSTOM_RANK), `${r.rank_key}: ${key} cannot be granted to a custom rank`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a recommended Foundation always comes with its council rank, and that rank makes a valid charter', () => {
|
|
95
|
+
const live = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
|
|
96
|
+
let foundations = 0;
|
|
97
|
+
for (const type of TYPES) {
|
|
98
|
+
for (const n of HEADCOUNTS) {
|
|
99
|
+
const c = reco.recommendedCharter(n, type);
|
|
100
|
+
if (c.key !== 'foundation') { assert.equal(c.council_rank, null); continue; }
|
|
101
|
+
foundations += 1;
|
|
102
|
+
const set = reco.recommendedRankSet(type, n).ranks.map((r) => r.rank_key);
|
|
103
|
+
assert.ok(set.includes(c.council_rank), `${type}@${n}: the rank set must carry ${c.council_rank}`);
|
|
104
|
+
const built = charter.buildCharterProposal(live, { form: 'foundation', councilRank: c.council_rank });
|
|
105
|
+
assert.ok(built.ok, `${type}@${n}: ${c.council_rank} must make a valid Foundation (${built.reason})`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
assert.ok(foundations > 0, 'the walk reached a Foundation');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('recommendationsFor carries both answers and the vocabulary to ask about another', () => {
|
|
112
|
+
const r = reco.recommendationsFor({ type: 'research', headcount: 60 });
|
|
113
|
+
assert.equal(r.type, 'research');
|
|
114
|
+
assert.equal(r.size, 'organisation');
|
|
115
|
+
assert.equal(r.charter.key, 'foundation');
|
|
116
|
+
assert.equal(r.rank_set.ranks.length, 3);
|
|
117
|
+
assert.equal(r.project_types.length, TYPES.length);
|
|
118
|
+
for (const b of r.size_bands) assert.equal(reco.sizeBandFor(b.example).key, b.key, 'each size option lands in its own band');
|
|
119
|
+
});
|
|
@@ -60,6 +60,7 @@ test('the routes file declares the full R110 surface', () => {
|
|
|
60
60
|
'POST /government/ranks/:rankKey/reset',
|
|
61
61
|
'POST /government/assignments',
|
|
62
62
|
'DELETE /government/assignments/:builderId/:rankKey',
|
|
63
|
+
'POST /government/rank-sets/apply',
|
|
63
64
|
];
|
|
64
65
|
for (const key of wanted) assert.ok(declared.has(key), `missing route ${key}`);
|
|
65
66
|
});
|
|
@@ -203,6 +204,7 @@ test('without government.manage EVERY route 403s (reads included)', async () =>
|
|
|
203
204
|
['DELETE', '/government/ranks/r/permissions/task.create'],
|
|
204
205
|
['POST', '/government/assignments'],
|
|
205
206
|
['DELETE', '/government/assignments/7/r'],
|
|
207
|
+
['POST', '/government/rank-sets/apply'],
|
|
206
208
|
]) {
|
|
207
209
|
const r = await call(method, p, method === 'GET' ? undefined : {});
|
|
208
210
|
assert.equal(r.status, 403, `${method} ${p} must deny`);
|
|
@@ -243,6 +245,39 @@ test('GET /government/assignments returns builder↔role rows', async () => {
|
|
|
243
245
|
assert.equal(r.json.assignments[0].rank_key, 'metic');
|
|
244
246
|
});
|
|
245
247
|
|
|
248
|
+
// --- recommended rank sets (task 1004400) -------------------------------------
|
|
249
|
+
|
|
250
|
+
test('POST /government/rank-sets/apply creates the missing ranks with their grants and leaves existing ones alone', async () => {
|
|
251
|
+
const realGet = stub.getRankByKey;
|
|
252
|
+
// 'design-lead' already exists: it must be skipped, never re-granted.
|
|
253
|
+
stub.getRankByKey = async (k) => (k === 'design-lead' ? { rank_key: k, label: 'Mine', is_system: false, seeded_rank: null } : null);
|
|
254
|
+
try {
|
|
255
|
+
const r = await call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 20 });
|
|
256
|
+
assert.equal(r.status, 200);
|
|
257
|
+
assert.deepEqual(r.json.skipped, ['design-lead']);
|
|
258
|
+
assert.deepEqual(r.json.created.map((c) => c.rank_key), ['playtester']);
|
|
259
|
+
const creates = calls.filter(([k]) => k === 'createRank');
|
|
260
|
+
assert.equal(creates.length, 1);
|
|
261
|
+
assert.equal(creates[0][1].createdBy, 42, 'created_by is the session builder');
|
|
262
|
+
const grants = calls.filter(([k]) => k === 'grantRankPermission').map(([, a]) => a);
|
|
263
|
+
assert.ok(grants.length > 0 && grants.every((g) => g.rankKey === 'playtester'), 'an existing rank is never re-granted');
|
|
264
|
+
assert.ok(grants.every((g) => catalog.isGrantableTo(g.permissionKey, { is_system: false, seeded_rank: null })));
|
|
265
|
+
} finally { stub.getRankByKey = realGet; }
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('POST /government/rank-sets/apply needs a headcount and does nothing at founding size', async () => {
|
|
269
|
+
const bad = await call('POST', '/government/rank-sets/apply', { type: 'game' });
|
|
270
|
+
assert.equal(bad.status, 400);
|
|
271
|
+
const realGet = stub.getRankByKey;
|
|
272
|
+
stub.getRankByKey = async () => null;
|
|
273
|
+
try {
|
|
274
|
+
const solo = await call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 1 });
|
|
275
|
+
assert.equal(solo.status, 200);
|
|
276
|
+
assert.deepEqual(solo.json.created, []);
|
|
277
|
+
assert.equal(calls.filter(([k]) => k === 'createRank').length, 0);
|
|
278
|
+
} finally { stub.getRankByKey = realGet; }
|
|
279
|
+
});
|
|
280
|
+
|
|
246
281
|
// --- create role -------------------------------------------------------------
|
|
247
282
|
|
|
248
283
|
test('POST /government/ranks creates a custom role, created_by = the session builder', async () => {
|