@bongos/core 1.21.33 → 1.21.34
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 +35 -25
- package/docs/copy-inventory.md +19 -17
- package/docs/copy-registry.json +39 -21
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +5 -1
- package/docs/page-readings.json +267 -198
- package/modules/hall-ui/public/street-desk.js +285 -0
- package/modules/hall-ui/public/street-inside.js +8 -4
- package/modules/hall-ui/public/street.css +59 -0
- package/modules/hall-ui/public/street.html +9 -3
- package/modules/hall-ui/public/street.js +219 -17
- package/modules/hall-ui/public/street.states.json +83 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/hall_matter_card.mjs +1 -1
- package/tests/hall_street.mjs +1 -1
- package/tests/hall_street_amend.mjs +1 -1
- package/tests/hall_street_desk.mjs +328 -0
- package/tests/hall_street_inside.mjs +1 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// tests/hall_street_desk.mjs — THE FLOOR DESK AND THE LINE RULE (task 1004558,
|
|
2
|
+
// goal 1000125; round 2 of the Governor City).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT GUARDS. The chosen floor of a building is the desk from the round 2
|
|
5
|
+
// board (docs/design/mocks/governor-city/Desks.dc.html), built by
|
|
6
|
+
// GovDesk.deskModel() (street-desk.js) and drawn by street.js. What can go wrong:
|
|
7
|
+
// * THE LINE RULE breaking on real data: a row name over three words, a
|
|
8
|
+
// second fact, a third button, a tile that reads zero or carries a
|
|
9
|
+
// sentence, a status that is orange when nothing needs you, an empty list
|
|
10
|
+
// where a quiet floor should be one lit line. Pinned by running EVERY
|
|
11
|
+
// building and EVERY floor of a deliberately long-winded docket through
|
|
12
|
+
// GovDesk.breaks() — the test that fails when the docket produces a line
|
|
13
|
+
// the rule does not allow;
|
|
14
|
+
// * fit() drifting from the board's own fit() (run from the board's
|
|
15
|
+
// Component class) or the limits drifting from the board's numbers;
|
|
16
|
+
// * a row's button sending anything but the matter card's own request to
|
|
17
|
+
// the existing route, or a reason arriving as free text instead of a chip;
|
|
18
|
+
// * the game layer going quiet: the act that does not clear its row, count a
|
|
19
|
+
// pip on the today meter, light the cleared floor with a tick, or say a toast.
|
|
20
|
+
//
|
|
21
|
+
// Run: node --test tests/hall_street_desk.mjs
|
|
22
|
+
|
|
23
|
+
import { test } from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import vm from 'node:vm';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
require(path.join(PUB, 'city-draw.js'));
|
|
35
|
+
const S = require(path.join(PUB, 'street-draw.js'));
|
|
36
|
+
const I = require(path.join(PUB, 'street-inside.js'));
|
|
37
|
+
const C = require(path.join(PUB, 'street-card.js'));
|
|
38
|
+
const D = require(path.join(PUB, 'street-desk.js'));
|
|
39
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
40
|
+
const NOW = Date.parse('2026-10-03T12:00:00.000Z');
|
|
41
|
+
const j = (v) => JSON.parse(JSON.stringify(v));
|
|
42
|
+
|
|
43
|
+
// the board's own Component class, run beside the desk
|
|
44
|
+
function board() {
|
|
45
|
+
const src = fs.readFileSync(path.join(ROOT, 'docs/design/mocks/governor-city/Desks.dc.html'), 'utf8');
|
|
46
|
+
const script = src.split('data-dc-script')[1].split('>').slice(1).join('>').split('</script>')[0];
|
|
47
|
+
const DCLogic = class { constructor(p) { this.props = p || {}; this.state = {}; } setState(s) { Object.assign(this.state, s); } };
|
|
48
|
+
const Comp = new Function('DCLogic', 'setTimeout', 'clearTimeout', `${script}\nreturn Component;`)(DCLogic, () => 0, () => {});
|
|
49
|
+
return new Comp({ mode: 'dark' });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── a long-winded docket: every source, long words everywhere ─────────────────
|
|
53
|
+
const LONG = 'The extraordinarily long-winded proposal to reorganise every single floor of the hall before winter';
|
|
54
|
+
const SOURCES = [['board', 'charter'], ['blockers', 'planning'], ['access_requests', 'people'], ['goal_join_requests', 'people'], ['override_requests', 'release'], ['removal_proposals', 'disputes'], ['redteam_reports', 'disputes'], ['criteria_uat', 'review'], ['core_update', 'release'], ['gate_approvals', 'release'], ['autobongos_fence', 'automation'], ['module_rechecks', 'release'], ['grade_appeals', 'disputes'], ['standing_rules', 'rules'], ['treasury_spending', 'treasury']];
|
|
55
|
+
function matter(over = {}) {
|
|
56
|
+
return { id: 'blockers:1', source: 'blockers', building: 'planning', rule: 'majority', state: 'needs', title: 'A matter', short: 'a matter', where: 'Planning', opened_at: '2026-09-29T10:00:00.000Z', deadline: null, proposer: null, tally: null, href: null, ref: null, ...over };
|
|
57
|
+
}
|
|
58
|
+
function docket(matters, over = {}) {
|
|
59
|
+
const counts = { needs: 0, moving: 0, settled: 0 };
|
|
60
|
+
matters.forEach((m) => { counts[m.state] += 1; });
|
|
61
|
+
return {
|
|
62
|
+
viewer: { id: '95' }, counts, line: '', matters,
|
|
63
|
+
buildings: S.CIVIC_KEYS.map((key) => ({ key, name: key[0].toUpperCase() + key.slice(1), group: '', counts: {}, matter_ids: [] })),
|
|
64
|
+
sources: SOURCES.map(([source, building]) => ({ source, building, label: source, status: 'live', count: 0, reason: null })),
|
|
65
|
+
readings: ['flow', 'promises', 'money', 'people', 'fairness', 'rules'].map((key) => ({ key, label: key, building: 'people', status: 'live', value: 0, figure: key === 'money' ? '$1,234,567.89 left this month' : '0 people', line: LONG, hot: false, reason: null })),
|
|
66
|
+
floors: Object.values(I.PLAN).flatMap((p) => p.floors).map((f) => f[2] && f[2].floor).filter(Boolean).map((key, i) => ({ key, building: key.split('.')[0], status: 'live', count: i % 3 === 0 ? '0' : i % 3 === 1 ? '1,204,331' : '', line: i % 2 ? LONG : '12 things that the floor counts', lines: [[LONG, 'today']], hot: i % 4 === 0, reason: null })),
|
|
67
|
+
history: S.CIVIC_KEYS.flatMap((b) => [0, 1, 2, 3, 4].map((n) => ({ id: `h:${b}:${n}`, source: 'government', building: b, title: LONG, at: '2026-09-2' + n + 'T10:00:00.000Z', outcome: 'passed after a very long sitting' }))),
|
|
68
|
+
...over,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function longMatters() {
|
|
72
|
+
const out = [];
|
|
73
|
+
let n = 0;
|
|
74
|
+
for (const [source, building] of SOURCES) {
|
|
75
|
+
for (const state of ['needs', 'moving', 'settled']) {
|
|
76
|
+
n += 1;
|
|
77
|
+
out.push(matter({ id: `${source}:${n}`, source, building, state, title: `${LONG} ${n}`, short: 'a matter with a very long short description', where: 'Somewhere far away in the hall, vouched for by everyone', proposer: { name: 'Maximiliana Theodora Alexandrovna-Wellington III', login: 'maxi' }, deadline: '2026-10-05T10:00:00.000Z', opened_at: `2026-0${(n % 9) + 1}-1${n % 9}T10:00:00.000Z`, href: source === 'goal_join_requests' ? '/builders/goal/42' : source === 'core_update' ? '/settings#software-update' : null, tally: source === 'board' ? { open: 3, needs: 'needs 4 of 5' } : null }));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
out.push(matter({ id: 'board:900', source: 'board', building: 'planning', short: 'idea to ratify', title: LONG, state: 'needs' }));
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
const ROOM = { open: [{ id: '1', subject_type: 'constitutional_amendment', subject: { title: LONG, author_id: '7' }, votes: [] }, { id: '900', subject_type: 'full_idea', subject: { title: LONG }, votes: [] }], closed: [] };
|
|
84
|
+
|
|
85
|
+
function everyDesk(dk, extra = {}) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const key of S.CIVIC_KEYS) {
|
|
88
|
+
I.PLAN[key].floors.forEach((_f, floor) => out.push(D.deskModel({ key, floor, docket: dk, goals: [{ id: '1', title: LONG, status: 'open' }], now: NOW, room: ROOM, viewerId: '95', ...extra })));
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── the line rule ─────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
test('fit() is the board\'s fit(), with a word cap on top', () => {
|
|
96
|
+
const b = board();
|
|
97
|
+
for (const [s, n] of [['Governor city', 20], [LONG, 20], [LONG, 44], ['Ana Ruiz', 18], ['exactly twenty chars', 20], [' spaced out ', 30]]) {
|
|
98
|
+
assert.equal(D.fit(s, n), b.fit(String(s).replace(/\s+/g, ' ').trim(), n), `${s} @ ${n}`);
|
|
99
|
+
}
|
|
100
|
+
assert.equal(D.fit('Raise the hosting limit', 20, 3), 'Raise the hosting…', 'a cut by words says so');
|
|
101
|
+
assert.equal(D.fit('Ana Ruiz', 20, 3), 'Ana Ruiz', 'nothing cut, nothing marked');
|
|
102
|
+
for (const n of [5, 12, 20]) for (const w of [1, 2, 3]) assert.ok(D.fit(LONG, n, w).length <= n, 'never longer than its cap');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the limits are the board\'s numbers: name 20, fact 26, facts 24 (three), status 44, record 30 (four), three tiles, ten pips', () => {
|
|
106
|
+
const L = D.LIMITS;
|
|
107
|
+
assert.deepEqual([L.name, L.nameWords, L.fact, L.factLen, L.facts, L.actions, L.status, L.recordLen, L.record, L.tiles, L.tileWords, L.todayTarget], [20, 3, 26, 24, 3, 2, 44, 30, 4, 3, 2, 10]);
|
|
108
|
+
const src = fs.readFileSync(path.join(ROOT, 'docs/design/mocks/governor-city/Desks.dc.html'), 'utf8');
|
|
109
|
+
for (const pin of ['this.fit(r.t, 20)', 'this.fit(r.f, 26)', 'this.fit(t, 24)', '.slice(0, 3)', "this.fit('nothing needs you · ' + F.quiet, 44)", "this.fit(t + ' · now', 30)", '.slice(0, 4)', 'const target = 10']) assert.ok(src.includes(pin), `the board says ${pin}`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('THE LINE RULE: no row, tile or status any docket produces breaks it, on every floor of every building', () => {
|
|
113
|
+
const dk = docket(longMatters());
|
|
114
|
+
const desks = everyDesk(dk);
|
|
115
|
+
assert.equal(desks.length, S.CIVIC_KEYS.reduce((a, k) => a + I.PLAN[k].floors.length, 0));
|
|
116
|
+
const broken = desks.flatMap((d) => D.breaks(d));
|
|
117
|
+
assert.deepEqual(broken, []);
|
|
118
|
+
// the stress docket really reaches every part of the rule
|
|
119
|
+
const rows = desks.flatMap((d) => d.rows);
|
|
120
|
+
assert.ok(rows.length >= 30, 'rows were drawn');
|
|
121
|
+
assert.ok(rows.some((r) => r.actions.length === 2) && rows.some((r) => r.chips) && rows.some((r) => r.wait), 'two buttons, chips and a wait pill were all exercised');
|
|
122
|
+
assert.ok(desks.some((d) => d.quiet), 'a quiet floor was exercised');
|
|
123
|
+
assert.ok(desks.some((d) => d.tiles.length === 3), 'three tiles were exercised');
|
|
124
|
+
assert.ok(desks.some((d) => d.hot), 'an orange status was exercised');
|
|
125
|
+
const moving = everyDesk(docket(longMatters().filter((m) => m.state !== 'needs')));
|
|
126
|
+
assert.ok(moving.some((d) => !d.hot && d.rows.length), 'a grey status over rows was exercised');
|
|
127
|
+
assert.deepEqual(moving.flatMap((d) => D.breaks(d)), []);
|
|
128
|
+
assert.ok(rows.some((r) => r.name.endsWith('…')), 'a long title was cut, and says so');
|
|
129
|
+
// the same docket with acts done, floors cleared, and with nothing readable
|
|
130
|
+
assert.deepEqual(everyDesk(dk, { done: Object.fromEntries(dk.matters.map((m) => [m.id, true])), cleared: true, mine: [LONG, 'let in Ana'], today: 123456 }).flatMap((d) => D.breaks(d)), []);
|
|
131
|
+
assert.deepEqual(everyDesk(null).flatMap((d) => D.breaks(d)), []);
|
|
132
|
+
assert.deepEqual(everyDesk(docket([])).flatMap((d) => D.breaks(d)), []);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('breaks() names every way a desk can break the rule (so the rule test can fail)', () => {
|
|
136
|
+
const dk = docket(longMatters());
|
|
137
|
+
const good = D.deskModel({ key: 'people', floor: 4, docket: dk, now: NOW });
|
|
138
|
+
assert.deepEqual(D.breaks(good), []);
|
|
139
|
+
const bad = j(good);
|
|
140
|
+
bad.rows[0].name = 'Four words in here';
|
|
141
|
+
bad.rows[0].fact = 'x'.repeat(27);
|
|
142
|
+
bad.rows[0].actions = [{ label: 'a' }, { label: 'b' }, { label: 'c' }];
|
|
143
|
+
bad.rows[0].facts = ['a', 'b', 'c', 'd'];
|
|
144
|
+
bad.tiles = [{ v: '0', l: 'need you' }, { v: '3', l: 'three whole words' }, { v: '4', l: 'ok' }, { v: '5', l: 'ok' }];
|
|
145
|
+
bad.status = 'x'.repeat(45);
|
|
146
|
+
bad.hot = false;
|
|
147
|
+
const why = D.breaks(bad).join('\n');
|
|
148
|
+
for (const w of ['row name', 'row fact', 'more than two buttons', 'row facts', 'tile value', 'tile label', 'more than three tiles', 'status too long', 'orange only when']) assert.match(why, new RegExp(w), w);
|
|
149
|
+
const empty = j(good); empty.rows = []; empty.quiet = false;
|
|
150
|
+
assert.match(D.breaks(empty).join(), /quiet and rows disagree/, 'never an empty list');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── which matters sit on which floor ──────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
test('a floor holds its own source; the ideas floor holds the open ideas; the rest sit at the front desk; settled is the record', () => {
|
|
156
|
+
const ms = [
|
|
157
|
+
matter({ id: 'access_requests:1', source: 'access_requests', building: 'people', state: 'needs' }),
|
|
158
|
+
matter({ id: 'access_requests:2', source: 'access_requests', building: 'people', state: 'settled' }),
|
|
159
|
+
matter({ id: 'board:5', source: 'board', building: 'people', state: 'needs' }),
|
|
160
|
+
matter({ id: 'board:6', source: 'board', building: 'planning', short: 'idea to ratify', state: 'moving' }),
|
|
161
|
+
];
|
|
162
|
+
const dk = docket(ms);
|
|
163
|
+
const at = (k, f) => D.floorMatters(k, f, dk).map((m) => m.id);
|
|
164
|
+
assert.deepEqual(at('people', 4), ['access_requests:1'], 'Waiting to join: the open request only');
|
|
165
|
+
assert.deepEqual(at('people', 0), ['board:5'], 'a sitting sits at the front desk');
|
|
166
|
+
assert.deepEqual(at('planning', 2), ['board:6'], 'Ideas to ratify');
|
|
167
|
+
assert.deepEqual(at('planning', 0), [], 'an idea is not also at the front desk');
|
|
168
|
+
assert.equal(D.firstNeeds('people', dk), 0);
|
|
169
|
+
assert.equal(D.firstNeeds('rules', dk), null);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── the acts are the card's own requests ──────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
test('a row\'s buttons are the card\'s acts under the board\'s words, and send the card\'s own request', () => {
|
|
175
|
+
const ms = [
|
|
176
|
+
matter({ id: 'access_requests:11', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Ana Ruiz' }, ref: { id: 11 } }),
|
|
177
|
+
matter({ id: 'goal_join_requests:12', source: 'goal_join_requests', building: 'people', state: 'needs', proposer: { name: 'Kai Mo' }, href: '/builders/goal/42', ref: { id: 12 } }),
|
|
178
|
+
matter({ id: 'override_requests:13', source: 'override_requests', building: 'release', state: 'needs', ref: { id: 13 } }),
|
|
179
|
+
matter({ id: 'gate_approvals:14', source: 'gate_approvals', building: 'release', state: 'needs', ref: { id: 14 } }),
|
|
180
|
+
matter({ id: 'board:1', source: 'board', building: 'charter', state: 'needs', ref: { type: 'board_item', id: '1' } }),
|
|
181
|
+
];
|
|
182
|
+
const dk = docket(ms);
|
|
183
|
+
const row = (k, f) => D.rowsFor(k, f, dk, { room: ROOM, now: NOW, viewerId: '95' })[0];
|
|
184
|
+
const ana = row('people', 4);
|
|
185
|
+
assert.equal(ana.name, 'Ana Ruiz', 'a person is named by their name');
|
|
186
|
+
assert.deepEqual(ana.actions.map((a) => [a.key, a.label, a.tone]), [['fine', 'let in', 'primary'], ['refuse', 'not now', 'quiet']]);
|
|
187
|
+
assert.deepEqual(j(C.request(ana.card, 'fine')), { method: 'PATCH', url: '/api/bongos/access-requests/11', body: { status: 'invited' } });
|
|
188
|
+
assert.deepEqual(j(C.request(row('people', 6).card, 'refuse')), { method: 'POST', url: '/api/bongos/goals/42/requests/12/respond', body: { action: 'deny' } });
|
|
189
|
+
const rel = D.rowsFor('release', 0, dk, { now: NOW });
|
|
190
|
+
assert.deepEqual(rel[0].actions.map((a) => a.label), ['release', 'hold']);
|
|
191
|
+
assert.equal(C.request(rel[0].card, 'release').url, '/api/bongos/override-requests/13/decide');
|
|
192
|
+
assert.equal(C.request(D.rowsFor('release', 5, dk, { now: NOW })[0].card, 'release').url, '/api/bongos/gate-approvals/14/approve');
|
|
193
|
+
// a vote: yes and no, and the no's reason is a chip, never free text
|
|
194
|
+
const vote = row('charter', 0);
|
|
195
|
+
assert.deepEqual(vote.actions.map((a) => [a.key, a.label]), [['for', 'yes'], ['object', 'no']]);
|
|
196
|
+
assert.equal(vote.chips.for, 'object');
|
|
197
|
+
assert.deepEqual(vote.chips.options.map((c) => c.key), C.SECTIONS.map((s) => s.key), 'every chip is a section the server accepts');
|
|
198
|
+
const chip = vote.chips.options[0];
|
|
199
|
+
assert.equal(C.check(vote.card, 'object', chip.input), null, 'one tap is a complete objection');
|
|
200
|
+
assert.deepEqual(j(C.request(vote.card, 'object', chip.input).body), { direction: 'no', reason_md: chip.input.reason, section_key: 'scope' });
|
|
201
|
+
// before the sitting is read the row waits, never guesses a ballot
|
|
202
|
+
assert.equal(D.rowsFor('charter', 0, dk, { now: NOW })[0].wait, 'reading…');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ── the page: the desk acts in place, and the game layer answers ──────────────
|
|
206
|
+
|
|
207
|
+
function fakeDom() {
|
|
208
|
+
const byId = new Map();
|
|
209
|
+
const focused = { el: null };
|
|
210
|
+
const mk = (tag) => ({
|
|
211
|
+
tagName: tag, attrs: {}, kids: [], className: '', listeners: {}, _text: '', hidden: false,
|
|
212
|
+
style: { setProperty(k, v) { this[k] = v; } },
|
|
213
|
+
clientWidth: 1440, clientHeight: 708, offsetLeft: 0, scrollLeft: 0, parentNode: null,
|
|
214
|
+
get children() { const a = this.kids.slice(); a[Symbol.isConcatSpreadable] = false; return a; },
|
|
215
|
+
get firstChild() { return this.kids[0] || null; },
|
|
216
|
+
setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'id') byId.set(String(v), this); },
|
|
217
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
|
|
218
|
+
appendChild(c) { this.kids.push(c); c.parentNode = this; return c; },
|
|
219
|
+
removeChild(c) { this.kids = this.kids.filter((x) => x !== c); return c; },
|
|
220
|
+
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
|
|
221
|
+
fire(t) { (this.listeners[t] || []).forEach((fn) => fn({ type: t, preventDefault() {} })); },
|
|
222
|
+
focus() { focused.el = this; },
|
|
223
|
+
set textContent(v) { this._text = String(v); this.kids = []; },
|
|
224
|
+
get textContent() { return this._text; },
|
|
225
|
+
get innerHTML() { throw new Error('no innerHTML'); },
|
|
226
|
+
set innerHTML(v) { throw new Error('no innerHTML'); },
|
|
227
|
+
});
|
|
228
|
+
const html = read('street.html').replace(/<!--[\s\S]*?-->/g, '');
|
|
229
|
+
for (const id of [...html.matchAll(/id="([A-Za-z]+)"/g)].map((m) => m[1])) {
|
|
230
|
+
const el = mk('x');
|
|
231
|
+
el.setAttribute('id', id);
|
|
232
|
+
const tag = new RegExp(`<[^>]*\\bid="${id}"[^>]*>`).exec(html);
|
|
233
|
+
if (tag && /\shidden(?=[\s>])/.test(tag[0])) el.hidden = true;
|
|
234
|
+
}
|
|
235
|
+
return { document: { documentElement: mk('html'), getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_n, t) => mk(t) }, byId, focused };
|
|
236
|
+
}
|
|
237
|
+
async function bootPage(hash, ms, { refuse = false } = {}) {
|
|
238
|
+
const { document, byId, focused } = fakeDom();
|
|
239
|
+
const sent = [];
|
|
240
|
+
const store = new Map();
|
|
241
|
+
let matters = ms.slice();
|
|
242
|
+
const feeds = () => ({ '/api/bongos/government/docket': docket(matters), '/api/bongos/goals': { goals: [] }, '/api/bongos/goals/rollups': { rollups: [] } });
|
|
243
|
+
const location = { hash, href: '' };
|
|
244
|
+
const window = {
|
|
245
|
+
innerWidth: 1440, innerHeight: 900, location,
|
|
246
|
+
localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
|
|
247
|
+
matchMedia: () => ({ matches: true, addEventListener() {} }),
|
|
248
|
+
addEventListener() {}, history: { pushState() {} },
|
|
249
|
+
};
|
|
250
|
+
const fetch = (url, init) => {
|
|
251
|
+
if (init && init.method && init.method !== 'GET') {
|
|
252
|
+
sent.push({ method: init.method, url, body: JSON.parse(init.body || '{}') });
|
|
253
|
+
if (refuse) return Promise.resolve({ ok: false, status: 409, json: async () => ({ error: { code: 'not_pending' } }) });
|
|
254
|
+
matters = matters.filter((m) => !url.includes('/' + String(m.id).split(':')[1]));
|
|
255
|
+
return Promise.resolve({ ok: true, status: 200, json: async () => ({ ok: true }) });
|
|
256
|
+
}
|
|
257
|
+
const body = feeds()[url];
|
|
258
|
+
return Promise.resolve(body === undefined ? { ok: false, json: async () => ({}) } : { ok: true, json: async () => body });
|
|
259
|
+
};
|
|
260
|
+
const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map, Set });
|
|
261
|
+
ctx.globalThis = ctx;
|
|
262
|
+
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
263
|
+
await window.GovStreetPage.ready;
|
|
264
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
265
|
+
const deskEl = () => (byId.get('insideOpenA').hidden ? byId.get('insideOpenB') : byId.get('insideOpenA'));
|
|
266
|
+
const part = (cls) => deskEl().children.find((c) => c.className.split(' ').includes(cls));
|
|
267
|
+
return { window, byId, focused, sent, store, deskEl, part };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
test('the page: the desk acts in place — the row clears, the floor lights with a tick, the meter takes a pip, the toast speaks', async () => {
|
|
271
|
+
const ms = [matter({ id: 'access_requests:11', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Ana Ruiz' }, ref: { id: 11 }, opened_at: '2026-10-01T12:00:00.000Z' })];
|
|
272
|
+
const p = await bootPage('#building=people', ms);
|
|
273
|
+
const html = read('street.html');
|
|
274
|
+
assert.ok(html.indexOf('/builders/street-desk.js?') > html.indexOf('/builders/street-inside.js?') && html.indexOf('/builders/street-desk.js?') < html.indexOf('/builders/street.js?'), 'the desk loads after the interior and before the page');
|
|
275
|
+
assert.equal(p.byId.get('insideFloors').children[4].attrs['aria-pressed'], 'true', 'the building opens on the floor that needs you');
|
|
276
|
+
const head = p.deskEl().children[0].children[1];
|
|
277
|
+
assert.deepEqual([head.children[1].textContent, head.children[2].textContent, head.children[2].className], ['Waiting to join', '1 waiting · oldest 2 days', 'desk-status desk-status--hot']);
|
|
278
|
+
assert.deepEqual(p.part('desk-tiles').children.map((t) => [t.children[0].textContent, t.children[1].textContent]), [['1', 'waiting'], ['5', 'on record']], 'the first tile is the count in the floor noun, as on the board; a zero tile (nothing moving) is hidden');
|
|
279
|
+
const row = p.part('desk-rows').children[0];
|
|
280
|
+
const name = row.children[0].children[1];
|
|
281
|
+
assert.equal(name.children[0].textContent, 'Ana Ruiz');
|
|
282
|
+
name.fire('click');
|
|
283
|
+
const opened = p.part('desk-rows').children[0];
|
|
284
|
+
assert.equal(opened.children[0].children[1].attrs['aria-expanded'], 'true');
|
|
285
|
+
assert.ok(opened.children[1].children.length >= 1 && opened.children[1].children.length <= 3, 'at most three facts open in place');
|
|
286
|
+
const acts = opened.children[0].children[2].children;
|
|
287
|
+
assert.deepEqual(acts.map((b) => b.textContent), ['not now', 'let in'], 'the primary sits rightmost');
|
|
288
|
+
acts[1].fire('click');
|
|
289
|
+
for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
|
|
290
|
+
assert.deepEqual(p.sent, [{ method: 'PATCH', url: '/api/bongos/access-requests/11', body: { status: 'invited' } }], 'the card\'s own route, nothing else');
|
|
291
|
+
assert.match(p.byId.get('streetToast').textContent, /Ana Ruiz is invited in/);
|
|
292
|
+
assert.equal(p.byId.get('streetToast').hidden, false);
|
|
293
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '1 decided');
|
|
294
|
+
assert.equal(p.byId.get('streetTodayPips').children.filter((x) => x.className.includes('--on')).length, 1, 'one pip per decision');
|
|
295
|
+
assert.equal(JSON.parse(p.store.get('gov-desk-today')).n, 1, 'the day keeps its count');
|
|
296
|
+
const quiet = p.part('desk-quiet');
|
|
297
|
+
assert.ok(quiet && quiet.className.includes('desk-quiet--cleared'), 'a cleared floor is one lit line, never an empty list');
|
|
298
|
+
assert.deepEqual(quiet.children.slice(1).map((x) => x.textContent), ['Everyone is in', 'lit up · 1 decided today']);
|
|
299
|
+
assert.equal(p.part('desk-rows'), undefined);
|
|
300
|
+
assert.equal(p.part('desk-record').children[1].textContent, 'let in Ana Ruiz · now', 'the record line starts with what you did');
|
|
301
|
+
assert.equal(p.byId.get('insideFloors').children[4].attrs['aria-pressed'], 'true', 'you stay on the floor you cleared');
|
|
302
|
+
// the drawing: the cleared floor's windows lit and its tick drawn
|
|
303
|
+
const bv = I.buildingModel({ key: 'people', docket: docket([]), goals: [], mode: 'dark', now: NOW, floor: 4, cleared: [4] });
|
|
304
|
+
const plain = I.buildingModel({ key: 'people', docket: docket([]), goals: [], mode: 'dark', now: NOW, floor: 0 });
|
|
305
|
+
assert.ok(bv.shapes.some((s) => /l6 6 12-13$/.test(s.d)), 'a tick on the cleared floor');
|
|
306
|
+
assert.ok(!plain.shapes.some((s) => /l6 6 12-13$/.test(s.d)), 'no tick on a floor nobody cleared');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('the page: a refused act keeps the row and says why; no pip is counted', async () => {
|
|
310
|
+
const ms = [matter({ id: 'access_requests:11', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Ana Ruiz' }, ref: { id: 11 } })];
|
|
311
|
+
const p = await bootPage('#building=people', ms, { refuse: true });
|
|
312
|
+
p.part('desk-rows').children[0].children[0].children[2].children[1].fire('click');
|
|
313
|
+
for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
|
|
314
|
+
assert.equal(p.byId.get('streetToast').textContent, 'Someone already decided this.');
|
|
315
|
+
assert.equal(p.part('desk-rows').children.length, 1);
|
|
316
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '0 decided');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('the stylesheet: the desk in tokens only, its motion dies under reduced motion, the chips clear 24px', () => {
|
|
320
|
+
const css = read('street.css').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
321
|
+
const desk = css.split('\n').filter((l) => /^\.(desk|street-today)/.test(l)).join('\n');
|
|
322
|
+
assert.ok(desk.length > 0);
|
|
323
|
+
assert.doesNotMatch(desk, /#[0-9a-f]{3,8}\b|rgba?\(/i, 'no literal colours on the desk');
|
|
324
|
+
assert.match(css, /\.desk-chip \{[^}]*height: 34px/);
|
|
325
|
+
assert.match(css, /\.desk-row--gone \{[^}]*animation: desk-gone/);
|
|
326
|
+
assert.match(css, /@media \(prefers-reduced-motion: reduce\) \{[^}]*animation: none !important; transition: none !important;/);
|
|
327
|
+
assert.match(read('street.html'), /id="streetTodayPips"/);
|
|
328
|
+
});
|
|
@@ -508,7 +508,7 @@ async function bootPage({ hash = '', feeds = {}, innerWidth = 1440 } = {}) {
|
|
|
508
508
|
};
|
|
509
509
|
const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map });
|
|
510
510
|
ctx.globalThis = ctx;
|
|
511
|
-
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
511
|
+
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
512
512
|
await window.GovStreetPage.ready;
|
|
513
513
|
await new Promise((r) => setTimeout(r, 0));
|
|
514
514
|
const fireWin = (t, e) => (listeners[t] || []).forEach((fn) => fn(e));
|