@bongos/core 1.21.6 → 1.21.8
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 +71 -26
- 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 +37 -6
- package/docs/page-readings.json +979 -921
- 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/modules/public-landing/public/projects-hall-live.states.json +216 -0
- package/modules/public-landing/public/projects.html +43 -13
- package/modules/public-landing/public/projects.states.json +1 -1
- package/modules/ui-design/kit/fixtures/provisioning-instances-hall-live.json +106 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/bongos/serve-internal.js +12 -0
- package/src/module-api.js +1 -1
- package/tests/hall_audit.mjs +9 -1
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_street.mjs +521 -0
- package/tests/project_invite_ui.mjs +65 -7
- package/tests/projects_hub_dns_ready.mjs +7 -1
- package/tests/ui_design_kit.mjs +1 -1
|
@@ -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
|
+
});
|
|
@@ -474,11 +474,69 @@ test('the two mounts keep their own lines — an invite on one is not drawn on t
|
|
|
474
474
|
// in" and "is this their project" — are answered by the caller, which hands over
|
|
475
475
|
// a slug or nothing. So the pins are on the calls.
|
|
476
476
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
477
|
+
// The wizard's invite step waits for the hall (task 1004503): a second account cannot join a
|
|
478
|
+
// project whose hall is not up, so the box opens only on the done panel's verified Live. The
|
|
479
|
+
// gate is RUN here: demoTeam, paintInviteGate and paintDemoTeam sliced out of the script.
|
|
480
|
+
const GATE_START = ' function demoTeam() {';
|
|
481
|
+
function gateSlice() {
|
|
482
|
+
const a = SCRIPT.indexOf(GATE_START);
|
|
483
|
+
const b = SCRIPT.indexOf('\n }\n', SCRIPT.indexOf(' function paintDemoTeam() {'));
|
|
484
|
+
return a > -1 && b > a ? SCRIPT.slice(a, b + 6) : '';
|
|
485
|
+
}
|
|
486
|
+
function bootGate({ me = { github_login: 'kestrel' }, created = { id: 7, slug: 'moonbase' }, demo = null } = {}) {
|
|
487
|
+
const els = {};
|
|
488
|
+
const $ = (id) => (els[id] = els[id] || { id, hidden: false, textContent: '', innerHTML: '', value: '' });
|
|
489
|
+
const calls = { slug: [], sugg: [] };
|
|
490
|
+
const sandbox = {
|
|
491
|
+
$, me, state: { demo },
|
|
492
|
+
createdRecord: () => created,
|
|
493
|
+
setInviteSlug: (id, slug) => calls.slug.push([id, slug]),
|
|
494
|
+
loadInviteSuggestions: (id, iid) => calls.sugg.push([id, iid]),
|
|
495
|
+
projectPageLink: (label) => '<a href="?view=manage">' + label + '</a>',
|
|
496
|
+
};
|
|
497
|
+
vm.createContext(sandbox);
|
|
498
|
+
vm.runInContext(gateSlice(), sandbox);
|
|
499
|
+
return { run: (c) => vm.runInContext(c, sandbox), $, calls };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
test('the wizard opens the invite step only once the hall is up, and only for a signed-in owner of a project that exists (tasks 1002346, 1004503)', () => {
|
|
503
|
+
assert.ok(gateSlice().includes('function paintInviteGate(live)'), 'the gate slice is found');
|
|
504
|
+
assert.match(SCRIPT, /function renderDone\(\) \{[\s\S]*?paintInviteGate\(false\);/, 'the done panel paints the gate shut');
|
|
505
|
+
assert.match(SCRIPT, /if \(inst\.last_verified_up\) \{[\s\S]{0,400}paintInviteGate\(true\);/, 'and opens it only on the verified Live');
|
|
506
|
+
assert.equal((SCRIPT.match(/setInviteSlug\('wizInvite'/g) || []).length, 2, 'the gate is the only caller: shut, or open on Live');
|
|
507
|
+
|
|
508
|
+
const before = bootGate();
|
|
509
|
+
before.run('paintInviteGate(false)');
|
|
510
|
+
assert.deepEqual(before.calls.slug, [['wizInvite', '']], 'before the hall is up: no invite control');
|
|
511
|
+
assert.deepEqual(before.calls.sugg, [], 'and no suggestions asked for');
|
|
512
|
+
assert.equal(before.$('wizInviteLater').textContent, 'You can invite builders once its hall is up.', 'one plain line says when');
|
|
513
|
+
|
|
514
|
+
const live = bootGate();
|
|
515
|
+
live.run('paintInviteGate(true)');
|
|
516
|
+
live.run('paintInviteGate(true)');
|
|
517
|
+
assert.deepEqual(live.calls.slug, [['wizInvite', 'moonbase']], 'on Live: the box opens on the created project, once');
|
|
518
|
+
assert.deepEqual(live.calls.sugg, [['wizInvite', 7]], 'and the suggestions are asked for once, not on every tick');
|
|
519
|
+
assert.match(live.$('wizInviteLater').innerHTML, /any time from <a href="\?view=manage">the project’s own page<\/a>/);
|
|
520
|
+
|
|
521
|
+
for (const [label, opts] of [['signed out', { me: null }], ['no created id', { created: { slug: 'moonbase' } }], ['no slug', { created: { id: 7 } }]]) {
|
|
522
|
+
const g = bootGate(opts);
|
|
523
|
+
g.run('paintInviteGate(true)');
|
|
524
|
+
assert.deepEqual(g.calls.slug, [['wizInvite', '']], label + ': the created record IS the proof — no step');
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('a demo\'s teammates are kept until the hall is up, then offered in the box (task 1004503)', () => {
|
|
529
|
+
const before = bootGate({ demo: { invites: ['ava', 'jo'] } });
|
|
530
|
+
before.run('paintInviteGate(false)');
|
|
531
|
+
assert.equal(before.$('wizInviteLater').textContent, 'You can invite @ava, @jo once its hall is up.', 'named while they wait');
|
|
532
|
+
assert.equal(before.$('wizDemoTeam').hidden, true, 'no invite prompt before the hall is up');
|
|
533
|
+
// a reload before the hall is up: the draft is gone, the created record still carries the names
|
|
534
|
+
const reloaded = bootGate({ created: { id: 7, slug: 'moonbase', demo_invites: ['ava'] } });
|
|
535
|
+
reloaded.run('paintInviteGate(true)');
|
|
536
|
+
assert.equal(reloaded.$('wizDemoTeam').hidden, false, 'on Live the teammates line shows');
|
|
537
|
+
assert.equal(reloaded.$('wizDemoTeam').textContent, 'Your demo teammates: @ava. Invite each one here.');
|
|
538
|
+
assert.equal(reloaded.$('wizInviteWho').value, 'ava', 'and the first is ready in the box');
|
|
539
|
+
assert.match(SCRIPT, /demo_invites: state\.demo && state\.demo\.invites && state\.demo\.invites\.length \? state\.demo\.invites\.slice\(\) : undefined/, 'the created record carries them');
|
|
482
540
|
});
|
|
483
541
|
|
|
484
542
|
test('the project page opens the step from its own manage read, which the API already own-scopes', () => {
|
|
@@ -643,8 +701,8 @@ test('a suggestion never renders a github_id, and its name goes through the esca
|
|
|
643
701
|
test('the suggestion surface is mounted on BOTH the wizard and the project page', () => {
|
|
644
702
|
assert.match(HUB, /<ul class="suggList" id="wizInviteSugg"><\/ul>/, 'the wizard mount');
|
|
645
703
|
assert.match(HUB, /<ul class="suggList" id="mInviteSugg"><\/ul>/, 'and the project page mount');
|
|
646
|
-
assert.match(SCRIPT, /
|
|
647
|
-
'the wizard asks keyed on the SAME created record the slug comes from (ADR 0249 §1)');
|
|
704
|
+
assert.match(SCRIPT, /setInviteSlug\('wizInvite', created\.slug\);\n\s*paintDemoTeam\(\);\n\s*loadInviteSuggestions\('wizInvite', created\.id\);/,
|
|
705
|
+
'the wizard asks keyed on the SAME created record the slug comes from (ADR 0249 §1), and only once the gate opens (task 1004503)');
|
|
648
706
|
assert.match(SCRIPT, /loadInviteSuggestions\('mInvite', inst\.slug \? inst\.id : ''\)/,
|
|
649
707
|
'and the manage page asks with the id whose read already proved ownership');
|
|
650
708
|
assert.match(COSMOS, /\.suggList\{/, 'styled in cosmos.css like its sibling list');
|