@bongos/core 1.21.5 → 1.21.7
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 +152 -72
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
- package/docs/api/openapi.json +2 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +156 -144
- package/docs/copy-registry.json +286 -158
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +35 -6
- package/docs/page-readings.json +1056 -1013
- package/modules/autonomy/config-idle.js +192 -0
- package/modules/autonomy/routes/autonomy.js +22 -0
- package/modules/autonomy/runner-health.js +22 -1
- package/modules/hall-ui/public/city-draw.js +438 -0
- package/modules/hall-ui/public/city.css +110 -0
- package/modules/hall-ui/public/city.html +71 -0
- package/modules/hall-ui/public/city.js +177 -0
- package/modules/hall-ui/public/city.states.json +30 -0
- package/modules/hall-ui/public/gate.js +11 -4
- package/modules/hall-ui/public/profile.css +4 -0
- package/modules/hall-ui/public/profile.js +25 -1
- package/modules/hall-ui/public/settings-autobongos.js +19 -1
- package/modules/hall-ui/public/settings.css +26 -0
- package/modules/hall-ui/public/settings.html +8 -0
- package/modules/hall-ui/public/settings.js +43 -3
- package/modules/platform-identity/craft-rollup.js +72 -0
- package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
- package/modules/platform-identity/platform-identity.js +12 -6
- package/modules/platform-identity/routes/sso.js +4 -0
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/modules/provisioning/core-upgrade.js +34 -7
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +2 -0
- package/modules/public-landing/public/projects-hall-live.states.json +216 -0
- package/modules/public-landing/public/projects.html +43 -13
- package/modules/public-landing/public/projects.states.json +2 -1
- package/modules/ui-design/kit/fixtures/provisioning-instances-hall-live.json +106 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +64 -0
- package/scripts/gds/autobongos-grade-cap.js +187 -0
- package/scripts/gds/autobongos-loop.js +4 -0
- package/scripts/gds/autobongos-run.js +154 -13
- package/scripts/gds/autobongos-verify.js +211 -14
- package/scripts/gds/provision-core-upgrade.js +14 -2
- package/scripts/gds/provision-repo.js +77 -13
- package/scripts/gds/ship-flow.js +5 -0
- package/scripts/gds/ship-preflight-steps.js +2 -1
- package/scripts/gds/ship.js +10 -0
- package/scripts/gds/update-sweep.js +24 -8
- package/scripts/gds/upgrade-outcome.js +1 -0
- package/src/bongos/auth-admission.js +95 -13
- package/src/bongos/db-kernel.js +1 -1
- package/src/bongos/routes/core-update.js +27 -2
- package/src/bongos/serve-internal.js +14 -0
- package/src/bongos/software-update.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_order_db.mjs +3 -1
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/autobongos_grade_cap.mjs +125 -0
- package/tests/autobongos_loop.mjs +230 -1
- package/tests/autobongos_verify.mjs +239 -3
- package/tests/autonomy_config_idle.mjs +272 -0
- package/tests/core_update_banner.mjs +29 -0
- package/tests/core_upgrade_door.mjs +31 -1
- package/tests/core_upgrade_runner.mjs +20 -0
- package/tests/hall_audit.mjs +10 -1
- package/tests/hall_city.mjs +369 -0
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hub_craft_rollup.mjs +350 -0
- package/tests/profile_rollup_consent.mjs +2 -0
- package/tests/profile_route.mjs +16 -2
- package/tests/project_invite_ui.mjs +65 -7
- package/tests/projects_hub_dns_ready.mjs +7 -1
- package/tests/provision.mjs +78 -0
- package/tests/settings_main_role.mjs +190 -0
- package/tests/software_update.mjs +56 -1
- package/tests/ui_design_kit.mjs +1 -1
- package/tests/update_subscription_engine.mjs +55 -3
- package/tests/wizard_physics_more.mjs +19 -0
package/tests/hall_audit.mjs
CHANGED
|
@@ -369,7 +369,16 @@ test('the module doc still records where the 2179 density pass landed', () => {
|
|
|
369
369
|
// /thinking left this list in task 1004327 (owner decision 2026-09-27): it sits
|
|
370
370
|
// in the hall chrome now, with the shell's full-screen switch to hide it. The
|
|
371
371
|
// project sky (/goal-map) followed in task 1004430, so the list is empty again.
|
|
372
|
-
|
|
372
|
+
// THE GOVERNOR CITY's home (task 1004524, goal 1000125) is on it now, for the
|
|
373
|
+
// reason the sky first was: the task and its reference design
|
|
374
|
+
// (docs/design/mocks/governor-city/Street.dc.html, the home view) say nothing
|
|
375
|
+
// else is on the page but the wordmark, a sun/moon switch, start governing and
|
|
376
|
+
// one line. It still loads style.css (the hall's tokens, so no private palette:
|
|
377
|
+
// city.css spends var(--bg), var(--ink), var(--accent) …), and reads and writes
|
|
378
|
+
// the hall's own 'hall-theme' preference. Whether it should join the chrome
|
|
379
|
+
// behind the full-screen switch, as the sky did in task 1004327, is the
|
|
380
|
+
// owner's call, named in that task's ship notes.
|
|
381
|
+
const STANDALONE_EXEMPT = { 'city.html': 'the governor city home: nothing on the page but the prototype home (task 1004524)' };
|
|
373
382
|
|
|
374
383
|
test('every hall page loads style.css and shell.js', () => {
|
|
375
384
|
const missing = [];
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// tests/hall_city.mjs — THE GOVERNOR CITY's home (task 1004524, goal 1000125).
|
|
2
|
+
//
|
|
3
|
+
// WHAT IT GUARDS. The page is the home view of the reference design
|
|
4
|
+
// (docs/design/mocks/governor-city/Street.dc.html) built over the governor
|
|
5
|
+
// docket (task 1004523) and the open goals' task rollups. What can go wrong:
|
|
6
|
+
// * the drawing drifts from the prototype (a civic recipe, the ground, a pin
|
|
7
|
+
// position) — pinned by running the PROTOTYPE'S OWN Component class beside
|
|
8
|
+
// city-draw.js and comparing the paths they emit, in both modes;
|
|
9
|
+
// * a pin drawn in the wrong state or over the wrong building, or invented
|
|
10
|
+
// where the docket has no matter;
|
|
11
|
+
// * a district for a goal that is not open, two goals on one plot, a plot on
|
|
12
|
+
// the ring road, or a task with no building;
|
|
13
|
+
// * the line under the button saying anything but the docket's own line;
|
|
14
|
+
// * the start governing button going anywhere but the street;
|
|
15
|
+
// * the switch not using the hall's own preference, so the city opens in a
|
|
16
|
+
// different mode from every other hall page;
|
|
17
|
+
// * motion that escapes the Kill Switch, or docket text reaching innerHTML.
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/hall_city.mjs
|
|
20
|
+
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import vm from 'node:vm';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const City = require(path.join(PUB, 'city-draw.js'));
|
|
33
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
34
|
+
|
|
35
|
+
// The prototype's Component class, run under a stub DCLogic: the reference
|
|
36
|
+
// the port is held equal to.
|
|
37
|
+
function prototype() {
|
|
38
|
+
const src = fs.readFileSync(path.join(ROOT, 'docs/design/mocks/governor-city/Street.dc.html'), 'utf8');
|
|
39
|
+
const script = src.split('data-dc-script')[1].split('>').slice(1).join('>').split('</script>')[0];
|
|
40
|
+
const DCLogic = class { constructor(p) { this.props = p || {}; this.state = {}; } setState() {} };
|
|
41
|
+
return new Function('DCLogic', `${script}\nreturn Component;`)(DCLogic);
|
|
42
|
+
}
|
|
43
|
+
const Proto = prototype();
|
|
44
|
+
|
|
45
|
+
// A docket in the route's own shape (modules/government/docket.js buildDocket).
|
|
46
|
+
function matter(over = {}) {
|
|
47
|
+
return { id: 'blockers:1', source: 'blockers', building: 'planning', rule: 'majority', state: 'needs', title: 'A matter', short: 'a matter', where: 'Planning', opened_at: null, deadline: null, proposer: null, tally: null, href: null, ref: null, ...over };
|
|
48
|
+
}
|
|
49
|
+
function docket(matters = [], line = null) {
|
|
50
|
+
const counts = { needs: 0, moving: 0, settled: 0 };
|
|
51
|
+
matters.forEach((m) => { counts[m.state] += 1; });
|
|
52
|
+
return { viewer: { id: '1' }, generated_at: '2026-10-02T00:00:00.000Z', counts, line: line || `${counts.needs} matters wait on you`, matters, buildings: [], sources: [], readings: [], vocabulary: {} };
|
|
53
|
+
}
|
|
54
|
+
const goal = (id, status = 'open') => ({ id: String(id), title: `Goal ${id}`, status });
|
|
55
|
+
const rollup = (id, c) => ({ goal_id: String(id), done: 0, active: 0, ready: 0, blocked: 0, backlog: 0, cut: 0, ...c });
|
|
56
|
+
|
|
57
|
+
// ── the drawing is the prototype's ───────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
test('the nine civic recipes draw exactly the prototype paths, in both modes', () => {
|
|
60
|
+
for (const mode of ['dark', 'light']) {
|
|
61
|
+
const p = new Proto({ mode });
|
|
62
|
+
const T = p.theme(mode);
|
|
63
|
+
const pk = p.isoKit(60, 30, 720, 176, 30 / 28, T.iso, false);
|
|
64
|
+
const ck = City.isoKit(60, 30, 720, 176, 30 / 28, City.isoTheme(mode), false);
|
|
65
|
+
for (const [kind, x, y] of City.CIVIC_SLOTS) {
|
|
66
|
+
const a = []; const b = [];
|
|
67
|
+
p.isoInstitution(a, pk, kind, x, y, { fixes: false });
|
|
68
|
+
City.isoInstitution(b, ck, kind, x, y, { fixes: false });
|
|
69
|
+
assert.deepEqual(b, a, `${mode} ${kind}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the iso sub-palette is the prototype theme().iso, both modes', () => {
|
|
75
|
+
for (const mode of ['dark', 'light']) assert.deepEqual(City.isoTheme(mode), new Proto({ mode }).theme(mode).iso, mode);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('every work kind the prototype draws is drawn the same way', () => {
|
|
79
|
+
const p = new Proto({ mode: 'dark' });
|
|
80
|
+
const pk = p.isoKit(60, 30, 720, 176, 30 / 28, p.theme('dark').iso, true);
|
|
81
|
+
const ck = City.isoKit(60, 30, 720, 176, 30 / 28, City.isoTheme('dark'), true);
|
|
82
|
+
for (const kind of ['tower', 'setback', 'shed', 'trees', 'scaffold']) {
|
|
83
|
+
const a = []; const b = [];
|
|
84
|
+
p.isoWork(a, pk, 6, 2, kind, 58);
|
|
85
|
+
City.isoWork(b, ck, 6, 2, kind, 58);
|
|
86
|
+
assert.deepEqual(b, a, kind);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('the ground, ring road, lanes and plaza are the prototype home view, path for path', () => {
|
|
91
|
+
const p = new Proto({ mode: 'light' });
|
|
92
|
+
p.state = p.initial();
|
|
93
|
+
p.state.reviewHeld = true;
|
|
94
|
+
const T = p.theme('light');
|
|
95
|
+
const home = p.homeView(T, p.matters(), false);
|
|
96
|
+
const model = City.cityModel({ docket: docket([matter()]), goals: [], rollups: [], mode: 'light' });
|
|
97
|
+
// The prototype's flat list is ground, 4 roads, lanes, plaza, then its 4 plots and their grid.
|
|
98
|
+
assert.deepEqual(model.flat.slice(0, 7), home.flat.slice(0, 7));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('with four goals the districts sit on the prototype\'s four plots, in its order', () => {
|
|
102
|
+
const lots = City.lotsFor(4).map((l) => [l.x0, l.x1, l.y0, l.y1, l.tall]);
|
|
103
|
+
assert.deepEqual(lots, [[5, 10, 0, 3, 60], [12, 15, 5, 10, 100], [0, 3, 5, 10, 120], [5, 10, 12, 15, 50]]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('the decision glyphs match the prototype (and the guide for first and signoff)', () => {
|
|
107
|
+
const p = new Proto({ mode: 'dark' });
|
|
108
|
+
for (const k of ['decide', 'majority', 'unanimous', 'consent', 'rule']) assert.deepEqual(City.glyph(k, 7, 7, 13), p.glyph(k, 7, 7, 13), k);
|
|
109
|
+
assert.ok(City.glyph('first', 7, 7, 13).fill && City.glyph('signoff', 7, 7, 13).line);
|
|
110
|
+
assert.deepEqual(City.glyph(null, 7, 7, 13), { fill: '', line: '' }, 'an unknown rule draws no mark, never the nearest one');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── pins come from the docket, and only from it ─────────────────────────────
|
|
114
|
+
|
|
115
|
+
test('a pin per open matter, in its state, over its building', () => {
|
|
116
|
+
const ms = [
|
|
117
|
+
matter({ id: 'board:1', building: 'charter', state: 'needs', rule: 'unanimous' }),
|
|
118
|
+
matter({ id: 'blockers:2', building: 'planning', state: 'moving' }),
|
|
119
|
+
matter({ id: 'removal_proposals:3', building: 'disputes', state: 'settled', rule: 'decide' }),
|
|
120
|
+
];
|
|
121
|
+
const m = City.cityModel({ docket: docket(ms), goals: [], rollups: [], mode: 'dark' });
|
|
122
|
+
assert.deepEqual(m.pins.map((p) => [p.key, p.building, p.state]), [
|
|
123
|
+
['blockers:2', 'planning', 'moving'], ['board:1', 'charter', 'needs'], ['removal_proposals:3', 'disputes', 'settled'],
|
|
124
|
+
]);
|
|
125
|
+
// the prototype's own pin position for a matter at the Charter: P(8, 8, 158 + 8)
|
|
126
|
+
const charter = m.pins.find((p) => p.building === 'charter');
|
|
127
|
+
const k = City.isoKit(60, 30, 720, 176, 30 / 28, City.isoTheme('dark'), false);
|
|
128
|
+
const top = k.P(8, 8, 166);
|
|
129
|
+
assert.equal(charter.x, Math.round((top[0] - 15) * 10) / 10);
|
|
130
|
+
assert.equal(charter.y, Math.round((top[1] - 52) * 10) / 10);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('no docket, no pins: a failed read draws none and says so', () => {
|
|
134
|
+
const m = City.cityModel({ docket: null, goals: [goal(1)], rollups: [rollup(1, { done: 2 })], mode: 'dark' });
|
|
135
|
+
assert.equal(m.pins.length, 0);
|
|
136
|
+
assert.equal(m.line, City.FALLBACK_LINE);
|
|
137
|
+
assert.equal(m.districts.length, 1, 'the goals still stand when only the docket failed');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('the line is the docket\'s own line, word for word', () => {
|
|
141
|
+
const m = City.cityModel({ docket: docket([matter()], '1 matter waits on you'), mode: 'dark' });
|
|
142
|
+
assert.equal(m.line, '1 matter waits on you');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('at most three pins stand over one building, needs first as the docket ordered them', () => {
|
|
146
|
+
const ms = ['needs', 'needs', 'moving', 'moving', 'settled'].map((state, i) => matter({ id: `blockers:${i}`, state }));
|
|
147
|
+
const m = City.cityModel({ docket: docket(ms), mode: 'dark' });
|
|
148
|
+
assert.equal(m.pins.length, City.MAX_PINS_PER_BUILDING);
|
|
149
|
+
assert.deepEqual(m.pins.map((p) => p.state), ['needs', 'needs', 'moving']);
|
|
150
|
+
assert.equal(new Set(m.pins.map((p) => p.x)).size, 3, 'abreast, not stacked on one spot');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a matter at a building the city does not have is not drawn, and an unknown state is never "needs"', () => {
|
|
154
|
+
const m = City.cityModel({ docket: docket([matter({ building: 'nowhere' }), matter({ id: 'x:1', state: 'weird' })]), mode: 'dark' });
|
|
155
|
+
assert.equal(m.pins.length, 1);
|
|
156
|
+
assert.equal(m.pins[0].state, 'moving');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('the windows light (evening) only when nothing waits on you', () => {
|
|
160
|
+
assert.equal(City.cityModel({ docket: docket([matter({ state: 'moving' })]), mode: 'dark' }).evening, true);
|
|
161
|
+
assert.equal(City.cityModel({ docket: docket([matter({ state: 'needs' })]), mode: 'dark' }).evening, false);
|
|
162
|
+
assert.equal(City.cityModel({ docket: null, mode: 'dark' }).evening, false, 'an unread docket is not an empty one');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── a district per open goal, a building per task ───────────────────────────
|
|
166
|
+
|
|
167
|
+
test('one district per OPEN goal; achieved and archived goals build nothing', () => {
|
|
168
|
+
const m = City.cityModel({ docket: docket(), goals: [goal(1), goal(2, 'achieved'), goal(3), goal(4, 'archived')], rollups: [], mode: 'dark' });
|
|
169
|
+
assert.deepEqual(m.districts.map((d) => d.goal_id), ['1', '3']);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('plots never overlap, never touch the roads or the plaza, and stay on the 16 x 16 ground', () => {
|
|
173
|
+
for (const n of [1, 4, 5, 8, 9, 12, 15, 16, 30]) {
|
|
174
|
+
const lots = City.lotsFor(n);
|
|
175
|
+
assert.equal(lots.length, Math.min(n, City.MAX_DISTRICTS), `n=${n}`);
|
|
176
|
+
const seen = new Set();
|
|
177
|
+
lots.forEach((l) => {
|
|
178
|
+
for (let x = l.x0; x <= l.x1; x++) for (let y = l.y0; y <= l.y1; y++) {
|
|
179
|
+
const key = `${x},${y}`;
|
|
180
|
+
assert.ok(!seen.has(key), `n=${n}: tile ${key} on two plots`);
|
|
181
|
+
seen.add(key);
|
|
182
|
+
assert.ok(x >= 0 && x <= 15 && y >= 0 && y <= 15, `n=${n}: ${key} off the ground`);
|
|
183
|
+
assert.ok(x !== 4 && x !== 11 && y !== 4 && y !== 11, `n=${n}: ${key} on the ring road`);
|
|
184
|
+
assert.ok(!(x >= 5 && x <= 10 && y >= 5 && y <= 10), `n=${n}: ${key} in the plaza`);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('goals past sixteen are reported, not silently dropped', () => {
|
|
191
|
+
const goals = Array.from({ length: 19 }, (_, i) => goal(i + 1));
|
|
192
|
+
const m = City.cityModel({ docket: docket(), goals, rollups: [], mode: 'dark' });
|
|
193
|
+
assert.equal(m.districts.length, 16);
|
|
194
|
+
assert.deepEqual(m.unplaced, ['17', '18', '19']);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('a building per task: shipped built, being built a scaffold, not started a slab', () => {
|
|
198
|
+
const lot = City.lotsFor(1)[0];
|
|
199
|
+
const work = City.districtWork(lot, rollup(1, { done: 3, active: 1, ready: 1, blocked: 1, cut: 9 })).filter((w) => w.kind !== 'trees');
|
|
200
|
+
assert.equal(work.length, 6, 'cut work builds nothing');
|
|
201
|
+
assert.equal(work.filter((w) => ['tower', 'setback', 'shed'].includes(w.kind)).length, 3);
|
|
202
|
+
assert.equal(work.filter((w) => w.kind === 'scaffold').length, 1);
|
|
203
|
+
assert.equal(work.filter((w) => w.kind === 'slab').length, 2);
|
|
204
|
+
assert.ok(work.every((w) => w.tasks === 1));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('past a full plot, every task still stands in a building, and they rise', () => {
|
|
208
|
+
const lot = City.lotsFor(1)[0];
|
|
209
|
+
const tiles = (lot.x1 - lot.x0 + 1) * (lot.y1 - lot.y0 + 1);
|
|
210
|
+
const r = rollup(1, { done: 200, active: 3, ready: 20 });
|
|
211
|
+
const work = City.districtWork(lot, r).filter((w) => w.kind !== 'trees');
|
|
212
|
+
assert.ok(work.length <= tiles);
|
|
213
|
+
assert.equal(work.reduce((s, w) => s + w.tasks, 0), 223);
|
|
214
|
+
const one = City.districtWork(lot, rollup(1, { done: 1 }))[0];
|
|
215
|
+
const many = work.find((w) => w.gx === one.gx && w.gy === one.gy);
|
|
216
|
+
assert.ok(many.h > one.h, 'the same tile stands taller when it carries more work');
|
|
217
|
+
assert.ok(many.h <= Math.round((20 + lot.tall) * 1.25), 'but the rise is capped');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('the drawing is deterministic: the same feeds give the same city', () => {
|
|
221
|
+
const input = { docket: docket([matter()]), goals: [goal(1), goal(2)], rollups: [rollup(1, { done: 5 }), rollup(2, { active: 2 })], mode: 'light' };
|
|
222
|
+
assert.deepEqual(City.cityModel(input), City.cityModel(input));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('parcels ride only the roads of plots that hold a district, and leave through Release', () => {
|
|
226
|
+
const none = City.cityModel({ docket: docket(), goals: [], rollups: [], mode: 'dark' });
|
|
227
|
+
assert.equal(none.parcels.length, 0);
|
|
228
|
+
const two = City.cityModel({ docket: docket(), goals: [goal(1), goal(2)], rollups: [], mode: 'dark' });
|
|
229
|
+
assert.deepEqual(two.parcels.map((p) => p.key), ['p00', 'p01', 'p10', 'p11', 'out0', 'out1']);
|
|
230
|
+
assert.ok(two.parcels.filter((p) => p.out).length === 2);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ── the page ────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
test('the page holds only the wordmark, the switch, the city, one button and one line', () => {
|
|
236
|
+
const html = read('city.html').replace(/<!--[\s\S]*?-->/g, ''); // the markup, not the prose about it
|
|
237
|
+
assert.equal((html.match(/<header\b/g) || []).length, 1);
|
|
238
|
+
assert.equal((html.match(/<main\b/g) || []).length, 1);
|
|
239
|
+
assert.equal((html.match(/<h1\b/g) || []).length, 1);
|
|
240
|
+
assert.match(html, /<a class="city-start" id="cityStart" href="\/builders\/street"/);
|
|
241
|
+
assert.equal((html.match(/<a\b/g) || []).length, 1, 'one door');
|
|
242
|
+
assert.equal((html.match(/<button\b/g) || []).length, 1, 'one switch');
|
|
243
|
+
assert.match(html, /class="city-mark">\{\{productName\}\}</);
|
|
244
|
+
assert.doesNotMatch(html, /app-sidebar|shell\.js/, 'no hall chrome on the city');
|
|
245
|
+
assert.equal(City.STREET_HREF, '/builders/street');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('motion is CSS only and dies under reduced motion; no page :root; no innerHTML', () => {
|
|
249
|
+
const css = read('city.css').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
250
|
+
assert.doesNotMatch(css, /:root/);
|
|
251
|
+
assert.match(css, /@media \(prefers-reduced-motion: reduce\) \{[^}]*animation: none !important; transition: none !important;/);
|
|
252
|
+
for (const k of ['city-ring', 'city-zoom', 'city-travel', 'city-bob', 'city-drift']) assert.match(css, new RegExp(`@keyframes ${k} `));
|
|
253
|
+
assert.match(css, /offset-rotate/);
|
|
254
|
+
const js = (read('city.js') + read('city-draw.js')).replace(/^\s*\/\/.*$/gm, ''); // code, not its comments
|
|
255
|
+
assert.doesNotMatch(js, /innerHTML|insertAdjacentHTML|requestAnimationFrame|setInterval/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('the two dark blocks are byte-identical twins', () => {
|
|
259
|
+
const css = read('city.css');
|
|
260
|
+
const a = css.match(/html\[data-theme="dark"\] body\[data-page="city"\] \{([^}]*)\}/)[1];
|
|
261
|
+
const b = css.match(/html:not\(\[data-theme\]\) body\[data-page="city"\] \{([^}]*)\}/)[1];
|
|
262
|
+
assert.equal(a.replace(/\s+/g, ' ').trim(), b.replace(/\s+/g, ' ').trim());
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// A small DOM, enough for city.js: ids, elements, SVG elements, attributes.
|
|
266
|
+
function fakeDom() {
|
|
267
|
+
const byId = new Map();
|
|
268
|
+
const mk = (tag) => {
|
|
269
|
+
const el = {
|
|
270
|
+
tagName: tag, attrs: {}, children: [], style: { setProperty(k, v) { this[k] = v; } }, className: '', listeners: {}, _text: '',
|
|
271
|
+
get firstChild() { return this.children[0] || null; },
|
|
272
|
+
setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'id') byId.set(String(v), this); },
|
|
273
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
|
|
274
|
+
appendChild(c) { this.children.push(c); c.parentNode = this; return c; },
|
|
275
|
+
removeChild(c) { this.children = this.children.filter((x) => x !== c); return c; },
|
|
276
|
+
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
|
|
277
|
+
fire(t) { (this.listeners[t] || []).forEach((fn) => fn({ type: t })); },
|
|
278
|
+
set textContent(v) { this._text = String(v); this.children = []; },
|
|
279
|
+
get textContent() { return this._text; },
|
|
280
|
+
get innerHTML() { throw new Error('city.js must not read innerHTML'); },
|
|
281
|
+
set innerHTML(v) { throw new Error('city.js must not write innerHTML'); },
|
|
282
|
+
};
|
|
283
|
+
return el;
|
|
284
|
+
};
|
|
285
|
+
const html = mk('html');
|
|
286
|
+
for (const id of ['cityMode', 'cityModeIcon', 'cityStage', 'cityFlat', 'cityBlds', 'cityParcels', 'cityPins', 'cityLine', 'cityStart']) mk('x').setAttribute('id', id);
|
|
287
|
+
const document = {
|
|
288
|
+
documentElement: html,
|
|
289
|
+
getElementById: (id) => byId.get(id) || null,
|
|
290
|
+
createElement: (t) => mk(t),
|
|
291
|
+
createElementNS: (_ns, t) => mk(t),
|
|
292
|
+
};
|
|
293
|
+
return { document, byId, html };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function bootPage({ feeds = {}, stored = null, osDark = true } = {}) {
|
|
297
|
+
const { document, byId, html } = fakeDom();
|
|
298
|
+
const store = new Map(stored ? [['hall-theme', stored]] : []);
|
|
299
|
+
const calls = [];
|
|
300
|
+
const window = {
|
|
301
|
+
innerWidth: 1440, innerHeight: 900,
|
|
302
|
+
localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
|
|
303
|
+
matchMedia: () => ({ matches: osDark, addEventListener() {} }),
|
|
304
|
+
addEventListener() {},
|
|
305
|
+
};
|
|
306
|
+
const fetch = (url) => {
|
|
307
|
+
calls.push(url);
|
|
308
|
+
const body = feeds[url];
|
|
309
|
+
return Promise.resolve(body === undefined ? { ok: false, json: async () => ({}) } : { ok: true, json: async () => body });
|
|
310
|
+
};
|
|
311
|
+
const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, console });
|
|
312
|
+
ctx.globalThis = ctx;
|
|
313
|
+
window.GovCity = undefined;
|
|
314
|
+
vm.runInContext(read('city-draw.js'), ctx);
|
|
315
|
+
window.GovCity = ctx.GovCity || ctx.window.GovCity;
|
|
316
|
+
vm.runInContext(read('city.js'), ctx);
|
|
317
|
+
await window.GovCityPage.ready;
|
|
318
|
+
return { window, byId, html, store, calls };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const FEEDS = (ms, goals = [goal(1)], rollups = [rollup(1, { done: 2, active: 1 })]) => ({
|
|
322
|
+
'/api/bongos/government/docket': docket(ms, '2 matters wait on you · 1 moving'),
|
|
323
|
+
'/api/bongos/goals': { goals },
|
|
324
|
+
'/api/bongos/goals/rollups': { rollups },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('the page reads the three feeds and draws the docket\'s pins and line', async () => {
|
|
328
|
+
const ms = [matter({ id: 'board:1', building: 'charter', state: 'needs' }), matter({ id: 'blockers:2', state: 'needs' }), matter({ id: 'gate_approvals:3', building: 'release', state: 'moving' })];
|
|
329
|
+
const p = await bootPage({ feeds: FEEDS(ms) });
|
|
330
|
+
assert.deepEqual([...p.calls].sort(), ['/api/bongos/goals', '/api/bongos/goals/rollups', '/api/bongos/government/docket']);
|
|
331
|
+
const pins = p.byId.get('cityPins').children;
|
|
332
|
+
assert.equal(pins.length, 3);
|
|
333
|
+
assert.deepEqual(pins.map((s) => s.className).sort(), ['city-pin city-pin--moving', 'city-pin city-pin--needs', 'city-pin city-pin--needs']);
|
|
334
|
+
const needs = pins.find((s) => s.attrs['data-matter'] === 'board:1');
|
|
335
|
+
assert.equal(needs.children[0].children[0].className, 'city-pin__ring', 'a needs pin pulses');
|
|
336
|
+
const moving = pins.find((s) => s.className.includes('moving'));
|
|
337
|
+
assert.ok(!moving.children[0].children.some((c) => c.className === 'city-pin__ring'), 'a moving pin only bobs');
|
|
338
|
+
assert.equal(p.byId.get('cityLine').textContent, '2 matters wait on you · 1 moving');
|
|
339
|
+
assert.equal(p.byId.get('cityStart').attrs.href, '/builders/street');
|
|
340
|
+
assert.ok(p.byId.get('cityBlds').children.length > 9, 'the civic quarter and the district are drawn');
|
|
341
|
+
assert.ok(p.byId.get('cityParcels').children.some((s) => /offset/.test(Object.keys(s.style).join(' '))), 'parcels carry an offset-path');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('a failed docket read draws no pins and says the docket could not be read', async () => {
|
|
345
|
+
const feeds = FEEDS([]);
|
|
346
|
+
delete feeds['/api/bongos/government/docket'];
|
|
347
|
+
const p = await bootPage({ feeds });
|
|
348
|
+
assert.equal(p.byId.get('cityPins').children.length, 0);
|
|
349
|
+
assert.equal(p.byId.get('cityLine').textContent, City.FALLBACK_LINE);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('the switch reads and writes the hall\'s own theme preference', async () => {
|
|
353
|
+
const p = await bootPage({ feeds: FEEDS([]), stored: 'light', osDark: true });
|
|
354
|
+
assert.equal(p.html.attrs['data-theme'], 'light', 'the stored hall choice beats the OS');
|
|
355
|
+
assert.equal(p.byId.get('cityMode').attrs['aria-label'], 'Switch to dark mode');
|
|
356
|
+
const before = JSON.stringify(p.byId.get('cityFlat').children[0].attrs);
|
|
357
|
+
p.byId.get('cityMode').fire('click');
|
|
358
|
+
assert.equal(p.store.get('hall-theme'), 'dark');
|
|
359
|
+
assert.equal(p.html.attrs['data-theme'], 'dark');
|
|
360
|
+
assert.equal(p.byId.get('cityMode').attrs['aria-label'], 'Switch to light mode');
|
|
361
|
+
assert.notEqual(JSON.stringify(p.byId.get('cityFlat').children[0].attrs), before, 'the city redraws in the new mode');
|
|
362
|
+
const q = await bootPage({ feeds: FEEDS([]), stored: null, osDark: true });
|
|
363
|
+
assert.equal(q.html.attrs['data-theme'], 'dark', 'no stored choice follows the OS');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('docket text lands as text: a title with markup is never parsed', async () => {
|
|
367
|
+
const p = await bootPage({ feeds: FEEDS([matter({ title: '<img src=x onerror=alert(1)>', short: '<b>x</b>' })]) });
|
|
368
|
+
assert.equal(p.byId.get('cityPins').children.length, 1);
|
|
369
|
+
});
|
|
@@ -140,6 +140,11 @@ const ORACLE = {
|
|
|
140
140
|
// no ballot (ADR 0175 §9 made the ballot open on purpose).
|
|
141
141
|
'/builders/board-room': 'requireBoardVotePage',
|
|
142
142
|
|
|
143
|
+
// task 1004524: the Governor City's home, on the same vote atom. Its one
|
|
144
|
+
// governed read, GET /government/docket, checks board.vote.cast, so the page's
|
|
145
|
+
// reach equals the right to read the docket it draws.
|
|
146
|
+
'/builders/city': 'requireBoardVotePage',
|
|
147
|
+
|
|
143
148
|
// Deliberately UNGATED — the landing card, the rank ladder, the reward
|
|
144
149
|
// schedule and a builder's public portfolio all render for a stranger.
|
|
145
150
|
//
|