@bongos/core 1.21.7 → 1.21.9
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 +98 -38
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0363-who-decides-what-is-a-table-changed-only-by-amendment.md +52 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +182 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +20 -5
- package/docs/copy-registry.json +163 -16
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +33 -4
- package/docs/page-readings.json +44 -2
- package/modules/discord/board-broadcast.js +2 -0
- package/modules/government/board.js +82 -23
- package/modules/government/db.js +81 -6
- package/modules/government/decision-board.js +300 -0
- package/modules/government/decision-rules.js +312 -0
- package/modules/government/docket.js +49 -9
- package/modules/government/migrations/government_023_decision_rules.sql +190 -0
- package/modules/government/routes/government.js +115 -0
- package/modules/hall-ui/public/city-draw.js +11 -2
- package/modules/hall-ui/public/street-draw.js +585 -0
- package/modules/hall-ui/public/street.css +188 -0
- package/modules/hall-ui/public/street.html +118 -0
- package/modules/hall-ui/public/street.js +364 -0
- package/modules/hall-ui/public/street.states.json +43 -0
- package/modules/hall-ui/records/governor-street.md +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/bongos/route-rank-check.js +6 -0
- package/src/bongos/serve-internal.js +12 -0
- package/src/module-api.js +1 -1
- package/tests/government_abuse_matrix.mjs +3 -0
- package/tests/government_decision_rules.mjs +701 -0
- package/tests/government_routes.mjs +4 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/hall_audit.mjs +9 -1
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_street.mjs +521 -0
|
@@ -143,6 +143,10 @@ const stub = {
|
|
|
143
143
|
resetRankGrants: async ({ permissionKeys }) => permissionKeys.length,
|
|
144
144
|
assignRank: async () => true,
|
|
145
145
|
unassignRank: async () => true,
|
|
146
|
+
// task 1004529: who decides at People. [] = the as-today default row.
|
|
147
|
+
listDecisionRules: async () => [],
|
|
148
|
+
getActiveBuildersWithRank: async () => [],
|
|
149
|
+
builderHoldsRankActive: async () => false,
|
|
146
150
|
};
|
|
147
151
|
const REAL = {};
|
|
148
152
|
for (const k of Object.keys(stub)) {
|
|
@@ -44,6 +44,7 @@ const NON_GRANT_MIGRATIONS = new Set([
|
|
|
44
44
|
'government_017_board_section_keys_v2.sql', // BV2.R06 board section_key CHECK widening (task 1004085)
|
|
45
45
|
'government_018_passed_items_index.sql', // BV2.R17 passed-item lookup index (task 1004096)
|
|
46
46
|
'government_020_genesis_stage_subject.sql', // BV2.PS04 genesis_stage board subject CHECK (task 1004429)
|
|
47
|
+
'government_023_decision_rules.sql', // who decides what: the decision-rules table, matters (task 1004529)
|
|
47
48
|
]);
|
|
48
49
|
|
|
49
50
|
// Permission keys RENAMED after the grant migrations that first granted them.
|
package/tests/hall_audit.mjs
CHANGED
|
@@ -378,7 +378,15 @@ test('the module doc still records where the 2179 density pass landed', () => {
|
|
|
378
378
|
// the hall's own 'hall-theme' preference. Whether it should join the chrome
|
|
379
379
|
// behind the full-screen switch, as the sky did in task 1004327, is the
|
|
380
380
|
// owner's call, named in that task's ship notes.
|
|
381
|
-
|
|
381
|
+
// THE STREET (task 1004525) follows the city, for the same reason: the
|
|
382
|
+
// reference design's street view shows nothing on the page but its own header
|
|
383
|
+
// (the wordmark back to the city, needs you, moving, your share, the switch),
|
|
384
|
+
// the street and the six readings. Same terms as the city: style.css and the
|
|
385
|
+
// hall's tokens, the hall's own 'hall-theme' preference, no private palette.
|
|
386
|
+
const STANDALONE_EXEMPT = {
|
|
387
|
+
'city.html': 'the governor city home: nothing on the page but the prototype home (task 1004524)',
|
|
388
|
+
'street.html': 'the governor street: nothing on the page but the prototype street view (task 1004525)',
|
|
389
|
+
};
|
|
382
390
|
|
|
383
391
|
test('every hall page loads style.css and shell.js', () => {
|
|
384
392
|
const missing = [];
|
|
@@ -144,6 +144,9 @@ const ORACLE = {
|
|
|
144
144
|
// governed read, GET /government/docket, checks board.vote.cast, so the page's
|
|
145
145
|
// reach equals the right to read the docket it draws.
|
|
146
146
|
'/builders/city': 'requireBoardVotePage',
|
|
147
|
+
// task 1004525: the street, the city's one door, on the same atom for the
|
|
148
|
+
// same reason (its governed read is the same docket).
|
|
149
|
+
'/builders/street': 'requireBoardVotePage',
|
|
147
150
|
|
|
148
151
|
// Deliberately UNGATED — the landing card, the rank ladder, the reward
|
|
149
152
|
// schedule and a builder's public portfolio all render for a stranger.
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
// tests/hall_street.mjs — THE STREET (task 1004525, goal 1000125).
|
|
2
|
+
//
|
|
3
|
+
// WHAT IT GUARDS. The page is the street view of the reference design
|
|
4
|
+
// (docs/design/mocks/governor-city/Street.dc.html) built over the governor
|
|
5
|
+
// docket (task 1004523), the open goals' task rollups and the viewer's
|
|
6
|
+
// earnings. What can go wrong:
|
|
7
|
+
// * the drawing drifts from the prototype (a civic recipe, the road, a
|
|
8
|
+
// district's work, the spacing of the row, a pin, a door, a crate) —
|
|
9
|
+
// pinned by running the PROTOTYPE'S OWN Component class beside
|
|
10
|
+
// street-draw.js and comparing what they emit, in both modes;
|
|
11
|
+
// * the street out of the order work moves, a group bracket over the wrong
|
|
12
|
+
// buildings, a goal with no door, or more districts than the row can hold;
|
|
13
|
+
// * a pin, a reading's orange dot or a state line saying something the
|
|
14
|
+
// docket does not (invented data, or a reading that is off drawn as live);
|
|
15
|
+
// * a door or a reading opening anywhere but where its interior will live;
|
|
16
|
+
// * the card reaching innerHTML, or opening an off-site link from the docket;
|
|
17
|
+
// * the switch not using the hall's own preference; motion that escapes the
|
|
18
|
+
// Kill Switch.
|
|
19
|
+
//
|
|
20
|
+
// Run: node tests/hall_street.mjs
|
|
21
|
+
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import vm from 'node:vm';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const City = require(path.join(PUB, 'city-draw.js'));
|
|
34
|
+
const S = require(path.join(PUB, 'street-draw.js'));
|
|
35
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
36
|
+
const NOW = Date.parse('2026-10-02T12:00:00.000Z'); // a Friday
|
|
37
|
+
|
|
38
|
+
// The prototype's Component class, run under a stub DCLogic: the reference
|
|
39
|
+
// the port is held equal to.
|
|
40
|
+
function prototype() {
|
|
41
|
+
const src = fs.readFileSync(path.join(ROOT, 'docs/design/mocks/governor-city/Street.dc.html'), 'utf8');
|
|
42
|
+
const script = src.split('data-dc-script')[1].split('>').slice(1).join('>').split('</script>')[0];
|
|
43
|
+
const DCLogic = class { constructor(p) { this.props = p || {}; this.state = {}; } setState() {} };
|
|
44
|
+
return new Function('DCLogic', `${script}\nreturn Component;`)(DCLogic);
|
|
45
|
+
}
|
|
46
|
+
const Proto = prototype();
|
|
47
|
+
function protoStreet(mode, st = {}) {
|
|
48
|
+
const p = new Proto({ mode });
|
|
49
|
+
p.state = Object.assign(p.initial(), { view: 'street' }, st);
|
|
50
|
+
return { p, v: p.renderVals() };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A docket in the route's own shape (modules/government/docket.js buildDocket).
|
|
54
|
+
function matter(over = {}) {
|
|
55
|
+
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 };
|
|
56
|
+
}
|
|
57
|
+
const BUILDINGS = [['charter', 'how we decide'], ['rules', 'how we decide'], ['disputes', 'how we decide'], ['people', 'who and what'], ['planning', 'who and what'], ['automation', 'the work'], ['review', 'checks and going live'], ['release', 'checks and going live'], ['treasury', 'money']];
|
|
58
|
+
const READINGS = [['flow', 'flow', 'review'], ['promises', 'promises', 'planning'], ['money', 'money', 'treasury'], ['people', 'people', 'people'], ['fairness', 'fairness', 'disputes'], ['rules', 'rules at work', 'rules']];
|
|
59
|
+
function docket(matters = [], over = {}) {
|
|
60
|
+
const counts = { needs: 0, moving: 0, settled: 0 };
|
|
61
|
+
matters.forEach((m) => { counts[m.state] += 1; });
|
|
62
|
+
const buildings = BUILDINGS.map(([key, group]) => {
|
|
63
|
+
const here = matters.filter((m) => m.building === key);
|
|
64
|
+
const c = { needs: 0, moving: 0, settled: 0 };
|
|
65
|
+
here.forEach((m) => { c[m.state] += 1; });
|
|
66
|
+
return { key, name: key[0].toUpperCase() + key.slice(1), group, counts: c, matter_ids: here.map((m) => m.id) };
|
|
67
|
+
});
|
|
68
|
+
const readings = READINGS.map(([key, label, building]) => ({ key, label, building, status: 'off', value: null, figure: null, line: null, hot: false, reason: 'The module that holds this is not enabled here.' }));
|
|
69
|
+
const sources = [{ source: 'criteria_uat', building: 'review', label: 'criteria awaiting UAT', status: 'live', count: 0, reason: null }];
|
|
70
|
+
return { viewer: { id: '95' }, generated_at: '2026-10-02T00:00:00.000Z', counts, line: `${counts.needs} matters wait on you`, matters, buildings, sources, readings, vocabulary: {}, ...over };
|
|
71
|
+
}
|
|
72
|
+
const goal = (id, status = 'open', title = `Goal ${id}`) => ({ id: String(id), title, status });
|
|
73
|
+
const rollup = (id, c) => ({ goal_id: String(id), done: 0, active: 0, ready: 0, blocked: 0, backlog: 0, cut: 0, ...c });
|
|
74
|
+
const model = (over = {}) => S.streetModel({ docket: docket(), goals: [], rollups: [], mode: 'dark', now: NOW, ...over });
|
|
75
|
+
|
|
76
|
+
// ── the drawing is the prototype's ───────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
test('the street palette is the prototype theme() (less the iso half city-draw.js carries), both modes', () => {
|
|
79
|
+
for (const mode of ['dark', 'light']) {
|
|
80
|
+
const t = new Proto({ mode }).theme(mode);
|
|
81
|
+
delete t.iso;
|
|
82
|
+
assert.deepEqual(S.theme(mode), t, mode);
|
|
83
|
+
assert.deepEqual(City.isoTheme(mode), new Proto({ mode }).theme(mode).iso, 'and the iso half is still city-draw.js\'s');
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('the nine civic recipes draw exactly the prototype paths and roof heights, in every state, both modes', () => {
|
|
88
|
+
for (const mode of ['dark', 'light']) {
|
|
89
|
+
const p = new Proto({ mode });
|
|
90
|
+
const T = p.theme(mode);
|
|
91
|
+
for (const st of [{ evening: false, reviewHeld: true, fixes: false }, { evening: true, reviewHeld: false, fixes: true }]) {
|
|
92
|
+
for (const [kind, w] of [['charter', 84], ['rules', 96], ['disputes', 80], ['people', 80], ['planning', 80], ['automation', 80], ['review', 86], ['release', 92], ['treasury', 84]]) {
|
|
93
|
+
const a = []; const b = [];
|
|
94
|
+
const ra = p.civic(a, T, kind, 100, 556, w, st);
|
|
95
|
+
const rb = S.civic(b, S.theme(mode), kind, 100, 556, w, st);
|
|
96
|
+
assert.deepEqual(b, a, `${mode} ${kind} ${JSON.stringify(st)}`);
|
|
97
|
+
assert.equal(rb, ra, `${mode} ${kind} roof`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('every district kind the prototype draws is drawn the same way, lit and unlit', () => {
|
|
104
|
+
for (const mode of ['dark', 'light']) {
|
|
105
|
+
const p = new Proto({ mode });
|
|
106
|
+
for (const lit of [false, true]) {
|
|
107
|
+
for (const kind of ['tower', 'setback', 'shed', 'scaffold', 'trees']) {
|
|
108
|
+
const a = []; const b = [];
|
|
109
|
+
p.work(a, p.theme(mode), kind, 660, 556, 19, 84, lit);
|
|
110
|
+
S.work(b, S.theme(mode), kind, 660, 556, 19, 84, lit);
|
|
111
|
+
assert.deepEqual(b, a, `${mode} ${kind} lit=${lit}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a task not started is the prototype\'s workItem "ready" slab, and the more-goals lot its "backlog" stakes', () => {
|
|
118
|
+
const p = new Proto({ mode: 'dark' });
|
|
119
|
+
const T = p.theme('dark');
|
|
120
|
+
const a = []; const b = [];
|
|
121
|
+
p.workItem(a, T, 'engineer', 'ready', 660, 19, 0);
|
|
122
|
+
S.work(b, S.theme('dark'), 'slab', 660, 0, 19, 6, false);
|
|
123
|
+
assert.deepEqual(b, a, 'the slab, at a base of 0 as workItem draws it');
|
|
124
|
+
const c = []; const d = [];
|
|
125
|
+
p.workItem(c, T, 'engineer', 'backlog', 660, 40, 0);
|
|
126
|
+
S.work(d, S.theme('dark'), 'stakes', 660, 0, 40, 0, false);
|
|
127
|
+
assert.deepEqual(d, c, 'the stakes');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('the road, its line and its lanes are the prototype\'s, and light adds its contact shadows', () => {
|
|
131
|
+
for (const mode of ['dark', 'light']) {
|
|
132
|
+
const { v } = protoStreet(mode);
|
|
133
|
+
const m = model({ mode, goals: [1, 2, 3, 4].map((i) => goal(i)) });
|
|
134
|
+
assert.deepEqual(m.flat.slice(0, 3), v.flat.slice(0, 3), mode);
|
|
135
|
+
assert.equal(m.flat.length, v.flat.length, `${mode}: one shadow per building or district, as the prototype`);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('with four goals the row is the prototype\'s: the same x for every civic building and the same group brackets', () => {
|
|
140
|
+
const { v } = protoStreet('dark');
|
|
141
|
+
const m = model({ goals: [1, 2, 3, 4].map((i) => goal(i)) });
|
|
142
|
+
assert.equal(m.width, 1440);
|
|
143
|
+
const civicX = (cols) => cols.filter((c) => S.CIVIC_KEYS.includes(c.key)).map((c) => [c.key, c.x, c.w]);
|
|
144
|
+
assert.deepEqual(civicX(m.cols), civicX(v.cols));
|
|
145
|
+
assert.deepEqual(m.groups.map((g) => [g.name, g.x, g.w]), v.groups.map((g) => [g.name, g.x, g.w]));
|
|
146
|
+
// the districts stand where the prototype's four stood
|
|
147
|
+
const dx = m.cols.filter((c) => c.key.startsWith('d:')).map((c) => c.x + 4);
|
|
148
|
+
assert.deepEqual(dx, v.cols.filter((c) => ['onboarding', 'payments', 'hall', 'modules'].includes(c.key)).map((c) => c.x + 10));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('the street is in the order work moves, under the five group names', () => {
|
|
152
|
+
const m = model({ goals: [goal(7), goal(8)] });
|
|
153
|
+
assert.deepEqual(m.cols.map((c) => c.key), ['charter', 'rules', 'disputes', 'people', 'planning', 'automation', 'd:7', 'd:8', 'review', 'release', 'treasury']);
|
|
154
|
+
assert.deepEqual(m.groups.map((g) => g.name), ['how we decide', 'who and what', 'the work', 'checks and going live', 'money']);
|
|
155
|
+
const xs = m.cols.map((c) => c.x);
|
|
156
|
+
assert.deepEqual(xs, [...xs].sort((a, b) => a - b), 'left to right');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a pin over a civic building stands where the prototype stands its pin, with its label', () => {
|
|
160
|
+
const { v } = protoStreet('dark');
|
|
161
|
+
const proto = (key) => v.pins.find((p) => p.key === key);
|
|
162
|
+
const m = model({ docket: docket([
|
|
163
|
+
matter({ id: 'board:1', building: 'charter', state: 'needs', rule: 'unanimous', short: 'review seat', deadline: '2026-10-06T12:00:00.000Z' }),
|
|
164
|
+
matter({ id: 'override_requests:2', building: 'release', state: 'settled', rule: 'decide', short: 'new sign-up' }),
|
|
165
|
+
]), goals: [1, 2, 3, 4].map((i) => goal(i)) });
|
|
166
|
+
const charter = m.pins.find((p) => p.building === 'charter');
|
|
167
|
+
assert.deepEqual([charter.x, charter.y, charter.h], [proto('review').x, proto('review').y, proto('review').h]);
|
|
168
|
+
assert.equal(charter.label, 'review seat · tue', 'the short label, then the deadline in the prototype\'s words');
|
|
169
|
+
const release = m.pins.find((p) => p.building === 'release');
|
|
170
|
+
assert.deepEqual([release.x, release.y], [proto('release').x, proto('release').y], 'the release pin keeps its 4 px nudge');
|
|
171
|
+
const g = City.glyph('unanimous', 9, 9, 16);
|
|
172
|
+
assert.deepEqual([charter.gf, charter.gl], [g.fill, g.line], 'the glyph is the matter\'s rule');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('every civic door is the prototype\'s door, and every door opens where its interior will live', () => {
|
|
176
|
+
const { v } = protoStreet('dark', { reviewHeld: true }); // this project's Review is held
|
|
177
|
+
const m = model({ goals: [1, 2, 3, 4].map((i) => goal(i)) });
|
|
178
|
+
for (const k of S.CIVIC_KEYS) {
|
|
179
|
+
const a = v.doors.find((d) => d.key === k); const b = m.doors.find((d) => d.key === k);
|
|
180
|
+
assert.deepEqual([b.x, b.y, b.w, b.h], [a.x, a.y, a.w, a.h], k);
|
|
181
|
+
assert.equal(b.href, `/builders/street#building=${k}`);
|
|
182
|
+
}
|
|
183
|
+
assert.deepEqual(m.doors.filter((d) => d.key.startsWith('d:')).map((d) => d.href), [1, 2, 3, 4].map((i) => `/builders/street#district=${i}`));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── what the street says comes from the docket, and only from it ───────────
|
|
187
|
+
|
|
188
|
+
test('a pin per open matter at its building, in its state, up to three stacked, needs first as the docket ordered them', () => {
|
|
189
|
+
const ms = ['needs', 'needs', 'moving', 'settled'].map((state, i) => matter({ id: `blockers:${i}`, state }));
|
|
190
|
+
const m = model({ docket: docket(ms) });
|
|
191
|
+
const here = m.pins.filter((p) => p.building === 'planning');
|
|
192
|
+
assert.equal(here.length, S.MAX_PINS_PER_BUILDING);
|
|
193
|
+
assert.deepEqual(here.map((p) => p.state), ['needs', 'needs', 'moving']);
|
|
194
|
+
assert.equal(new Set(here.map((p) => p.y)).size, 3, 'stacked, one label above the other');
|
|
195
|
+
assert.ok(here.every((p) => p.x === here[0].x));
|
|
196
|
+
assert.equal(m.cols.find((c) => c.key === 'planning').state, '0 goals open', 'Planning says how many goals are open');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('no docket: no pins, the readings say so, and nothing is invented', () => {
|
|
200
|
+
const m = S.streetModel({ docket: null, goals: [goal(1)], rollups: [], mode: 'dark', now: NOW });
|
|
201
|
+
assert.equal(m.pins.length, 0);
|
|
202
|
+
assert.equal(m.crates.length, 0);
|
|
203
|
+
assert.ok(m.signs.every((s) => s.figure === '—' && !s.hot && s.line === S.FALLBACK_LINE));
|
|
204
|
+
assert.deepEqual([m.hud.needs, m.hud.moving, m.hud.share], ['—', '—', '—']);
|
|
205
|
+
assert.ok(m.cols.filter((c) => S.CIVIC_KEYS.includes(c.key)).every((c) => c.state === ''));
|
|
206
|
+
assert.equal(m.evening, false, 'an unread docket is not an empty one');
|
|
207
|
+
assert.equal(m.reviewHeld, true, 'nor does it empty Review');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a reading that is off is drawn as off: a dash and its reason, never a figure', () => {
|
|
211
|
+
const d = docket();
|
|
212
|
+
d.readings[3] = { ...d.readings[3], status: 'live', figure: '12 builders', line: 'the council casts 31 votes each a month', value: 12 };
|
|
213
|
+
d.readings[1] = { ...d.readings[1], status: 'not_yours', reason: null };
|
|
214
|
+
const m = model({ docket: d });
|
|
215
|
+
const by = Object.fromEntries(m.signs.map((s) => [s.key, s]));
|
|
216
|
+
assert.deepEqual(m.signs.map((s) => [s.key, s.label]), READINGS.map(([k, l]) => [k, l]));
|
|
217
|
+
assert.deepEqual([by.people.figure, by.people.line], ['12 builders', 'the council casts 31 votes each a month']);
|
|
218
|
+
assert.deepEqual([by.money.figure, by.money.line], ['—', 'The module that holds this is not enabled here.']);
|
|
219
|
+
assert.equal(by.promises.line, 'not yours to read here');
|
|
220
|
+
assert.equal(m.cols.find((c) => c.key === 'people').state, '12 builders', 'a live reading is its building\'s state line');
|
|
221
|
+
assert.deepEqual(m.signs.map((s) => s.href), READINGS.map(([, , b]) => `/builders/street#building=${b}`), 'each reading opens its building');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('a reading\'s dot is orange when the reading is hot or its building has a matter that needs you', () => {
|
|
225
|
+
const d = docket([matter({ id: 'access_requests:1', building: 'people', state: 'needs' }), matter({ id: 'core_update:core', building: 'treasury', state: 'moving' })]);
|
|
226
|
+
d.readings[4] = { ...d.readings[4], status: 'live', figure: '1 open', line: 'oldest 2 days', hot: true };
|
|
227
|
+
const by = Object.fromEntries(model({ docket: d }).signs.map((s) => [s.key, s.hot]));
|
|
228
|
+
assert.deepEqual(by, { flow: false, promises: false, money: false, people: true, fairness: true, rules: false });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('state lines count a building\'s matters when no reading speaks for it', () => {
|
|
232
|
+
const d = docket([matter({ id: 'a:1', building: 'release', state: 'moving' }), matter({ id: 'a:2', building: 'release', state: 'moving' }), matter({ id: 'a:3', building: 'charter', state: 'needs' })]);
|
|
233
|
+
const st = Object.fromEntries(model({ docket: d }).cols.map((c) => [c.key, c.state]));
|
|
234
|
+
assert.equal(st.release, '2 moving');
|
|
235
|
+
assert.equal(st.charter, '1 needs you');
|
|
236
|
+
assert.equal(st.automation, 'nothing open');
|
|
237
|
+
assert.equal(st.review, 'nothing waiting');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('work waiting at Review is crates outside its door and the prototype\'s count pin, not a pin each', () => {
|
|
241
|
+
const { v } = protoStreet('dark');
|
|
242
|
+
const ms = [1, 2, 3, 4].map((i) => matter({ id: `criteria_uat:${i}`, source: 'criteria_uat', building: 'review', state: 'needs', title: `Criterion ${i}` }));
|
|
243
|
+
const m = model({ docket: docket(ms), goals: [1, 2, 3, 4].map((i) => goal(i)) });
|
|
244
|
+
assert.deepEqual(m.crates.map((c) => [c.x, c.y]), v.crates.map((c) => [c.x, c.y]), 'three crates where the prototype stands them');
|
|
245
|
+
const q = m.pins.filter((p) => p.building === 'review');
|
|
246
|
+
assert.equal(q.length, 1);
|
|
247
|
+
const pq = v.pins.find((p) => p.key === 'queue');
|
|
248
|
+
assert.deepEqual([q[0].isCount, q[0].count, q[0].label, q[0].x, q[0].y, q[0].h], [true, '4', 'waiting for a tester', pq.x, pq.y, pq.h]);
|
|
249
|
+
assert.equal(m.cols.find((c) => c.key === 'review').state, '4 waiting');
|
|
250
|
+
const card = S.cardFor(docket(ms), 'queue', NOW);
|
|
251
|
+
assert.deepEqual(card.lines, ['Criterion 1', 'Criterion 2', 'Criterion 3', 'Criterion 4']);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('Review is the empty seat only when no source that stands in it can be read', () => {
|
|
255
|
+
const off = docket([], { sources: [{ source: 'criteria_uat', building: 'review', status: 'off', count: 0 }] });
|
|
256
|
+
const m = model({ docket: off });
|
|
257
|
+
assert.equal(m.reviewHeld, false);
|
|
258
|
+
assert.equal(m.cols.find((c) => c.key === 'review').state, 'empty seat');
|
|
259
|
+
assert.equal(m.cols.find((c) => c.key === 'review').strong, true, 'the empty seat\'s name is in ink, as the prototype');
|
|
260
|
+
const rv = m.cols.find((c) => c.key === 'review');
|
|
261
|
+
const ghost = []; S.civic(ghost, S.theme('dark'), 'review', rv.x + 10, S.G, 86, { reviewHeld: false });
|
|
262
|
+
assert.deepEqual(m.blds.find((b) => b.key === 'review').polys, ghost, 'drawn as the prototype\'s vacant lot');
|
|
263
|
+
assert.equal(model({ docket: docket() }).reviewHeld, true, 'a live source holds the seat');
|
|
264
|
+
assert.equal(model({ docket: docket([], { sources: [{ source: 'criteria_uat', building: 'review', status: 'not_yours' }] }) }).reviewHeld, true, 'one the viewer cannot see still holds it');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('Rules lights its band when a standing rule is at work, on the street and in the city', () => {
|
|
268
|
+
const d = docket();
|
|
269
|
+
d.readings[5] = { ...d.readings[5], status: 'live', value: 1, figure: '1 rule', line: 'settled 3 things this month' };
|
|
270
|
+
assert.equal(City.rulesAtWork(d), true);
|
|
271
|
+
assert.equal(City.rulesAtWork(docket()), false);
|
|
272
|
+
assert.equal(City.rulesAtWork(null), false);
|
|
273
|
+
const band = (m) => m.blds.find((b) => b.key === 'rules').polys.length;
|
|
274
|
+
assert.equal(band(model({ docket: d })), band(model()) + 1, 'the street draws the lit band');
|
|
275
|
+
const cityRules = (dk) => City.cityModel({ docket: dk, mode: 'dark' }).blds.find((b) => b.key === 'rules').polys;
|
|
276
|
+
assert.notDeepEqual(cityRules(d), cityRules(docket()), 'and so does the city');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('the windows light (evening) only when the docket was read and nothing needs you', () => {
|
|
280
|
+
assert.equal(model({ docket: docket([matter({ state: 'moving' })]) }).evening, true);
|
|
281
|
+
assert.equal(model({ docket: docket([matter({ state: 'needs' })]) }).evening, false);
|
|
282
|
+
const m = model({ docket: docket([matter({ state: 'moving' }), matter({ id: 'x:2', state: 'settled' })]), earnings: { days: [{ day: '2026-10-01', credits: 24 }] } });
|
|
283
|
+
assert.equal(m.hint, '', 'the hint gives way to the evening');
|
|
284
|
+
assert.equal(m.eveningLine, 'this month · 1 moving · 1 settled · $24 to your share');
|
|
285
|
+
assert.equal(model({ docket: docket([matter()]) }).hint, S.HINT);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── districts: one door per open goal ───────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
test('one district per OPEN goal; achieved and archived goals build nothing', () => {
|
|
291
|
+
const m = model({ goals: [goal(1), goal(2, 'achieved'), goal(3), goal(4, 'archived')] });
|
|
292
|
+
assert.deepEqual(m.cols.filter((c) => c.key.startsWith('d:')).map((c) => c.key), ['d:1', 'd:3']);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('past eight open goals the first seven stand and the eighth plot is the rest, a door to the goals page', () => {
|
|
296
|
+
const goals = Array.from({ length: 20 }, (_, i) => goal(i + 1));
|
|
297
|
+
const m = model({ goals });
|
|
298
|
+
const ds = m.cols.filter((c) => c.key.startsWith('d:'));
|
|
299
|
+
assert.equal(ds.length, S.MAX_DISTRICTS);
|
|
300
|
+
assert.deepEqual(ds.map((c) => c.key).slice(0, 7), goals.slice(0, 7).map((g) => `d:${g.id}`));
|
|
301
|
+
assert.equal(ds[7].name, '13 more goals');
|
|
302
|
+
assert.equal(m.doors.find((d) => d.key === 'd:more').href, S.GOALS_HREF);
|
|
303
|
+
assert.ok(m.width < 1600, 'the row stays near the artboard');
|
|
304
|
+
const xs = m.cols.map((c) => c.x);
|
|
305
|
+
for (let i = 1; i < m.cols.length; i++) assert.ok(m.cols[i].x >= m.cols[i - 1].x + (m.cols[i - 1].key.startsWith('d:') ? m.cols[i - 1].w : 0) - 1, `no two names overlap at ${m.cols[i].key}`);
|
|
306
|
+
assert.equal(xs.length, m.doors.length);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('a goal\'s work: built, being built and not started, in proportion; nothing yet is trees', () => {
|
|
310
|
+
const kinds = (r) => S.districtItems('7', 90, r).map((i) => i.kind);
|
|
311
|
+
assert.deepEqual(kinds(rollup(7, {})), ['trees']);
|
|
312
|
+
const k = kinds(rollup(7, { done: 6, active: 1, ready: 1 }));
|
|
313
|
+
assert.equal(k.length, 4, 'a 90 px plot holds the prototype\'s four');
|
|
314
|
+
assert.ok(k.filter((x) => ['tower', 'setback', 'shed'].includes(x)).length >= 2);
|
|
315
|
+
assert.equal(k.filter((x) => x === 'scaffold').length, 1);
|
|
316
|
+
assert.equal(k.filter((x) => x === 'slab').length, 1);
|
|
317
|
+
assert.deepEqual(kinds(rollup(7, { backlog: 9 })), ['slab', 'slab', 'slab', 'slab']);
|
|
318
|
+
assert.equal(S.districtLine(rollup(7, { done: 4, active: 1 })), '4 built', 'the prototype\'s line');
|
|
319
|
+
assert.equal(S.districtLine(rollup(7, { active: 2 })), '2 being built');
|
|
320
|
+
assert.equal(S.districtLine(rollup(7, {})), 'no work yet');
|
|
321
|
+
const m = model({ goals: [goal(7)], rollups: [rollup(7, { done: 6, active: 1, ready: 1 })] });
|
|
322
|
+
assert.ok(m.blds.filter((b) => b.key.startsWith('d:7:')).length === 4, 'all four stand on the plot');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('the drawing is deterministic: the same feeds give the same street', () => {
|
|
326
|
+
const input = { docket: docket([matter()]), goals: [goal(1), goal(2)], rollups: [rollup(1, { done: 5 }), rollup(2, { active: 2 })], mode: 'light', now: NOW };
|
|
327
|
+
assert.deepEqual(S.streetModel(input), S.streetModel(input));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ── the small words ─────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
test('deadlines read the prototype\'s way: today, a weekday inside the week, else a date', () => {
|
|
333
|
+
assert.equal(S.whenWord('2026-10-02T20:00:00.000Z', NOW), 'today');
|
|
334
|
+
assert.equal(S.whenWord('2026-10-04T09:00:00.000Z', NOW), 'sun');
|
|
335
|
+
assert.equal(S.whenWord('2026-10-20T09:00:00.000Z', NOW), 'oct 20');
|
|
336
|
+
assert.equal(S.whenWord(null, NOW), '');
|
|
337
|
+
assert.equal(S.whenWord('nonsense', NOW), '');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('your share this month sums only this month\'s earnings, in dollars at the peg', () => {
|
|
341
|
+
const e = { days: [{ day: '2026-09-29', credits: 120 }, { day: '2026-10-01', credits: 40 }, { day: '2026-10-02', credits: 1200 }] };
|
|
342
|
+
assert.equal(S.monthShare(e, NOW), 1240);
|
|
343
|
+
assert.equal(S.money(1240), '$1,240');
|
|
344
|
+
assert.equal(S.monthShare(null, NOW), null);
|
|
345
|
+
assert.equal(S.money(null), '—');
|
|
346
|
+
assert.equal(model({ earnings: e }).hud.share, '$1,240');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('the address names a building or a district, and nothing else', () => {
|
|
350
|
+
assert.deepEqual(S.parseHash('#building=charter'), { kind: 'building', key: 'charter' });
|
|
351
|
+
assert.deepEqual(S.parseHash('#district=1000125'), { kind: 'district', key: '1000125' });
|
|
352
|
+
assert.equal(S.parseHash('#building=nowhere'), null);
|
|
353
|
+
assert.equal(S.parseHash('#/task/5'), null);
|
|
354
|
+
assert.equal(S.parseHash('#district=%E0%A4%A'), null, 'a malformed escape is no address');
|
|
355
|
+
const m = model({ here: { kind: 'building', key: 'disputes' } });
|
|
356
|
+
assert.deepEqual(m.doors.filter((d) => d.here).map((d) => d.key), ['disputes']);
|
|
357
|
+
assert.deepEqual(m.signs.filter((s) => s.here).map((s) => s.key), ['fairness']);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('the short card: where, when, title, who raised it, the tally and its state; only an on-site link', () => {
|
|
361
|
+
const d = docket([matter({ id: 'board:9', building: 'charter', where: 'Charter · amendment', state: 'needs', title: 'Give Review a seat', deadline: '2026-10-03T09:00:00.000Z', proposer: { id: '4', login: 'ana', name: 'Ana' }, tally: { for: 4, against: 1, open: 0, needs: 'needs everyone who votes' }, href: '/builders/board-room#s9' }), matter({ id: 'x:1', href: 'https://evil.example/' })]);
|
|
362
|
+
const c = S.cardFor(d, 'board:9', NOW);
|
|
363
|
+
assert.deepEqual([c.where, c.when, c.title, c.proposer, c.tally, c.href], ['charter · amendment', 'sat', 'Give Review a seat', 'raised by Ana', '4 for · 1 against · needs everyone who votes', '/builders/board-room#s9']);
|
|
364
|
+
assert.equal(c.result, 'This waits on you.');
|
|
365
|
+
assert.equal(S.cardFor(d, 'x:1', NOW).href, null, 'an off-site href is never offered');
|
|
366
|
+
assert.equal(S.cardFor(d, 'nope', NOW), null);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ── the page ────────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
test('the page holds the prototype\'s header, the street, the readings and the card; nothing else', () => {
|
|
372
|
+
const html = read('street.html').replace(/<!--[\s\S]*?-->/g, '');
|
|
373
|
+
assert.equal((html.match(/<header\b/g) || []).length, 1);
|
|
374
|
+
assert.equal((html.match(/<main\b/g) || []).length, 1);
|
|
375
|
+
assert.equal((html.match(/<h1\b/g) || []).length, 1);
|
|
376
|
+
assert.match(html, /<a class="street-mark" id="streetHome" href="\/builders\/city"[^>]*>\{\{productName\}\}</);
|
|
377
|
+
for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
|
|
378
|
+
assert.doesNotMatch(html, /app-sidebar|shell\.js/, 'no hall chrome, as the city');
|
|
379
|
+
const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
|
|
380
|
+
assert.deepEqual(scripts, ['city-draw', 'street-draw', 'street'], 'the shared kit loads first');
|
|
381
|
+
assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('motion is CSS only and dies under reduced motion; no page :root; no innerHTML', () => {
|
|
385
|
+
const css = read('street.css').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
386
|
+
assert.doesNotMatch(css, /:root/);
|
|
387
|
+
assert.match(css, /@media \(prefers-reduced-motion: reduce\) \{[^}]*animation: none !important; transition: none !important;/);
|
|
388
|
+
for (const k of ['street-ring', 'street-enter']) assert.match(css, new RegExp(`@keyframes ${k} `));
|
|
389
|
+
assert.doesNotMatch(css, /#fb9569/i, 'the accent is the token, never a literal');
|
|
390
|
+
const js = (read('street.js') + read('street-draw.js')).replace(/^\s*\/\/.*$/gm, '');
|
|
391
|
+
assert.doesNotMatch(js, /innerHTML|insertAdjacentHTML|requestAnimationFrame|setInterval/);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('the two dark blocks are byte-identical twins', () => {
|
|
395
|
+
const css = read('street.css');
|
|
396
|
+
const a = css.match(/html\[data-theme="dark"\] body\[data-page="street"\] \{([^}]*)\}/)[1];
|
|
397
|
+
const b = css.match(/html:not\(\[data-theme\]\) body\[data-page="street"\] \{([^}]*)\}/)[1];
|
|
398
|
+
assert.equal(a.replace(/\s+/g, ' ').trim(), b.replace(/\s+/g, ' ').trim());
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// A small DOM, enough for street.js: ids, elements, SVG elements, attributes.
|
|
402
|
+
function fakeDom() {
|
|
403
|
+
const byId = new Map();
|
|
404
|
+
const mk = (tag) => {
|
|
405
|
+
const el = {
|
|
406
|
+
tagName: tag, attrs: {}, children: [], style: { setProperty(k, v) { this[k] = v; } }, className: '', listeners: {}, _text: '', hidden: false,
|
|
407
|
+
clientWidth: 1440, clientHeight: 708, offsetLeft: 0, scrollLeft: 0, parentNode: null,
|
|
408
|
+
get firstChild() { return this.children[0] || null; },
|
|
409
|
+
setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'id') byId.set(String(v), this); },
|
|
410
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
|
|
411
|
+
appendChild(c) { this.children.push(c); c.parentNode = this; return c; },
|
|
412
|
+
removeChild(c) { this.children = this.children.filter((x) => x !== c); return c; },
|
|
413
|
+
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
|
|
414
|
+
fire(t, e = {}) { (this.listeners[t] || []).forEach((fn) => fn({ type: t, preventDefault() { e.prevented = true; }, ...e })); },
|
|
415
|
+
focus() { focused.el = this; },
|
|
416
|
+
set textContent(v) { this._text = String(v); this.children = []; },
|
|
417
|
+
get textContent() { return this._text; },
|
|
418
|
+
get innerHTML() { throw new Error('street.js must not read innerHTML'); },
|
|
419
|
+
set innerHTML(v) { throw new Error('street.js must not write innerHTML'); },
|
|
420
|
+
};
|
|
421
|
+
return el;
|
|
422
|
+
};
|
|
423
|
+
const focused = { el: null };
|
|
424
|
+
const html = mk('html');
|
|
425
|
+
const ids = ['streetMode', 'streetModeIcon', 'streetModeWord', 'streetWindow', 'streetSizer', 'streetBoard', 'streetSvg', 'streetFlat', 'streetBlds', 'streetCrates', 'streetCols', 'streetGroups', 'streetDoors', 'streetPins', 'streetSigns', 'streetHint', 'streetEvening', 'streetEveningLine', 'hudNeeds', 'hudMoving', 'hudShare', 'hudDot', 'streetHome', 'streetCard', 'streetCardWhere', 'streetCardWhen', 'streetCardTitle', 'streetCardMeta', 'streetCardLines', 'streetCardResult', 'streetCardOpen', 'streetCardClose'];
|
|
426
|
+
for (const id of ids) mk('x').setAttribute('id', id);
|
|
427
|
+
const document = { documentElement: html, getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_ns, t) => mk(t) };
|
|
428
|
+
return { document, byId, html, focused };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function bootPage({ feeds = {}, stored = null, osDark = true, hash = '' } = {}) {
|
|
432
|
+
const { document, byId, html, focused } = fakeDom();
|
|
433
|
+
const store = new Map(stored ? [['hall-theme', stored]] : []);
|
|
434
|
+
const calls = [];
|
|
435
|
+
const listeners = {};
|
|
436
|
+
const window = {
|
|
437
|
+
innerWidth: 1440, innerHeight: 900, location: { hash },
|
|
438
|
+
localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
|
|
439
|
+
matchMedia: () => ({ matches: osDark, addEventListener() {} }),
|
|
440
|
+
addEventListener(t, fn) { (listeners[t] = listeners[t] || []).push(fn); },
|
|
441
|
+
};
|
|
442
|
+
const fetch = (url) => {
|
|
443
|
+
calls.push(url);
|
|
444
|
+
const body = feeds[url];
|
|
445
|
+
return Promise.resolve(body === undefined ? { ok: false, json: async () => ({}) } : { ok: true, json: async () => body });
|
|
446
|
+
};
|
|
447
|
+
const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN });
|
|
448
|
+
ctx.globalThis = ctx;
|
|
449
|
+
// each file hangs its api on `window`, as the browser loads them
|
|
450
|
+
vm.runInContext(read('city-draw.js'), ctx);
|
|
451
|
+
vm.runInContext(read('street-draw.js'), ctx);
|
|
452
|
+
vm.runInContext(read('street.js'), ctx);
|
|
453
|
+
await window.GovStreetPage.ready;
|
|
454
|
+
const key = (t, e) => (listeners[t] || []).forEach((fn) => fn(e));
|
|
455
|
+
return { window, byId, html, store, calls, key, focused };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const FEEDS = (ms, goals = [goal(1)], rollups = [rollup(1, { done: 2, active: 1 })]) => ({
|
|
459
|
+
'/api/bongos/government/docket': docket(ms),
|
|
460
|
+
'/api/bongos/goals': { goals },
|
|
461
|
+
'/api/bongos/goals/rollups': { rollups },
|
|
462
|
+
'/api/bongos/builders/95/earnings': { builder_id: 95, days: [{ day: new Date().toISOString().slice(0, 10), kind: 'task', credits: 24 }], totals: {}, total: 24 },
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('the page reads the four feeds and draws the docket\'s pins, the readings and the header', async () => {
|
|
466
|
+
const ms = [matter({ id: 'board:1', building: 'charter', state: 'needs' }), matter({ id: 'blockers:2', state: 'moving' })];
|
|
467
|
+
const p = await bootPage({ feeds: FEEDS(ms) });
|
|
468
|
+
assert.deepEqual([...p.calls].sort(), ['/api/bongos/builders/95/earnings', '/api/bongos/goals', '/api/bongos/goals/rollups', '/api/bongos/government/docket']);
|
|
469
|
+
const pins = p.byId.get('streetPins').children;
|
|
470
|
+
assert.deepEqual(pins.map((s) => s.className).sort(), ['street-pin street-pin--moving', 'street-pin street-pin--needs']);
|
|
471
|
+
assert.equal(pins.find((s) => s.attrs['data-matter'] === 'board:1').children[1].children[0].className, 'street-pin__ring', 'a needs pin pulses');
|
|
472
|
+
assert.equal(p.byId.get('streetSigns').children.length, 6);
|
|
473
|
+
assert.ok(p.byId.get('streetSigns').children.every((a) => /^\/builders\/street#building=/.test(a.attrs.href)));
|
|
474
|
+
assert.equal(p.byId.get('hudNeeds').textContent, '1');
|
|
475
|
+
assert.equal(p.byId.get('hudMoving').textContent, '1');
|
|
476
|
+
assert.equal(p.byId.get('hudShare').textContent, '$24');
|
|
477
|
+
assert.equal(p.byId.get('hudDot').className, 'street-hud__dot street-hud__dot--hot');
|
|
478
|
+
assert.equal(p.byId.get('streetDoors').children.length, 10, 'nine buildings and one district, each a door');
|
|
479
|
+
assert.equal(p.byId.get('streetHome').attrs.href, '/builders/city');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('a pin opens its short card, Escape closes it and focus goes back to the pin', async () => {
|
|
483
|
+
const p = await bootPage({ feeds: FEEDS([matter({ id: 'board:1', building: 'charter', title: '<img src=x onerror=alert(1)>', href: '/builders/board-room' })]) });
|
|
484
|
+
const pin = p.byId.get('streetPins').children[0];
|
|
485
|
+
pin.fire('click');
|
|
486
|
+
const card = p.byId.get('streetCard');
|
|
487
|
+
assert.equal(card.hidden, false);
|
|
488
|
+
assert.equal(p.byId.get('streetCardTitle').textContent, '<img src=x onerror=alert(1)>', 'docket text lands as text');
|
|
489
|
+
assert.equal(p.byId.get('streetCardOpen').attrs.href, '/builders/board-room');
|
|
490
|
+
assert.equal(p.focused.el, p.byId.get('streetCardClose'));
|
|
491
|
+
assert.equal(p.byId.get('streetPins').children[0].attrs['aria-expanded'], 'true');
|
|
492
|
+
const e = { key: 'Escape', prevented: false, preventDefault() { this.prevented = true; } };
|
|
493
|
+
p.key('keydown', e);
|
|
494
|
+
assert.equal(card.hidden, true);
|
|
495
|
+
assert.equal(e.prevented, true, 'the press is used, so nothing else answers it');
|
|
496
|
+
assert.equal(p.focused.el.attrs['data-matter'], 'board:1');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('the switch reads and writes the hall\'s own theme preference and redraws the street', async () => {
|
|
500
|
+
const p = await bootPage({ feeds: FEEDS([]), stored: 'light', osDark: true });
|
|
501
|
+
assert.equal(p.html.attrs['data-theme'], 'light', 'the stored hall choice beats the OS');
|
|
502
|
+
assert.equal(p.byId.get('streetMode').attrs['aria-label'], 'Switch to dark mode');
|
|
503
|
+
assert.equal(p.byId.get('streetModeWord').textContent, 'dark');
|
|
504
|
+
const before = JSON.stringify(p.byId.get('streetBlds').children[0].children[0].attrs);
|
|
505
|
+
p.byId.get('streetMode').fire('click');
|
|
506
|
+
assert.equal(p.store.get('hall-theme'), 'dark');
|
|
507
|
+
assert.equal(p.html.attrs['data-theme'], 'dark');
|
|
508
|
+
assert.notEqual(JSON.stringify(p.byId.get('streetBlds').children[0].children[0].attrs), before, 'the street redraws in the new mode');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test('arriving through a door marks that building, and a failed docket says so', async () => {
|
|
512
|
+
const p = await bootPage({ feeds: FEEDS([]), hash: '#building=treasury' });
|
|
513
|
+
const here = p.byId.get('streetDoors').children.filter((a) => a.attrs['aria-current'] === 'location');
|
|
514
|
+
assert.deepEqual(here.map((a) => a.attrs['data-door']), ['treasury']);
|
|
515
|
+
const feeds = FEEDS([]);
|
|
516
|
+
delete feeds['/api/bongos/government/docket'];
|
|
517
|
+
const q = await bootPage({ feeds });
|
|
518
|
+
assert.equal(q.byId.get('streetPins').children.length, 0);
|
|
519
|
+
assert.ok(!q.calls.some((c) => /earnings/.test(c)), 'no viewer, no earnings read');
|
|
520
|
+
assert.equal(q.byId.get('hudShare').textContent, '—');
|
|
521
|
+
});
|