@bongos/core 1.24.72 → 1.24.74
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 +107 -77
- 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 +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +36 -2
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +5 -5
- package/docs/copy-registry.json +5 -5
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +33 -0
- package/docs/page-readings.json +33 -33
- package/modules/government/board.js +19 -7
- package/modules/government/config.js +21 -9
- package/modules/government/constitution.js +224 -0
- package/modules/government/db.js +28 -0
- package/modules/government/migrations/government_037_constitution.sql +33 -0
- package/modules/government/module.json +2 -1
- package/modules/government/pollers/constitution-restore.js +45 -0
- package/modules/government/routes/government.js +7 -7
- package/modules/hall-ui/public/ai-studio.html +2 -1
- package/modules/hall-ui/public/atlas.html +2 -1
- package/modules/hall-ui/public/blockers.html +2 -1
- package/modules/hall-ui/public/board-room.html +2 -1
- package/modules/hall-ui/public/board-room.js +7 -3
- package/modules/hall-ui/public/city.html +2 -1
- package/modules/hall-ui/public/collab.html +2 -1
- package/modules/hall-ui/public/commands.html +2 -1
- package/modules/hall-ui/public/copy-desk.html +2 -1
- package/modules/hall-ui/public/deploy.html +2 -1
- package/modules/hall-ui/public/goal-map.html +2 -1
- package/modules/hall-ui/public/goals.html +2 -1
- package/modules/hall-ui/public/government.html +2 -1
- package/modules/hall-ui/public/idea.html +2 -1
- package/modules/hall-ui/public/ideas.html +2 -1
- package/modules/hall-ui/public/index.html +2 -1
- package/modules/hall-ui/public/market-watch-item.html +2 -1
- package/modules/hall-ui/public/market-watch-rules.html +2 -1
- package/modules/hall-ui/public/market-watch.html +2 -1
- package/modules/hall-ui/public/modules.html +2 -1
- package/modules/hall-ui/public/money.html +2 -1
- package/modules/hall-ui/public/my-agents.html +2 -1
- package/modules/hall-ui/public/primer.html +2 -1
- package/modules/hall-ui/public/profile.html +2 -1
- package/modules/hall-ui/public/project-settings.html +2 -1
- package/modules/hall-ui/public/roster.html +2 -1
- package/modules/hall-ui/public/settings.html +2 -1
- package/modules/hall-ui/public/shell-game.js +50 -0
- package/modules/hall-ui/public/shell.js +11 -11
- package/modules/hall-ui/public/street-card.js +2 -0
- package/modules/hall-ui/public/street.html +2 -1
- package/modules/hall-ui/public/studio.html +2 -1
- package/modules/hall-ui/public/task.html +2 -1
- package/modules/hall-ui/public/thinking.html +2 -1
- package/modules/hall-ui/public/tweak-editor.html +2 -1
- package/modules/hall-ui/public/watch.html +2 -1
- package/modules/hall-ui/public/work.html +2 -1
- package/modules/hall-ui/records/game-mode.md +20 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/government-walk.js +7 -0
- package/src/bongos/route-rank-check.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/genesis_government.mjs +11 -0
- package/tests/goal_map_page.mjs +2 -2
- package/tests/government_board_amendment.mjs +10 -0
- package/tests/government_charter_forms.mjs +10 -0
- package/tests/government_constitution_durable.mjs +304 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/hall_city.mjs +1 -1
- package/tests/hall_fullscreen.mjs +101 -0
- package/tests/hall_my_agents.mjs +1 -1
- package/tests/hall_street.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// tests/government_constitution_durable.mjs — a ratified constitution survives a
|
|
2
|
+
// restart (task 1005140).
|
|
3
|
+
//
|
|
4
|
+
// Found live on toast, 2026-11-07: the board ratified a Timocratic Republic, the
|
|
5
|
+
// amendment was written to <instance root>/config/government.json, and the next
|
|
6
|
+
// container start (deploy, restart, or an idle wake on a free plan) erased the
|
|
7
|
+
// file. The project went back to the day-one monarchy without an error.
|
|
8
|
+
//
|
|
9
|
+
// DB-free: the constitution row lives in an in-memory stand-in for db.js's two
|
|
10
|
+
// functions; the instance pack is a REAL temp file, and "a restart" is deleting
|
|
11
|
+
// it. The invariants:
|
|
12
|
+
//
|
|
13
|
+
// 1. A restart does not lose a ratified constitution — the row restores it.
|
|
14
|
+
// 2. A file with no row is ADOPTED into the row (nobody loses one on upgrade).
|
|
15
|
+
// 3. The row wins over a stale file.
|
|
16
|
+
// 4. The row is written BEFORE the file, and a write that did not take is
|
|
17
|
+
// REPORTED at the close (constitution_not_applied), never absorbed.
|
|
18
|
+
// 5. The operator's re-apply only ever re-applies a PASSED constitutional
|
|
19
|
+
// amendment — it cannot invent a block.
|
|
20
|
+
// 6. The restore runs at boot, immediately.
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
32
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
33
|
+
const config = require(path.join(GOV, 'config.js'));
|
|
34
|
+
const constitution = require(path.join(GOV, 'constitution.js'));
|
|
35
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
36
|
+
|
|
37
|
+
const MONARCHY = Object.freeze({ membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true });
|
|
38
|
+
const TIMOCRACY = Object.freeze({ membership: 'rank:metic+', pass_rule: 'consent', window_minutes: null, close_early_on_full_turnout: true });
|
|
39
|
+
const quiet = { log: () => {}, error: () => {} };
|
|
40
|
+
|
|
41
|
+
let seq = 0;
|
|
42
|
+
const tmpPack = () => path.join(os.tmpdir(), `gov-durable-${process.pid}-${Date.now()}-${seq++}`, 'config', 'government.json');
|
|
43
|
+
|
|
44
|
+
// One harness: an in-memory row, and the config's instance path redirected to
|
|
45
|
+
// a temp file for both the write and the read — what a real process does with
|
|
46
|
+
// its own INSTANCE_PATH.
|
|
47
|
+
async function withInstance(fn, { row = null, saveFails = false, getFails = null, history } = {}) {
|
|
48
|
+
const pack = tmpPack();
|
|
49
|
+
const store = { row, saves: [] };
|
|
50
|
+
const orig = {
|
|
51
|
+
get: db.getConstitutionRow, save: db.saveConstitutionRow,
|
|
52
|
+
apply: config.applyBoardAmendment, load: config.loadGovernmentConfig,
|
|
53
|
+
list: db.listAmendmentHistory,
|
|
54
|
+
};
|
|
55
|
+
if (history !== undefined) db.listAmendmentHistory = async () => history;
|
|
56
|
+
db.getConstitutionRow = async () => { if (getFails) throw getFails; return store.row; };
|
|
57
|
+
db.saveConstitutionRow = async (args) => {
|
|
58
|
+
if (saveFails) throw new Error('connection refused');
|
|
59
|
+
store.saves.push(args);
|
|
60
|
+
store.row = { board: args.board, source: args.source, amendment_item_id: args.itemId };
|
|
61
|
+
return store.row;
|
|
62
|
+
};
|
|
63
|
+
config.applyBoardAmendment = (proposed, opts = {}) => orig.apply(proposed, { instancePath: opts.instancePath || pack });
|
|
64
|
+
config.loadGovernmentConfig = (opts = {}) => orig.load({ ...opts, instancePath: opts.instancePath || pack });
|
|
65
|
+
try { return await fn({ pack, store, live: () => orig.load({ instancePath: pack }).board }); }
|
|
66
|
+
finally {
|
|
67
|
+
db.getConstitutionRow = orig.get; db.saveConstitutionRow = orig.save; db.listAmendmentHistory = orig.list;
|
|
68
|
+
config.applyBoardAmendment = orig.apply; config.loadGovernmentConfig = orig.load;
|
|
69
|
+
fs.rmSync(path.dirname(path.dirname(pack)), { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const restart = (pack) => fs.rmSync(pack, { force: true });
|
|
74
|
+
|
|
75
|
+
// ── 1. the bug itself ────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
test('a ratified constitution survives a container restart (the toast bug)', async () => {
|
|
78
|
+
await withInstance(async ({ pack, store, live }) => {
|
|
79
|
+
const out = await constitution.recordRatified(TIMOCRACY, { itemId: '7' });
|
|
80
|
+
assert.equal(out.failure, null, `the ratified amendment took: ${JSON.stringify(out.failure)}`);
|
|
81
|
+
assert.deepEqual({ ...live() }, TIMOCRACY, 'in force right after the close');
|
|
82
|
+
assert.deepEqual(store.row.board, TIMOCRACY, 'the durable row holds it');
|
|
83
|
+
assert.equal(store.row.source, 'amendment');
|
|
84
|
+
|
|
85
|
+
restart(pack); // the container's filesystem is wiped
|
|
86
|
+
assert.deepEqual({ ...live() }, MONARCHY, 'fixture: with the file gone the process alone falls back to monarchy');
|
|
87
|
+
|
|
88
|
+
const r = await constitution.reconcile({ instancePath: pack, ...quiet });
|
|
89
|
+
assert.equal(r.action, 'restored');
|
|
90
|
+
assert.deepEqual({ ...live() }, TIMOCRACY, 'after the boot restore the board\'s decision is in force again');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── 2 + 3. adopt on first read; the row wins ─────────────────────────────────
|
|
95
|
+
|
|
96
|
+
test('a file with no row is adopted into the database and the log says so', async () => {
|
|
97
|
+
await withInstance(async ({ pack, store }) => {
|
|
98
|
+
fs.mkdirSync(path.dirname(pack), { recursive: true });
|
|
99
|
+
fs.writeFileSync(pack, JSON.stringify({ ranks: { metic: { label: 'Trusted' } }, board: TIMOCRACY }));
|
|
100
|
+
const lines = [];
|
|
101
|
+
const r = await constitution.reconcile({ instancePath: pack, log: (m) => lines.push(m), error: () => {} });
|
|
102
|
+
assert.equal(r.action, 'adopted');
|
|
103
|
+
assert.deepEqual(store.row.board, TIMOCRACY);
|
|
104
|
+
assert.equal(store.row.source, 'adopted_file');
|
|
105
|
+
assert.ok(lines.some((l) => /adopted/.test(l)), 'the adoption is logged');
|
|
106
|
+
const again = await constitution.reconcile({ instancePath: pack, ...quiet });
|
|
107
|
+
assert.equal(again.action, 'in_step', 'a second pass changes nothing');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('no row and no file is the vanilla instance: nothing is written', async () => {
|
|
112
|
+
await withInstance(async ({ pack, store }) => {
|
|
113
|
+
const r = await constitution.reconcile({ instancePath: pack, ...quiet });
|
|
114
|
+
assert.equal(r.action, 'none');
|
|
115
|
+
assert.equal(store.saves.length, 0);
|
|
116
|
+
assert.equal(fs.existsSync(pack), false, 'no file is invented for a vanilla instance');
|
|
117
|
+
}, { history: [] });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('the toast case: no row, no file, a passed amendment — the first boot puts it back in force (part C)', async () => {
|
|
121
|
+
const history = [
|
|
122
|
+
{ item_id: 30, amendment_id: 9, outcome: 'returned', proposed: MONARCHY, kind: 'constitution' },
|
|
123
|
+
{ item_id: 21, amendment_id: 1, outcome: 'passed', proposed: TIMOCRACY, kind: 'constitution' },
|
|
124
|
+
];
|
|
125
|
+
await withInstance(async ({ pack, store, live }) => {
|
|
126
|
+
assert.deepEqual({ ...live() }, MONARCHY, 'fixture: the erased instance answers the day-one monarchy');
|
|
127
|
+
const lines = [];
|
|
128
|
+
const r = await constitution.reconcile({ instancePath: pack, log: (m) => lines.push(m), error: () => {} });
|
|
129
|
+
assert.equal(r.action, 'recovered', JSON.stringify(r));
|
|
130
|
+
assert.equal(r.item_id, '21', 'the PASSED amendment, never the newer returned one');
|
|
131
|
+
assert.deepEqual({ ...live() }, TIMOCRACY, 'the board decision is in force again');
|
|
132
|
+
assert.deepEqual(store.row.board, TIMOCRACY, 'and durable, so the next restart keeps it');
|
|
133
|
+
assert.ok(lines.some((l) => /put back passed amendment 1/.test(l)), 'the log says so');
|
|
134
|
+
assert.equal((await constitution.reconcile({ instancePath: pack, ...quiet })).action, 'in_step');
|
|
135
|
+
}, { history });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('the row wins over a stale file, and the file keeps its other keys', async () => {
|
|
139
|
+
await withInstance(async ({ pack, live }) => {
|
|
140
|
+
fs.mkdirSync(path.dirname(pack), { recursive: true });
|
|
141
|
+
fs.writeFileSync(pack, JSON.stringify({ ranks: { metic: { label: 'Trusted' } }, board: MONARCHY }));
|
|
142
|
+
const r = await constitution.reconcile({ instancePath: pack, ...quiet });
|
|
143
|
+
assert.equal(r.action, 'restored');
|
|
144
|
+
assert.deepEqual({ ...live() }, TIMOCRACY);
|
|
145
|
+
assert.equal(JSON.parse(fs.readFileSync(pack, 'utf8')).ranks.metic.label, 'Trusted', 'rank templates survive the restore');
|
|
146
|
+
}, { row: { board: TIMOCRACY, source: 'amendment' } });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('an unmigrated database (no table) and an unreadable one never throw and never touch the file', async () => {
|
|
150
|
+
const missing = Object.assign(new Error('relation "government_constitution" does not exist'), { code: '42P01' });
|
|
151
|
+
await withInstance(async ({ pack }) => {
|
|
152
|
+
assert.equal((await constitution.reconcile({ instancePath: pack, ...quiet })).action, 'no_table');
|
|
153
|
+
assert.equal(fs.existsSync(pack), false);
|
|
154
|
+
}, { getFails: missing });
|
|
155
|
+
await withInstance(async ({ pack }) => {
|
|
156
|
+
assert.equal((await constitution.reconcile({ instancePath: pack, ...quiet })).action, 'read_failed');
|
|
157
|
+
}, { getFails: new Error('timeout') });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── 4. row first, and a write that did not take is reported ──────────────────
|
|
161
|
+
|
|
162
|
+
test('the row is written before the file: a failed save leaves the old rule and SAYS so', async () => {
|
|
163
|
+
await withInstance(async ({ pack, live }) => {
|
|
164
|
+
const out = await constitution.recordRatified(TIMOCRACY, { itemId: '7' });
|
|
165
|
+
assert.equal(out.applied, null);
|
|
166
|
+
assert.ok(out.failure, 'reported, not absorbed');
|
|
167
|
+
assert.equal(out.failure.problems[0].where, 'database');
|
|
168
|
+
assert.equal(fs.existsSync(pack), false, 'nothing was put in force that would vanish at the next restart');
|
|
169
|
+
assert.equal(out.failure.problems[0].reason, 'save_failed');
|
|
170
|
+
assert.doesNotMatch(JSON.stringify(out.failure), /connection refused/, 'a voter sees a reason word, never the driver message');
|
|
171
|
+
assert.deepEqual({ ...live() }, MONARCHY);
|
|
172
|
+
}, { saveFails: true });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('a file write that silently did not take is caught by the read-back', async () => {
|
|
176
|
+
await withInstance(async ({ store }) => {
|
|
177
|
+
config.applyBoardAmendment = (proposed) => ({ ...proposed }); // claims success, writes nothing
|
|
178
|
+
const out = await constitution.recordRatified(TIMOCRACY, { itemId: '7' });
|
|
179
|
+
assert.ok(out.failure, 'the verify caught it');
|
|
180
|
+
assert.deepEqual(out.failure.problems.map((p) => p.where), ['in_force']);
|
|
181
|
+
assert.deepEqual(out.failure.problems[0].fields, ['membership', 'pass_rule']);
|
|
182
|
+
assert.deepEqual(store.row.board, TIMOCRACY, 'the durable row still holds the decision, so a restore can finish the job');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('closeItem hands a constitution that did not take back to the caller (and the vote route forwards it)', async () => {
|
|
187
|
+
const item = { id: '21', subject_type: 'constitutional_amendment', subject_id: '5', opened_by: '42', closed_at: null, expired: false, constitution: MONARCHY };
|
|
188
|
+
const stubs = {
|
|
189
|
+
getVotesForItem: async () => [{ id: '1', voter_id: '42', direction: 'yes' }],
|
|
190
|
+
closeBoardItem: async ({ outcome }) => ({ ...item, closed_at: 'T', outcome }),
|
|
191
|
+
getAmendment: async () => ({ id: '5', proposed: TIMOCRACY, kind: 'constitution' }),
|
|
192
|
+
insertAuthorRatifyKarma: async () => true,
|
|
193
|
+
getBoardItemAuthor: async () => '42',
|
|
194
|
+
};
|
|
195
|
+
const orig = {};
|
|
196
|
+
for (const [k, v] of Object.entries(stubs)) { orig[k] = db[k]; db[k] = v; }
|
|
197
|
+
try {
|
|
198
|
+
await withInstance(async () => {
|
|
199
|
+
const closed = await board.closeItem(item, { cause: 'vote' });
|
|
200
|
+
assert.equal(closed.outcome, 'passed');
|
|
201
|
+
assert.ok(closed.constitution_not_applied, 'the close itself reports it');
|
|
202
|
+
assert.equal(closed.constitution_not_applied.problems[0].where, 'database');
|
|
203
|
+
}, { saveFails: true });
|
|
204
|
+
await withInstance(async ({ live }) => {
|
|
205
|
+
const closed = await board.closeItem(item, { cause: 'vote' });
|
|
206
|
+
assert.equal(closed.constitution_not_applied, undefined, 'a clean apply reports nothing');
|
|
207
|
+
assert.deepEqual({ ...closed.applied_constitution }, TIMOCRACY);
|
|
208
|
+
assert.deepEqual({ ...live() }, TIMOCRACY);
|
|
209
|
+
});
|
|
210
|
+
} finally { for (const [k, v] of Object.entries(orig)) db[k] = v; }
|
|
211
|
+
|
|
212
|
+
const routeSrc = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
|
|
213
|
+
assert.match(routeSrc, /closure && closure\.constitution_not_applied \? \{ constitution_not_applied:/,
|
|
214
|
+
'the vote route forwards it to the page the voter is standing on');
|
|
215
|
+
for (const page of ['board-room.js', 'street-card.js']) {
|
|
216
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', page), 'utf8');
|
|
217
|
+
assert.match(src, /constitution_not_applied/, `${page} says so instead of "in force"`);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── 5. the operator's re-apply ───────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
test('re-apply puts the newest PASSED constitutional amendment back, skipping returned ones and laws', async () => {
|
|
224
|
+
const history = [
|
|
225
|
+
{ item_id: 30, amendment_id: 12, outcome: 'returned', proposed: MONARCHY, kind: 'constitution' },
|
|
226
|
+
{ item_id: 29, amendment_id: 11, outcome: 'passed', proposed: { enact: [] }, kind: 'laws' },
|
|
227
|
+
{ item_id: 21, amendment_id: 1, outcome: 'passed', proposed: TIMOCRACY, kind: 'constitution' },
|
|
228
|
+
];
|
|
229
|
+
const origList = db.listAmendmentHistory;
|
|
230
|
+
db.listAmendmentHistory = async () => history;
|
|
231
|
+
try {
|
|
232
|
+
await withInstance(async ({ store, live }) => {
|
|
233
|
+
const out = await constitution.reapplyNewestPassed({ updatedBy: '3' });
|
|
234
|
+
assert.equal(out.ok, true, JSON.stringify(out));
|
|
235
|
+
assert.equal(out.item_id, '21');
|
|
236
|
+
assert.deepEqual({ ...live() }, TIMOCRACY);
|
|
237
|
+
assert.equal(store.row.source, 'reapply');
|
|
238
|
+
assert.equal(store.saves[0].updatedBy, '3');
|
|
239
|
+
});
|
|
240
|
+
db.listAmendmentHistory = async () => history.slice(0, 2);
|
|
241
|
+
await withInstance(async ({ store }) => {
|
|
242
|
+
const out = await constitution.reapplyNewestPassed({});
|
|
243
|
+
assert.deepEqual(out, { ok: false, reason: 'no_passed_amendment' });
|
|
244
|
+
assert.equal(store.saves.length, 0, 'it never invents a constitution');
|
|
245
|
+
});
|
|
246
|
+
} finally { db.listAmendmentHistory = origList; }
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('the re-apply route is pinned to the owner atom and takes no block from the request', () => {
|
|
250
|
+
const { GOVERNMENT_PINS } = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
251
|
+
const pins = GOVERNMENT_PINS || {};
|
|
252
|
+
const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'), 'utf8');
|
|
253
|
+
assert.match(src, /'POST \/government\/constitution\/reapply': 'perm:government\.manage'/);
|
|
254
|
+
if (Object.keys(pins).length) assert.equal(pins['POST /government/constitution/reapply'], 'perm:government.manage');
|
|
255
|
+
const routeSrc = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
|
|
256
|
+
assert.match(routeSrc, /router\.post\('\/government\/constitution\/reapply', api\.requireBuilder, api\.requirePermission\('government\.manage'\), constitution\.reapplyRoute\(log\)\)/,
|
|
257
|
+
'the route exists, gated on the owner atom');
|
|
258
|
+
assert.doesNotMatch(constitution.reapplyRoute.toString(), /req\.body/, 'the block comes from a passed amendment, never the request');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('the re-apply handler answers 404 with nothing passed and 200 with the board it put back', async () => {
|
|
262
|
+
const call = async () => {
|
|
263
|
+
const res = { code: 200, body: null,
|
|
264
|
+
status(c) { this.code = c; return this; }, json(b) { this.body = b; return this; },
|
|
265
|
+
fail(code, status, extra) { this.code = typeof status === 'number' ? status : status.status; this.body = { code, ...extra }; return this; } };
|
|
266
|
+
await constitution.reapplyRoute({ error: () => {} })({ builder: { id: '3' }, body: { board: MONARCHY } }, res);
|
|
267
|
+
return res;
|
|
268
|
+
};
|
|
269
|
+
const origList = db.listAmendmentHistory;
|
|
270
|
+
try {
|
|
271
|
+
db.listAmendmentHistory = async () => [];
|
|
272
|
+
await withInstance(async () => {
|
|
273
|
+
const res = await call();
|
|
274
|
+
assert.equal(res.code, 404);
|
|
275
|
+
assert.equal(res.body.code, 'no_passed_amendment');
|
|
276
|
+
});
|
|
277
|
+
db.listAmendmentHistory = async () => [{ item_id: 21, amendment_id: 1, outcome: 'passed', proposed: TIMOCRACY, kind: 'constitution' }];
|
|
278
|
+
await withInstance(async ({ live }) => {
|
|
279
|
+
const res = await call();
|
|
280
|
+
assert.equal(res.code, 200);
|
|
281
|
+
assert.deepEqual({ ...res.body.board }, TIMOCRACY, 'the passed amendment, not the request body');
|
|
282
|
+
assert.deepEqual({ ...live() }, TIMOCRACY);
|
|
283
|
+
});
|
|
284
|
+
} finally { db.listAmendmentHistory = origList; }
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ── 6. at boot ───────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
test('the restore runs the moment the poller starts, not after a delay', () => {
|
|
290
|
+
const poller = require(path.join(GOV, 'pollers', 'constitution-restore.js'));
|
|
291
|
+
let calls = 0;
|
|
292
|
+
poller.start({ reconcileFn: async () => { calls += 1; return { action: 'in_step' }; } });
|
|
293
|
+
try {
|
|
294
|
+
return new Promise((resolve) => setImmediate(() => { assert.equal(calls, 1); resolve(); }));
|
|
295
|
+
} finally { poller.stop(); }
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('the module declares the poller and the migration creates the one-row table', () => {
|
|
299
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(GOV, 'module.json'), 'utf8'));
|
|
300
|
+
assert.ok(manifest.contributes.pollers.includes('constitution-restore'));
|
|
301
|
+
const sql = fs.readFileSync(path.join(GOV, 'migrations', 'government_037_constitution.sql'), 'utf8');
|
|
302
|
+
assert.match(sql, /CREATE TABLE IF NOT EXISTS government_constitution/);
|
|
303
|
+
assert.match(sql, /CHECK \(id = 1\)/, 'one row, by construction');
|
|
304
|
+
});
|
|
@@ -53,6 +53,7 @@ const NON_GRANT_MIGRATIONS = new Set([
|
|
|
53
53
|
'government_031_treasury.sql', // the treasury: a pot per goal and the loans a dry pot asks the reserve for (task 1004712)
|
|
54
54
|
'government_032_ask_flags.sql', // the reword list: matters a reader flagged as unclear (owner 2026-10-23)
|
|
55
55
|
'government_036_access_holders.sql', // who holds the keys outside Bongos + the access:<key> ask seat (task 1005000)
|
|
56
|
+
'government_037_constitution.sql', // the constitution in force as a durable row (task 1005140)
|
|
56
57
|
]);
|
|
57
58
|
|
|
58
59
|
// Permission keys RENAMED after the grant migrations that first granted them.
|
package/tests/hall_city.mjs
CHANGED
|
@@ -289,7 +289,7 @@ test('the city sits in the hall chrome, behind the shell\'s full-screen switch',
|
|
|
289
289
|
// task 1004685 (the living city): gov-living.js, the model under the drawing, and
|
|
290
290
|
// gov-feeds.js, its one reader per page (shared with the shell's tally), load before it
|
|
291
291
|
// task 1004702: the card answers its first ask in place, so the deck's card and sender (and the desk rows under them) load before the page
|
|
292
|
-
assert.deepEqual(scripts, ['dom-utils', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'hall-kit', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city-decide', 'city-switchboard', 'city'], 'the shell first, then the shared dialog (task 1005085), as on every hall page');
|
|
292
|
+
assert.deepEqual(scripts, ['dom-utils', 'shell-game', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'hall-kit', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city-decide', 'city-switchboard', 'city'], 'the shell first, then the shared dialog (task 1005085), as on every hall page');
|
|
293
293
|
// task 1004925: every script but dom-utils.js is deferred, so none holds up the
|
|
294
294
|
// parser; defer runs them in document order, which is why the order above still
|
|
295
295
|
// holds. dom-utils.js stays synchronous: the one-escaper guard
|
|
@@ -25,6 +25,8 @@ const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
|
25
25
|
const SHELL = fs.readFileSync(path.join(HALL, 'shell.js'), 'utf8');
|
|
26
26
|
// task 1004492: the way back lives in shell-trail.js, which every page loads first.
|
|
27
27
|
const TRAIL = fs.readFileSync(path.join(HALL, 'shell-trail.js'), 'utf8');
|
|
28
|
+
// task 1005142: game mode, the hall-wide half of the switch; every page loads it before the shell.
|
|
29
|
+
const GAME = fs.readFileSync(path.join(HALL, 'shell-game.js'), 'utf8');
|
|
28
30
|
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
29
31
|
|
|
30
32
|
// Boot the shell once — one page load. `store` is the browser's localStorage and
|
|
@@ -125,11 +127,16 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
|
|
|
125
127
|
};
|
|
126
128
|
sandbox.globalThis = sandbox;
|
|
127
129
|
vm.createContext(sandbox);
|
|
130
|
+
vm.runInContext(GAME, sandbox, { filename: 'shell-game.js' });
|
|
128
131
|
vm.runInContext(TRAIL, sandbox, { filename: 'shell-trail.js' });
|
|
129
132
|
vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
|
|
130
133
|
const root = documentObj.documentElement;
|
|
131
134
|
return {
|
|
132
135
|
store,
|
|
136
|
+
game: () => windowObj.OTBShellGame,
|
|
137
|
+
gameStamp: () => root.dataset.game === 'on',
|
|
138
|
+
// The "how to leave" line: showing = the shell created it and it is not hidden.
|
|
139
|
+
hintShowing: () => els.has('fullscreen-hint') && els.get('fullscreen-hint').hidden === false,
|
|
133
140
|
button: () => getElementById('fullscreen-toggle'),
|
|
134
141
|
on: () => root.dataset.fullscreen === 'on',
|
|
135
142
|
plain: () => root.dataset.fullscreenPlain === 'on',
|
|
@@ -477,3 +484,97 @@ test('source: full screen hides the sidebar and foot, and collapses the top bar
|
|
|
477
484
|
assert.match(css, /html\[data-fullscreen="on"\] \.topbar-side > :not\(\.fullscreen-toggle\)/);
|
|
478
485
|
assert.doesNotMatch(css, /html\[data-fullscreen="on"\] \.app-topbar \{ display: none/);
|
|
479
486
|
});
|
|
487
|
+
|
|
488
|
+
// GAME MODE (task 1005142, BONGOS-V3 R02). The spec's done-when: no page shows the
|
|
489
|
+
// sidebar in game mode. Game mode is hall-wide — one key, read by the SAME switch
|
|
490
|
+
// — so these drive the real shell-game.js + shell.js the way a reader moves
|
|
491
|
+
// through the hall: page after page over one browser store.
|
|
492
|
+
test('game mode: EVERY page opens without its bars — plain, laid-out, and one that remembers "off"', () => {
|
|
493
|
+
const store = { 'hall-game': 'on', 'hall-fullscreen:goals': 'off' };
|
|
494
|
+
for (const [page, optIn] of [['goals', false], ['work', false], ['thinking', true], ['city', true]]) {
|
|
495
|
+
const p = boot({ page, optIn, store });
|
|
496
|
+
assert.equal(p.on(), true, `${page} opens in full screen`);
|
|
497
|
+
assert.equal(p.gameStamp(), true, `${page}: html[data-game="on"]`);
|
|
498
|
+
assert.equal(p.plain(), optIn === false, `${page}: the plain mark still follows the page`);
|
|
499
|
+
}
|
|
500
|
+
assert.equal(boot({ page: 'goals', optIn: false, store: {} }).on(), false, 'and outside game mode nothing changed');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('game mode: every hall page carries the switch — none opts out, and each loads shell-game.js before the shell', () => {
|
|
504
|
+
const pages = fs.readdirSync(HALL).filter((f) => f.endsWith('.html'));
|
|
505
|
+
assert.ok(pages.length >= 30, `found ${pages.length} hall pages`);
|
|
506
|
+
for (const f of pages) {
|
|
507
|
+
const html = read(f);
|
|
508
|
+
assert.doesNotMatch(html, /data-fullscreen-switch="off"/, `${f} must not opt out: it would keep its sidebar in game mode`);
|
|
509
|
+
assert.match(html, /id="app-topbar"/, `${f}: the top bar is where the shell renders the switch`);
|
|
510
|
+
const tags = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js[^"]*"( defer)?><\/script>/g)].map((m) => [m[1], !!m[2]]);
|
|
511
|
+
const game = tags.findIndex((t) => t[0] === 'shell-game');
|
|
512
|
+
const shell = tags.findIndex((t) => t[0] === 'shell');
|
|
513
|
+
assert.ok(game >= 0 && game < shell, `${f} loads shell-game.js before shell.js`);
|
|
514
|
+
assert.equal(tags[game][1], tags[shell][1], `${f}: both load the same way, so the order holds`);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test('game mode: leaving brings the bars back for THIS page only, and never rewrites a page\'s own memory', () => {
|
|
519
|
+
const store = { 'hall-game': 'on', 'hall-fullscreen:thinking': 'on' };
|
|
520
|
+
const goals = boot({ page: 'goals', optIn: false, store });
|
|
521
|
+
goals.press('Escape');
|
|
522
|
+
assert.equal(goals.on(), false, 'Escape still leaves');
|
|
523
|
+
assert.equal(store['hall-fullscreen:goals'], undefined, 'nothing remembered for the page');
|
|
524
|
+
assert.equal(boot({ page: 'goals', optIn: false, store }).on(), true, 'the next load opens without bars again');
|
|
525
|
+
const sky = boot({ page: 'thinking', store });
|
|
526
|
+
sky.button().click();
|
|
527
|
+
assert.equal(sky.on(), false, 'the corner button leaves too');
|
|
528
|
+
assert.equal(store['hall-fullscreen:thinking'], 'on', 'the sky\'s own choice survives game mode');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test('game mode: the Escape hand-off is unchanged — a press the page used, or one in a field, does not leave', () => {
|
|
532
|
+
const p = boot({ page: 'goals', optIn: false, store: { 'hall-game': 'on' } });
|
|
533
|
+
p.press('Escape', { pageUses: true });
|
|
534
|
+
assert.equal(p.on(), true, 'a page listener that called preventDefault keeps the press');
|
|
535
|
+
p.press('Escape', { target: { tagName: 'TEXTAREA' } });
|
|
536
|
+
assert.equal(p.on(), true);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('game mode: set() switches the page live, and turning it off hands each page back its own memory', () => {
|
|
540
|
+
const store = { 'hall-fullscreen:thinking': 'on' };
|
|
541
|
+
const goals = boot({ page: 'goals', optIn: false, store });
|
|
542
|
+
goals.game().set(true);
|
|
543
|
+
assert.equal(store['hall-game'], 'on');
|
|
544
|
+
assert.equal(goals.on(), true, 'on at once, no reload');
|
|
545
|
+
assert.ok(goals.resizes() >= 1, 'a page that sizes to its box is told to re-measure');
|
|
546
|
+
goals.game().set(false);
|
|
547
|
+
assert.equal(goals.on(), false, 'goals remembered nothing, so its bars return');
|
|
548
|
+
assert.equal(goals.gameStamp(), false);
|
|
549
|
+
assert.equal(store['hall-fullscreen:goals'], undefined, 'and switching the mode wrote nothing for the page');
|
|
550
|
+
const sky = boot({ page: 'thinking', store: Object.assign(store, { 'hall-game': 'on' }) });
|
|
551
|
+
sky.game().set(false);
|
|
552
|
+
assert.equal(sky.on(), true, 'the sky had chosen full screen itself, and keeps it');
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test('game mode: ?game=on / ?game=off set it from the URL; anything else is ignored', () => {
|
|
556
|
+
const store = {};
|
|
557
|
+
assert.equal(boot({ page: 'work', optIn: false, store, search: '?game=on' }).on(), true);
|
|
558
|
+
assert.equal(store['hall-game'], 'on');
|
|
559
|
+
assert.equal(boot({ page: 'goals', optIn: false, store }).on(), true, 'and it holds on the next page');
|
|
560
|
+
assert.equal(boot({ page: 'work', optIn: false, store, search: '?game=off' }).on(), false);
|
|
561
|
+
boot({ page: 'work', optIn: false, store, search: '?game=%22%3E%3Cimg%3E' });
|
|
562
|
+
assert.equal(store['hall-game'], 'off', 'a value that is not on/off is not stored');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test('game mode: how to leave is said once per tab, not on every page; outside it the shell\'s rule stands', () => {
|
|
566
|
+
const store = { 'hall-game': 'on' };
|
|
567
|
+
const session = {};
|
|
568
|
+
assert.equal(boot({ page: 'goals', optIn: false, store, session }).hintShowing(), true, 'the first page says it');
|
|
569
|
+
assert.equal(boot({ page: 'work', optIn: false, store, session }).hintShowing(), false, 'the next does not repeat it');
|
|
570
|
+
const own = { 'hall-fullscreen:thinking': 'on' };
|
|
571
|
+
assert.equal(boot({ page: 'thinking', store: own, session: {} }).hintShowing(), true);
|
|
572
|
+
assert.equal(boot({ page: 'thinking', store: own, session: {} }).hintShowing(), true, 'a remembered page says it on every open, as before');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test('game mode: storage that throws leaves the hall out of game mode, with the switch working', () => {
|
|
576
|
+
const p = boot({ page: 'goals', optIn: false, storageThrows: true, search: '?game=on' });
|
|
577
|
+
assert.equal(p.on(), false);
|
|
578
|
+
p.button().click();
|
|
579
|
+
assert.equal(p.on(), true);
|
|
580
|
+
});
|
package/tests/hall_my_agents.mjs
CHANGED
|
@@ -443,7 +443,7 @@ test('PAGE: in the hall family, with no prototype key', () => {
|
|
|
443
443
|
assert.deepEqual(sheets, ['style.css', 'oversight.css', 'speciality-tools.css', 'my-agents.css'],
|
|
444
444
|
'rides the oversight archetype; the shared speciality sheet (task 1005052) before its own, so the page skin wins');
|
|
445
445
|
const scripts = [...html.matchAll(/src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
|
|
446
|
-
assert.deepEqual(scripts.slice(0,
|
|
446
|
+
assert.deepEqual(scripts.slice(0, 5), ['dom-utils', 'shell-game', 'shell-trail', 'craft-mode', 'shell']); // dom-utils first: the shell escapes through it (task 1004876)
|
|
447
447
|
for (const s of ['gov-living', 'gov-positions', 'my-agents', 'speciality-tools']) assert.ok(scripts.includes(s), `loads ${s}.js`);
|
|
448
448
|
assert.doesNotMatch(html, /ma-key|data-proto|Prototype data/, 'nothing on the page is prototype data, so there is no key');
|
|
449
449
|
assert.doesNotMatch(read('my-agents.js'), /governor-overlay/, 'and it reads no overlay');
|
package/tests/hall_street.mjs
CHANGED
|
@@ -490,7 +490,7 @@ test('the page holds the prototype\'s header, the street, the readings and the c
|
|
|
490
490
|
assert.match(html, /<a class="street-mark" id="streetHome" href="\/builders\/city"[^>]*>\{\{productName\}\}</);
|
|
491
491
|
for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
|
|
492
492
|
const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
|
|
493
|
-
assert.deepEqual(scripts.filter((s) => !/^street-(deck|office)$/.test(s)), ['dom-utils', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-depth', 'street-desk', 'street-amend', 'street'], 'the hall shell (task 1004544), the living model and its one reader (task 1004685: city-draw.js counts with it, so it loads first), the shared kit, then the card (task 1004528), the interiors (task 1004526), the living street\'s exterior (street-depth.js), the floor desk (task 1004558) and the drafted amendment (task 1004530); the deck rides beside them');
|
|
493
|
+
assert.deepEqual(scripts.filter((s) => !/^street-(deck|office)$/.test(s)), ['dom-utils', 'shell-game', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-depth', 'street-desk', 'street-amend', 'street'], 'the hall shell (task 1004544), the living model and its one reader (task 1004685: city-draw.js counts with it, so it loads first), the shared kit, then the card (task 1004528), the interiors (task 1004526), the living street\'s exterior (street-depth.js), the floor desk (task 1004558) and the drafted amendment (task 1004530); the deck rides beside them');
|
|
494
494
|
assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
|
|
495
495
|
});
|
|
496
496
|
|
|
@@ -102,7 +102,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
102
102
|
// task 1005051: Roadmap left it (folded into Goals; /roadmap redirects).
|
|
103
103
|
// task 1005070: the remembered nav (the rail paints before /me).
|
|
104
104
|
// task 1005088: the sidebar mark is drawn by reference to the one brand mark.
|
|
105
|
-
const SHELL_SHA = '
|
|
105
|
+
const SHELL_SHA = 'e9c13c142bcd2a95';
|
|
106
106
|
|
|
107
107
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
108
108
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|