@bongos/core 1.21.12 → 1.21.13
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 +42 -22
- package/docs/copy-inventory.md +34 -34
- package/docs/copy-registry.json +38 -38
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +11 -3
- package/docs/page-readings.json +88 -55
- package/modules/hall-ui/public/board-room.js +9 -0
- package/modules/hall-ui/public/street-card.css +98 -0
- package/modules/hall-ui/public/street-card.js +697 -0
- package/modules/hall-ui/public/street-card.states.json +191 -0
- package/modules/hall-ui/public/street.html +5 -3
- package/modules/hall-ui/public/street.js +107 -17
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/hall-preview/server.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/hall_matter_card.mjs +387 -0
- package/tests/hall_street.mjs +3 -2
- package/tests/hall_street_inside.mjs +1 -1
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// tests/hall_matter_card.mjs — THE MATTER CARD THAT ACTS (task 1004528, goal
|
|
2
|
+
// 1000125).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT GUARDS. A pin on the street opens the prototype's full card()
|
|
5
|
+
// (docs/design/mocks/governor-city/Street.dc.html), built by street-card.js over
|
|
6
|
+
// the docket, the Board Room's read and the constitution in force, and its acts
|
|
7
|
+
// go to the EXISTING routes. What can go wrong:
|
|
8
|
+
// * the card says something the ballot does not (a seat, a tally, a voice, a
|
|
9
|
+
// counsel note, a wording change) or offers an act that is not the viewer's;
|
|
10
|
+
// * an act reaches a route other than the one that does it, with a body that
|
|
11
|
+
// route refuses (a yes with a reason, an objection with no section), or a
|
|
12
|
+
// "release" moves the live core pin from a card;
|
|
13
|
+
// * an act that records nothing (abstain, hold) sends a request anyway;
|
|
14
|
+
// * after an act the street does not answer: no toast, no re-read, no
|
|
15
|
+
// building that rises or lights, no evening when nothing waits;
|
|
16
|
+
// * the card reaches innerHTML, or its motion escapes the Kill Switch.
|
|
17
|
+
//
|
|
18
|
+
// Run: node tests/hall_matter_card.mjs
|
|
19
|
+
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import vm from 'node:vm';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
require(path.join(PUB, 'city-draw.js'));
|
|
32
|
+
require(path.join(PUB, 'street-draw.js'));
|
|
33
|
+
const C = require(path.join(PUB, 'street-card.js'));
|
|
34
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
35
|
+
const NOW = Date.parse('2026-10-02T12:00:00.000Z');
|
|
36
|
+
const VIEWER = '95';
|
|
37
|
+
|
|
38
|
+
// ── fixtures in the routes' own shapes ──────────────────────────────────────
|
|
39
|
+
// docket matter: modules/government/docket.js normalizeMatter / boardMatter
|
|
40
|
+
function matter(over = {}) {
|
|
41
|
+
return { id: 'board:41', source: 'board', building: 'charter', rule: 'unanimous', state: 'needs', title: 'Give Review a seat', short: 'amendment', where: 'charter · amendment', opened_at: '2026-10-01T09:00:00.000Z', deadline: '2026-10-03T09:00:00.000Z', proposer: { id: '4', login: 'ana', name: 'Ana Ruiz' }, tally: { for: 3, against: 0, open: 2, needs: 'needs everyone who votes' }, href: '/board-room?item=41', ref: { type: 'board_item', id: '41' }, ...over };
|
|
42
|
+
}
|
|
43
|
+
function docket(ms, over = {}) {
|
|
44
|
+
const counts = { needs: 0, moving: 0, settled: 0 };
|
|
45
|
+
ms.forEach((m) => { counts[m.state] += 1; });
|
|
46
|
+
return {
|
|
47
|
+
viewer: { id: VIEWER }, counts, line: '', matters: ms, readings: [], sources: [],
|
|
48
|
+
buildings: [{ key: 'review', counts: {}, matter_ids: [], decision: { who: 'whoever may mark work as meeting its checks', rule: 'decide', rule_words: 'decides alone', as_today: true, sitting: false } }],
|
|
49
|
+
vocabulary: {}, ...over,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const vote = (id, name, direction, over = {}) => ({ voter_id: String(id), github_login: name.toLowerCase(), display_name: name, direction, reason_md: null, section_key: null, cast_at: '2026-10-01T10:00:00.000Z', ...over });
|
|
53
|
+
// board item: board.js boardRoomView project()
|
|
54
|
+
function item(over = {}) {
|
|
55
|
+
return {
|
|
56
|
+
id: '41', subject_type: 'constitutional_amendment', subject_id: '7',
|
|
57
|
+
subject: { kind: 'decision_rules', proposed: { decision_rules: [{ area: 'review', decided_by: 'rank:tester', rule: 'signoff', who: 'the testers' }] }, rationale_md: 'Three pieces of finished work are waiting because no one is allowed to test them.\n\nMore below.', author_id: '4' },
|
|
58
|
+
opened_by: '4', opened_at: '2026-10-01T09:00:00.000Z', closes_at: '2026-10-03T09:00:00.000Z', remaining_seconds: 3600, expired: false, closed_at: null, outcome: null,
|
|
59
|
+
constitution: { membership: 'rank:metic+', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true },
|
|
60
|
+
votes: [vote(4, 'Ana Ruiz', 'yes'), vote(5, 'Theo', 'yes'), vote(6, 'Mira', 'yes')],
|
|
61
|
+
...over,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const room = (open = [], closed = []) => ({ viewer_id: VIEWER, open, closed });
|
|
65
|
+
const card = (ms, key, r = null, cons = null) => C.model({ docket: docket(ms), key, room: r, constitution: cons, now: NOW });
|
|
66
|
+
|
|
67
|
+
// ── the card for each rule ──────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
test('an open unanimous sitting that waits on you: the proposer\'s words, the change, the seats, counsel and the three acts', () => {
|
|
70
|
+
const cv = card([matter()], 'board:41', room([item()]));
|
|
71
|
+
assert.deepEqual([cv.where, cv.when, cv.title], ['charter · amendment', 'sat', 'Give Review a seat']);
|
|
72
|
+
assert.deepEqual(cv.quote, { by: 'Ana', words: 'Three pieces of finished work are waiting because no one is allowed to test them.' }, 'the first paragraph, in their own words');
|
|
73
|
+
assert.deepEqual(cv.change.rows, [{ label: 'Review', struck: 'whoever may mark work as meeting its checks, decides alone', inserted: 'the testers, signed off by someone who did not build it' }]);
|
|
74
|
+
assert.deepEqual(cv.seats.map((s) => [s.init, s.pos]), [['A', 'for'], ['T', 'for'], ['M', 'for'], ['you', 'you'], ['', 'open']], 'three for, your seat, one more open');
|
|
75
|
+
assert.equal(cv.tally, '3 for · 0 against · needs everyone who votes');
|
|
76
|
+
assert.match(cv.counsel.words, /^Everyone who votes has to agree, so one objection sends it back to Ana/);
|
|
77
|
+
assert.equal(cv.counsel.revise, false);
|
|
78
|
+
assert.deepEqual(cv.actions.map((a) => [a.key, a.label, a.tone]), [['for', 'vote for', 'primary'], ['object', 'object', 'quiet']]);
|
|
79
|
+
assert.deepEqual(cv.textActions.map((a) => a.key), ['abstain']);
|
|
80
|
+
assert.ok(cv.objectForm.sections.length >= 3);
|
|
81
|
+
assert.equal(cv.readOnly, false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a majority sitting with an objection, after your yes: voices for and against, what it still needs', () => {
|
|
85
|
+
const it = item({ constitution: { membership: 'rank:metic+', pass_rule: 'majority', window_minutes: 1440, close_early_on_full_turnout: true }, votes: [vote(4, 'Ana Ruiz', 'yes'), vote(95, 'You', 'yes'), vote(7, 'Jun', 'no', { reason_md: 'Testers are volunteers. If they go quiet, nothing ships.', section_key: 'scope' })] });
|
|
86
|
+
const cv = card([matter({ rule: 'majority', state: 'moving', tally: { for: 2, against: 1, open: 2, needs: 'needs more than half' } })], 'board:41', room([it]));
|
|
87
|
+
assert.deepEqual(cv.voices, [{ side: 'for', by: 'for', words: 'Ana and You' }, { side: 'against', by: 'Jun · objects to what it changes', words: 'Testers are volunteers. If they go quiet, nothing ships.' }]);
|
|
88
|
+
assert.equal(cv.seats.find((s) => s.key === 'you').pos, 'for', 'your cast seat is filled');
|
|
89
|
+
assert.equal(cv.result, 'You voted for. It needs 1 more yes.', '5 seats: 3 carries it, 2 are in');
|
|
90
|
+
assert.match(cv.counsel.words, /^Jun objects to what it changes\. Under a majority/);
|
|
91
|
+
assert.deepEqual(cv.actions, [], 'no second vote');
|
|
92
|
+
assert.equal(cv.readOnly, true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a sitting that came back to you: counsel answers the objection, revise and sit again, and the revision struck and inserted', () => {
|
|
96
|
+
const earlier = item({ id: '30', subject_type: 'review_matter', subject: { area: 'review', title: 'Testers sign off', detail_md: 'Testers sign off finished work.', author_id: VIEWER }, opened_at: '2026-09-20T09:00:00.000Z', closed_at: '2026-09-21T09:00:00.000Z', outcome: 'returned', votes: [vote(7, 'Jun', 'no', { reason_md: 'Volunteers go quiet.', section_key: 'scope' })] });
|
|
97
|
+
const back = item({ id: '44', subject_type: 'review_matter', subject: { area: 'review', title: 'Testers sign off', detail_md: 'Testers sign off finished work; after 3 days any council member can.', author_id: VIEWER }, opened_at: '2026-09-22T09:00:00.000Z', closed_at: '2026-09-23T09:00:00.000Z', outcome: 'returned', votes: [vote(7, 'Jun', 'no', { reason_md: 'Three days is too short.', section_key: 'success' })] });
|
|
98
|
+
const cv = card([matter({ id: 'board:44', building: 'review', state: 'settled', ref: { type: 'board_item', id: '44' }, proposer: { id: VIEWER, login: 'me', name: 'Me' } })], 'board:44', room([], [back, earlier]));
|
|
99
|
+
assert.equal(cv.result, 'It came back to you.');
|
|
100
|
+
assert.match(cv.counsel.words, /^Jun objected to how we will know it worked: “Three days is too short\.” Answer that/);
|
|
101
|
+
assert.equal(cv.counsel.revise, true);
|
|
102
|
+
assert.deepEqual(cv.reviseForm, { text: 'Testers sign off finished work; after 3 days any council member can.', label: 'the matter, in your words' });
|
|
103
|
+
assert.deepEqual(cv.revision, { struck: 'Testers sign off finished work.', inserted: 'Testers sign off finished work; after 3 days any council member can.' });
|
|
104
|
+
assert.equal(cv.readOnly, false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('passed, not yours, a Full Idea, and a ballot not read yet are read-only', () => {
|
|
108
|
+
const passed = card([matter({ state: 'settled' })], 'board:41', room([], [item({ closed_at: '2026-10-02T09:00:00.000Z', outcome: 'passed', votes: [vote(4, 'Ana', 'yes'), vote(5, 'Theo', 'yes')] })]));
|
|
109
|
+
assert.equal(passed.result, 'Passed 2–0. In force from 2026-10-02.');
|
|
110
|
+
assert.deepEqual([passed.readOnly, passed.actions.length, passed.textActions.length], [true, 0, 0]);
|
|
111
|
+
const notMine = card([matter({ state: 'settled', proposer: null })], 'board:41', room([item({ subject: { ...item().subject, author_id: '4' } })]));
|
|
112
|
+
assert.equal(notMine.result, 'Not yours to vote on. You will see how it ends.');
|
|
113
|
+
assert.equal(notMine.actions.length, 0);
|
|
114
|
+
assert.ok(!notMine.seats.some((s) => s.pos === 'you'), 'no seat drawn for you when you have none');
|
|
115
|
+
const idea = card([matter({ building: 'planning', state: 'needs' })], 'board:41', room([item({ subject_type: 'full_idea', subject: { title: 'An idea', author_id: '4', field_values: { purpose: 'It does a thing.' } } })]));
|
|
116
|
+
assert.equal(idea.actions.length, 0, 'a Full Idea is voted on with its sections in front of you');
|
|
117
|
+
assert.match(idea.result, /Board Room/);
|
|
118
|
+
assert.equal(idea.href, '/board-room?item=41');
|
|
119
|
+
const loading = card([matter()], 'board:41', null);
|
|
120
|
+
assert.deepEqual([loading.loading, loading.readOnly, loading.result], [true, true, 'Reading the sitting…']);
|
|
121
|
+
const gone = card([matter()], 'board:41', room([]));
|
|
122
|
+
assert.equal(gone.result, 'This sitting could not be read just now.');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a constitution amendment shows each changed setting struck and inserted against the constitution in force', () => {
|
|
126
|
+
const it = item({ subject: { proposed: { membership: 'rank:metic+', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true }, rationale_md: 'Let the council decide.', author_id: '4' } });
|
|
127
|
+
const cv = card([matter()], 'board:41', room([it]), { board: { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true } });
|
|
128
|
+
assert.deepEqual(cv.change.rows, [
|
|
129
|
+
{ label: 'who sits', struck: 'the archon rank', inserted: 'metic and above' },
|
|
130
|
+
{ label: 'how it passes', struck: 'the first yes decides', inserted: 'everyone has to agree' },
|
|
131
|
+
{ label: 'how long it sits', struck: 'no clock', inserted: '1 day' },
|
|
132
|
+
], 'the unchanged setting is left out');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('admissions: fine by me or object when it is yours, read-only when it is not', () => {
|
|
136
|
+
const m = { id: 'access_requests:7', source: 'access_requests', building: 'people', rule: 'decide', state: 'needs', title: 'Admit Kai as a builder', short: 'admit kai', where: 'people · waiting to join · vouched', proposer: { login: 'kai', name: 'Kai' }, tally: null, href: '/watch', ref: { type: 'access_request', id: '7' } };
|
|
137
|
+
const cv = card([m], 'access_requests:7');
|
|
138
|
+
assert.equal(cv.quote.words, 'Kai asked to join as a builder, and someone vouched for them.');
|
|
139
|
+
assert.deepEqual(cv.actions.map((a) => [a.key, a.label]), [['fine', 'fine by me'], ['refuse', 'object']]);
|
|
140
|
+
const other = card([{ ...m, state: 'moving' }], 'access_requests:7');
|
|
141
|
+
assert.deepEqual([other.readOnly, other.actions.length], [true, 0]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('release or hold at the gate and for an override; a newer core is released where its check runs, never from the card', () => {
|
|
145
|
+
const o = card([{ id: 'override_requests:9', source: 'override_requests', building: 'release', rule: 'decide', state: 'needs', title: 'Release the new sign-up', where: 'release · past a failed check or grade', proposer: null, href: '/task/12', ref: { type: 'override_request', id: '9' } }], 'override_requests:9');
|
|
146
|
+
assert.deepEqual(o.actions.map((a) => [a.key, a.tone]), [['release', 'primary'], ['hold', 'quiet']]);
|
|
147
|
+
const core = card([{ id: 'core_update:1.21.0', source: 'core_update', building: 'release', rule: 'decide', state: 'needs', title: 'Take core 1.21.0', where: 'release · core update', proposer: null, href: '/settings#software-update', ref: { type: 'core_version', id: '1.21.0' } }], 'core_update:1.21.0');
|
|
148
|
+
assert.equal(core.actions[0].go, '/settings#software-update', 'a link to where the pin is moved with its dry run');
|
|
149
|
+
assert.equal(C.request(core, 'release', {}), null, 'the card itself never moves the pin');
|
|
150
|
+
const uat = card([{ id: 'criteria_uat:3', source: 'criteria_uat', building: 'review', rule: 'signoff', state: 'needs', title: 'Sign-up works', where: 'review', href: '/goals', ref: { type: 'criterion', id: '3' } }], 'criteria_uat:3');
|
|
151
|
+
assert.deepEqual([uat.readOnly, uat.actions.length], [true, 0]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── act → the existing route ────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
test('every act maps to the route that does it, with the body that route accepts', () => {
|
|
157
|
+
const sit = card([matter()], 'board:41', room([item()]));
|
|
158
|
+
assert.deepEqual(C.request(sit, 'for'), { method: 'POST', url: '/api/bongos/government/board/items/41/votes', body: { direction: 'yes' } });
|
|
159
|
+
assert.deepEqual(C.request(sit, 'object', { reason: ' Volunteers go quiet. ', section: 'scope' }), { method: 'POST', url: '/api/bongos/government/board/items/41/votes', body: { direction: 'no', reason_md: 'Volunteers go quiet.', section_key: 'scope' } });
|
|
160
|
+
assert.equal(C.request(sit, 'abstain'), null, 'abstaining records nothing');
|
|
161
|
+
assert.deepEqual(C.request(sit, 'revise', { text: 'New words' }), { method: 'POST', url: '/api/bongos/government/board/decision-rules', body: { rules: item().subject.proposed.decision_rules, rationale_md: 'New words' } });
|
|
162
|
+
const amend = card([matter()], 'board:41', room([item({ subject: { proposed: { membership: 'rank:archon', pass_rule: 'consent', window_minutes: null, close_early_on_full_turnout: true }, rationale_md: 'x', author_id: VIEWER } })]));
|
|
163
|
+
assert.deepEqual(C.request(amend, 'revise', { text: 'Why, again' }), { method: 'POST', url: '/api/bongos/government/board/items', body: { subject_type: 'constitutional_amendment', proposal: { membership: 'rank:archon', pass_rule: 'consent', window_minutes: null, close_early_on_full_turnout: true }, rationale_md: 'Why, again' } });
|
|
164
|
+
const mat = card([matter()], 'board:41', room([item({ subject_type: 'people_matter', subject: { area: 'people', title: 'Make Kai a tester', detail_md: 'd', action: { type: 'assign_rank', builder_id: 12, rank_key: 'tester' }, author_id: VIEWER } })]));
|
|
165
|
+
assert.deepEqual(C.request(mat, 'revise', { text: 'Kai tests well.' }), { method: 'POST', url: '/api/bongos/government/board/matters', body: { area: 'people', title: 'Make Kai a tester', detail_md: 'Kai tests well.', action: { type: 'assign_rank', builder_id: 12, rank_key: 'tester' } } });
|
|
166
|
+
|
|
167
|
+
const acc = card([{ id: 'access_requests:7', source: 'access_requests', building: 'people', state: 'needs', title: 'Admit Kai', proposer: { login: 'kai' }, ref: { type: 'access_request', id: '7' } }], 'access_requests:7');
|
|
168
|
+
assert.deepEqual(C.request(acc, 'fine'), { method: 'PATCH', url: '/api/bongos/access-requests/7', body: { status: 'invited' } });
|
|
169
|
+
assert.deepEqual(C.request(acc, 'refuse'), { method: 'PATCH', url: '/api/bongos/access-requests/7', body: { status: 'dismissed' } });
|
|
170
|
+
const join = card([{ id: 'goal_join_requests:5', source: 'goal_join_requests', building: 'people', state: 'needs', title: 'Join', proposer: { login: 'bo' }, href: '/goals#/goal/1000125', ref: { type: 'goal_membership_request', id: '5' } }], 'goal_join_requests:5');
|
|
171
|
+
assert.deepEqual(C.request(join, 'fine'), { method: 'POST', url: '/api/bongos/goals/1000125/requests/5/respond', body: { action: 'approve' } });
|
|
172
|
+
assert.deepEqual(C.request(join, 'refuse'), { method: 'POST', url: '/api/bongos/goals/1000125/requests/5/respond', body: { action: 'deny' } });
|
|
173
|
+
const ov = card([{ id: 'override_requests:9', source: 'override_requests', building: 'release', state: 'needs', title: 'Release', ref: { type: 'override_request', id: '9' } }], 'override_requests:9');
|
|
174
|
+
assert.deepEqual(C.request(ov, 'release'), { method: 'POST', url: '/api/bongos/override-requests/9/decide', body: { decision: 'approve' } });
|
|
175
|
+
assert.equal(C.request(ov, 'hold'), null, 'holding records nothing');
|
|
176
|
+
const gate = card([{ id: 'gate_approvals:812', source: 'gate_approvals', building: 'release', state: 'needs', title: 'A PR', ref: { type: 'pull_request', id: 812 } }], 'gate_approvals:812');
|
|
177
|
+
assert.deepEqual(C.request(gate, 'release'), { method: 'POST', url: '/api/bongos/gate-approvals/812/approve', body: {} });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('no act on any card reaches the core pin or a provisioning route', () => {
|
|
181
|
+
const src = read('street-card.js').replace(/^\s*\/\/.*$/gm, '');
|
|
182
|
+
assert.doesNotMatch(src, /core-upgrade|provisioning|\/core-update/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('an objection names its reason and its part before anything is sent; the parts are the server\'s own keys', () => {
|
|
186
|
+
const sit = card([matter()], 'board:41', room([item()]));
|
|
187
|
+
assert.match(C.check(sit, 'object', { reason: ' ', section: 'scope' }), /^Say why/);
|
|
188
|
+
assert.match(C.check(sit, 'object', { reason: 'Because.', section: '' }), /^Pick the part/);
|
|
189
|
+
assert.equal(C.check(sit, 'object', { reason: 'Because.', section: 'scope' }), null);
|
|
190
|
+
assert.match(C.check(sit, 'revise', { text: '' }), /^Write the revised words/);
|
|
191
|
+
const board = fs.readFileSync(path.join(ROOT, 'modules/government/board.js'), 'utf8');
|
|
192
|
+
const keys = [...board.match(/const FULL_IDEA_SECTION_KEYS = Object\.freeze\(\[([\s\S]*?)\]\)/)[1].matchAll(/'([a-z_]+)'/g)].map((m) => m[1]);
|
|
193
|
+
assert.ok(keys.length >= 5);
|
|
194
|
+
for (const s of C.SECTIONS) assert.ok(keys.includes(s.key), `${s.key} is a key the vote route accepts`);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('after an act the street answers: what rises, what lights, what settles, and when the card closes', () => {
|
|
198
|
+
const sit = card([matter()], 'board:41', room([item()]));
|
|
199
|
+
assert.deepEqual(C.answer(sit, 'for', { item_closed: true, outcome: 'passed' }), { toast: 'Passed. It is in force from today.', building: 'charter', kind: 'rise', close: false });
|
|
200
|
+
assert.deepEqual(C.answer(sit, 'for', {}), { toast: 'Your yes is on the record.', building: 'charter', kind: 'light', close: false });
|
|
201
|
+
assert.equal(C.answer(sit, 'object', { item_closed: true, outcome: 'returned' }).kind, 'settle');
|
|
202
|
+
assert.deepEqual(C.answer(sit, 'abstain', {}).building, null, 'nothing recorded, nothing moves');
|
|
203
|
+
const ov = card([{ id: 'override_requests:9', source: 'override_requests', building: 'release', state: 'needs', title: 'Release', ref: { type: 'override_request', id: '9' } }], 'override_requests:9');
|
|
204
|
+
assert.deepEqual(C.answer(ov, 'release', { ok: true }), { toast: 'Released. It goes live.', building: 'release', kind: 'rise', close: true });
|
|
205
|
+
assert.equal(C.refusal(403, { error: { code: 'not_board_member' } }), 'Only the members of this sitting may vote on it.');
|
|
206
|
+
assert.equal(C.refusal(403, { error: { code: 'forbidden' } }), 'That is not yours to do.');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── the page ────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
function fakeDom() {
|
|
212
|
+
const byId = new Map();
|
|
213
|
+
const focused = { el: null };
|
|
214
|
+
const mk = (tag) => ({
|
|
215
|
+
tagName: tag, attrs: {}, children: [], style: { setProperty(k, v) { this[k] = v; } }, className: '', listeners: {}, _text: '', hidden: false, value: '',
|
|
216
|
+
clientWidth: 1440, clientHeight: 708, offsetLeft: 0, scrollLeft: 0, parentNode: null,
|
|
217
|
+
get firstChild() { return this.children[0] || null; },
|
|
218
|
+
setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'id') byId.set(String(v), this); },
|
|
219
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
|
|
220
|
+
appendChild(c) { this.children.push(c); c.parentNode = this; return c; },
|
|
221
|
+
removeChild(c) { this.children = this.children.filter((x) => x !== c); return c; },
|
|
222
|
+
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
|
|
223
|
+
fire(t) { (this.listeners[t] || []).forEach((fn) => fn({ type: t, preventDefault() {} })); },
|
|
224
|
+
focus() { focused.el = this; },
|
|
225
|
+
set textContent(v) { this._text = String(v); this.children = []; },
|
|
226
|
+
get textContent() { return this._text; },
|
|
227
|
+
get innerHTML() { throw new Error('the card must not read innerHTML'); },
|
|
228
|
+
set innerHTML(v) { throw new Error('the card must not write innerHTML'); },
|
|
229
|
+
});
|
|
230
|
+
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', 'streetCardBody', 'streetCardOpen', 'streetCardClose', 'streetToast'];
|
|
231
|
+
for (const id of ids) mk('x').setAttribute('id', id);
|
|
232
|
+
byId.get('streetToast').hidden = true; // as the page ships it
|
|
233
|
+
const document = { documentElement: mk('html'), getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_ns, t) => mk(t) };
|
|
234
|
+
return { document, byId, focused };
|
|
235
|
+
}
|
|
236
|
+
const walk = (el, pred, out = []) => { if (pred(el)) out.push(el); (el.children || []).forEach((c) => walk(c, pred, out)); return out; };
|
|
237
|
+
const byAct = (el, key) => walk(el, (n) => n.attrs && n.attrs['data-act'] === key)[0];
|
|
238
|
+
const settle = () => new Promise((r) => setImmediate(r));
|
|
239
|
+
|
|
240
|
+
// feeds: url → body, or (call) → body; a POST answers from `posts`.
|
|
241
|
+
async function bootPage({ feeds, posts = {} }) {
|
|
242
|
+
const { document, byId, focused } = fakeDom();
|
|
243
|
+
const calls = [];
|
|
244
|
+
const window = {
|
|
245
|
+
innerWidth: 1440, innerHeight: 900, location: { hash: '' },
|
|
246
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
247
|
+
matchMedia: () => ({ matches: true, addEventListener() {} }),
|
|
248
|
+
addEventListener() {},
|
|
249
|
+
};
|
|
250
|
+
const fetch = (url, opts = {}) => {
|
|
251
|
+
const method = opts.method || 'GET';
|
|
252
|
+
calls.push({ url, method, body: opts.body ? JSON.parse(opts.body) : null });
|
|
253
|
+
const src = method === 'GET' ? feeds[url] : posts[`${method} ${url}`];
|
|
254
|
+
const ans = typeof src === 'function' ? src(calls) : src;
|
|
255
|
+
if (ans === undefined) return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
|
|
256
|
+
const status = ans && ans.__status ? ans.__status : 200;
|
|
257
|
+
return Promise.resolve({ ok: status < 400, status, json: async () => ans });
|
|
258
|
+
};
|
|
259
|
+
const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite });
|
|
260
|
+
ctx.globalThis = ctx;
|
|
261
|
+
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
262
|
+
await window.GovStreetPage.ready;
|
|
263
|
+
return { window, byId, calls, focused };
|
|
264
|
+
}
|
|
265
|
+
const BASE = (d) => ({
|
|
266
|
+
'/api/bongos/government/docket': d,
|
|
267
|
+
'/api/bongos/goals': { goals: [] },
|
|
268
|
+
'/api/bongos/goals/rollups': { rollups: [] },
|
|
269
|
+
'/api/bongos/builders/95/earnings': { days: [] },
|
|
270
|
+
'/api/bongos/government/board/items': room([item()]),
|
|
271
|
+
'/api/bongos/government/constitution': { board: { membership: 'rank:metic+', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true } },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('a pin opens the full card: the ballot is read, then the seats, counsel and acts are drawn as text', async () => {
|
|
275
|
+
const p = await bootPage({ feeds: BASE(docket([matter({ title: '<b>x</b>' })])) });
|
|
276
|
+
p.byId.get('streetPins').children[0].fire('click');
|
|
277
|
+
await settle(); await settle();
|
|
278
|
+
assert.ok(p.calls.some((c) => c.url === '/api/bongos/government/board/items'), 'the room is read when a sitting opens');
|
|
279
|
+
const body = p.byId.get('streetCardBody');
|
|
280
|
+
assert.equal(p.byId.get('streetCardTitle').textContent, '<b>x</b>');
|
|
281
|
+
assert.equal(walk(body, (n) => /mcard-seat /.test(n.className)).length, 5);
|
|
282
|
+
assert.ok(byAct(body, 'for') && byAct(body, 'object') && byAct(body, 'abstain'));
|
|
283
|
+
assert.equal(walk(body, (n) => n.className === 'mcard-counsel').length, 1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('vote for: one POST to the vote route, a toast, the docket re-read, and the Charter lights with its pin settling', async () => {
|
|
287
|
+
let reads = 0;
|
|
288
|
+
const after = docket([matter({ state: 'moving', tally: { for: 4, against: 0, open: 1, needs: 'needs everyone who votes' } })]);
|
|
289
|
+
const feeds = BASE((() => { const d0 = docket([matter()]); return () => (reads++ === 0 ? d0 : after); })());
|
|
290
|
+
feeds['/api/bongos/government/docket'] = (() => { const d0 = docket([matter()]); return () => (reads++ === 0 ? d0 : after); })();
|
|
291
|
+
const p = await bootPage({ feeds, posts: { 'POST /api/bongos/government/board/items/41/votes': { ok: true, vote: {}, already_voted: false, karma_delta: 1 } } });
|
|
292
|
+
p.byId.get('streetPins').children[0].fire('click');
|
|
293
|
+
await settle(); await settle();
|
|
294
|
+
byAct(p.byId.get('streetCardBody'), 'for').fire('click');
|
|
295
|
+
await settle(); await settle(); await settle();
|
|
296
|
+
const posts = p.calls.filter((c) => c.method !== 'GET');
|
|
297
|
+
assert.deepEqual(posts, [{ url: '/api/bongos/government/board/items/41/votes', method: 'POST', body: { direction: 'yes' } }]);
|
|
298
|
+
assert.equal(p.byId.get('streetToast').textContent, 'Your yes is on the record.');
|
|
299
|
+
assert.equal(p.byId.get('streetToast').hidden, false);
|
|
300
|
+
assert.ok(reads >= 2, 'the docket is read again');
|
|
301
|
+
const charter = p.byId.get('streetBlds').children.find((g) => g.attrs['data-key'] === 'charter');
|
|
302
|
+
assert.equal(charter.attrs.class, 'street-answer street-answer--light');
|
|
303
|
+
assert.match(p.byId.get('streetPins').children[0].className, /street-pin--moving street-pin--answer/, 'the pin moved from needs to moving');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('object: the form asks for the reason and the part, then puts both on the record', async () => {
|
|
307
|
+
const p = await bootPage({ feeds: BASE(docket([matter()])), posts: { 'POST /api/bongos/government/board/items/41/votes': { ok: true, vote: {}, item_closed: true, outcome: 'returned' } } });
|
|
308
|
+
p.byId.get('streetPins').children[0].fire('click');
|
|
309
|
+
await settle(); await settle();
|
|
310
|
+
byAct(p.byId.get('streetCardBody'), 'object').fire('click');
|
|
311
|
+
assert.equal(p.focused.el, p.byId.get('mcardReason'), 'focus goes to the reason');
|
|
312
|
+
byAct(p.byId.get('streetCardBody'), 'object-send').fire('click');
|
|
313
|
+
await settle();
|
|
314
|
+
assert.equal(p.calls.filter((c) => c.method !== 'GET').length, 0, 'an empty objection is refused before it is sent');
|
|
315
|
+
assert.match(walk(p.byId.get('streetCardBody'), (n) => n.className === 'mcard-error')[0].textContent, /^Say why/);
|
|
316
|
+
p.byId.get('mcardReason').value = 'Volunteers go quiet.';
|
|
317
|
+
p.byId.get('mcardSection').value = 'scope';
|
|
318
|
+
byAct(p.byId.get('streetCardBody'), 'object-send').fire('click');
|
|
319
|
+
await settle(); await settle(); await settle();
|
|
320
|
+
assert.deepEqual(p.calls.filter((c) => c.method !== 'GET').map((c) => c.body), [{ direction: 'no', reason_md: 'Volunteers go quiet.', section_key: 'scope' }]);
|
|
321
|
+
assert.equal(p.byId.get('streetToast').textContent, 'Your objection is on the record. It goes back to its author to revise.');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('a refused act says why and records nothing; abstain sends nothing at all', async () => {
|
|
325
|
+
const p = await bootPage({ feeds: BASE(docket([matter()])), posts: { 'POST /api/bongos/government/board/items/41/votes': { __status: 403, error: { code: 'not_board_member' } } } });
|
|
326
|
+
p.byId.get('streetPins').children[0].fire('click');
|
|
327
|
+
await settle(); await settle();
|
|
328
|
+
byAct(p.byId.get('streetCardBody'), 'for').fire('click');
|
|
329
|
+
await settle(); await settle();
|
|
330
|
+
assert.equal(walk(p.byId.get('streetCardBody'), (n) => n.className === 'mcard-error')[0].textContent, 'Only the members of this sitting may vote on it.');
|
|
331
|
+
assert.equal(p.byId.get('streetToast').hidden, true, 'no toast for a refusal');
|
|
332
|
+
const before = p.calls.length;
|
|
333
|
+
byAct(p.byId.get('streetCardBody'), 'abstain').fire('click');
|
|
334
|
+
await settle();
|
|
335
|
+
assert.equal(p.calls.length, before, 'abstaining sends nothing');
|
|
336
|
+
assert.equal(p.byId.get('streetCard').hidden, true);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('release: the override is approved, the card closes, Release rises, and with nothing left the street turns to evening', async () => {
|
|
340
|
+
const ov = { id: 'override_requests:9', source: 'override_requests', building: 'release', rule: 'decide', state: 'needs', title: 'Release the new sign-up', where: 'release · past a failed check or grade', proposer: { login: 'lu' }, href: '/task/12', ref: { type: 'override_request', id: '9' } };
|
|
341
|
+
let reads = 0;
|
|
342
|
+
const feeds = BASE(null);
|
|
343
|
+
feeds['/api/bongos/government/docket'] = () => (reads++ === 0 ? docket([ov]) : docket([]));
|
|
344
|
+
const p = await bootPage({ feeds, posts: { 'POST /api/bongos/override-requests/9/decide': { ok: true } } });
|
|
345
|
+
assert.equal(p.byId.get('streetEvening').hidden, true);
|
|
346
|
+
p.byId.get('streetPins').children[0].fire('click');
|
|
347
|
+
byAct(p.byId.get('streetCardBody'), 'release').fire('click');
|
|
348
|
+
await settle(); await settle(); await settle();
|
|
349
|
+
assert.deepEqual(p.calls.filter((c) => c.method !== 'GET').map((c) => [c.method, c.url, c.body]), [['POST', '/api/bongos/override-requests/9/decide', { decision: 'approve' }]]);
|
|
350
|
+
assert.equal(p.byId.get('streetCard').hidden, true);
|
|
351
|
+
assert.equal(p.byId.get('streetToast').textContent, 'Released. It goes live.');
|
|
352
|
+
assert.equal(p.byId.get('streetBlds').children.find((g) => g.attrs['data-key'] === 'release').attrs.class, 'street-answer street-answer--rise');
|
|
353
|
+
assert.equal(p.byId.get('streetEvening').hidden, false, 'nothing waits on you');
|
|
354
|
+
assert.equal(p.byId.get('hudNeeds').textContent, '0');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ── the sheet ───────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
test('the card\'s motion is CSS only and dies under reduced motion; tokens only; no innerHTML', () => {
|
|
360
|
+
const css = read('street-card.css').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
361
|
+
assert.doesNotMatch(css, /:root/);
|
|
362
|
+
assert.doesNotMatch(css, /#fb9569/i, 'the accent is the token');
|
|
363
|
+
const literals = [...css.matchAll(/#[0-9a-f]{3,8}\b/gi)].map((m) => m[0].toLowerCase());
|
|
364
|
+
assert.ok(literals.every((h) => h === '#000000' || h === '#ffffff'), `only black or white in a mix: ${literals}`);
|
|
365
|
+
for (const k of ['street-rise', 'street-light', 'street-settle', 'street-seat']) assert.match(css, new RegExp(`@keyframes ${k} `));
|
|
366
|
+
const kill = css.match(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*)\}\s*$/)[1];
|
|
367
|
+
for (const c of ['.street-answer', '.street-pin--answer', '.mcard-seat--fresh', '.street-toast']) assert.ok(kill.includes(c), `${c} stops under reduced motion`);
|
|
368
|
+
assert.match(kill, /animation: none !important; transition: none !important;/);
|
|
369
|
+
const js = read('street-card.js').replace(/^\s*\/\/.*$/gm, '');
|
|
370
|
+
assert.doesNotMatch(js, /innerHTML|insertAdjacentHTML|requestAnimationFrame|setInterval|setTimeout/);
|
|
371
|
+
const html = read('street.html');
|
|
372
|
+
assert.match(html, /<link rel="stylesheet" href="\/builders\/street-card\.css/);
|
|
373
|
+
assert.match(html, /id="streetToast" role="status" aria-live="polite"/);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ── the Board Room names the new subjects ───────────────────────────────────
|
|
377
|
+
|
|
378
|
+
test('the Board Room labels a building\'s matter as that building\'s matter, and a who-decides change as its own amendment', () => {
|
|
379
|
+
const src = read('board-room.js');
|
|
380
|
+
const fn = src.slice(src.indexOf(' function subjectLine(item) {'), src.indexOf(' function clockLine(item) {'));
|
|
381
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
382
|
+
const subjectLine = new Function('escapeHtml', 'pill', `${fn}\nreturn subjectLine;`)(esc, (tone, t) => `[${t}]`);
|
|
383
|
+
assert.equal(subjectLine({ subject_type: 'people_matter', subject: { title: 'Make <Kai> a tester' } }), 'Make <Kai> a tester [people matter]');
|
|
384
|
+
assert.equal(subjectLine({ subject_type: 'treasury_matter', subject: null }), 'A matter at treasury [treasury matter]');
|
|
385
|
+
assert.equal(subjectLine({ subject_type: 'constitutional_amendment', subject: { kind: 'decision_rules' } }), 'Who decides what [amendment]');
|
|
386
|
+
assert.equal(subjectLine({ subject_type: 'constitutional_amendment', subject: { proposed: {} } }), 'Constitutional amendment [amendment]');
|
|
387
|
+
});
|
package/tests/hall_street.mjs
CHANGED
|
@@ -377,7 +377,7 @@ test('the page holds the prototype\'s header, the street, the readings and the c
|
|
|
377
377
|
for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
|
|
378
378
|
assert.doesNotMatch(html, /app-sidebar|shell\.js/, 'no hall chrome, as the city');
|
|
379
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-inside', 'street'], 'the shared kit loads first, then the interiors (task 1004526)');
|
|
380
|
+
assert.deepEqual(scripts, ['city-draw', 'street-draw', 'street-card', 'street-inside', 'street'], 'the shared kit loads first, then the card (task 1004528) and the interiors (task 1004526)');
|
|
381
381
|
assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
|
|
382
382
|
});
|
|
383
383
|
|
|
@@ -422,7 +422,7 @@ function fakeDom() {
|
|
|
422
422
|
};
|
|
423
423
|
const focused = { el: null };
|
|
424
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', '
|
|
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', 'streetCardBody', 'streetCardOpen', 'streetCardClose', 'streetToast'];
|
|
426
426
|
for (const id of ids) mk('x').setAttribute('id', id);
|
|
427
427
|
const document = { documentElement: html, getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_ns, t) => mk(t) };
|
|
428
428
|
return { document, byId, html, focused };
|
|
@@ -449,6 +449,7 @@ async function bootPage({ feeds = {}, stored = null, osDark = true, hash = '' }
|
|
|
449
449
|
// each file hangs its api on `window`, as the browser loads them
|
|
450
450
|
vm.runInContext(read('city-draw.js'), ctx);
|
|
451
451
|
vm.runInContext(read('street-draw.js'), ctx);
|
|
452
|
+
vm.runInContext(read('street-card.js'), ctx);
|
|
452
453
|
vm.runInContext(read('street.js'), ctx);
|
|
453
454
|
await window.GovStreetPage.ready;
|
|
454
455
|
const key = (t, e) => (listeners[t] || []).forEach((fn) => fn(e));
|
|
@@ -414,7 +414,7 @@ async function bootPage({ hash = '', feeds = {}, innerWidth = 1440 } = {}) {
|
|
|
414
414
|
};
|
|
415
415
|
const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map });
|
|
416
416
|
ctx.globalThis = ctx;
|
|
417
|
-
for (const f of ['city-draw.js', 'street-draw.js', 'street-inside.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
417
|
+
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
418
418
|
await window.GovStreetPage.ready;
|
|
419
419
|
await new Promise((r) => setTimeout(r, 0));
|
|
420
420
|
const fireWin = (t, e) => (listeners[t] || []).forEach((fn) => fn(e));
|