@bongos/core 1.24.29 → 1.24.30
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 +78 -38
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +36 -1
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +86 -89
- package/docs/copy-registry.json +137 -175
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +3 -2
- package/docs/page-readings.json +89 -107
- package/modules/government/db.js +29 -0
- package/modules/government/docket.js +61 -4
- package/modules/government/routes/government.js +2 -0
- package/modules/hall-ui/public/builders.js +3 -79
- package/modules/hall-ui/public/hall-render.js +6 -81
- package/modules/hall-ui/public/hall-widgets.js +4 -45
- package/modules/hall-ui/public/home-city-draw.js +275 -0
- package/modules/hall-ui/public/home-city.css +66 -0
- package/modules/hall-ui/public/home-city.js +223 -0
- package/modules/hall-ui/public/index.html +5 -6
- package/modules/hall-ui/public/index.states.json +73 -8
- package/modules/hall-ui/records/home-city.md +66 -0
- package/modules/lifecycle/home-city.js +340 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/home-city.js +29 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/src/module-api.js +1 -1
- package/tests/claim_from_task_and_home.mjs +8 -43
- package/tests/hall_board_world.mjs +10 -12
- package/tests/hall_home_ships_bounded.mjs +4 -3
- package/tests/home_bounded_reads.mjs +16 -31
- package/tests/home_city.mjs +385 -0
- package/tests/home_city_widget.mjs +120 -0
- package/tests/leaderboard_roles.mjs +3 -2
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// tests/home_city.mjs — Home's project city (task 1005054): the weekly summary the
|
|
2
|
+
// server counts, and the drawing that turns it into towers and a ladder.
|
|
3
|
+
//
|
|
4
|
+
// The server half (modules/lifecycle/home-city.js) is driven three ways with no
|
|
5
|
+
// database: the pure shaping over hand-written rows, the reads against a recording
|
|
6
|
+
// fake pool (to prove every query is bounded by the window, the areas or a LIMIT,
|
|
7
|
+
// and that the viewer-only and module-optional parts behave), and the handler. The
|
|
8
|
+
// drawing half (modules/hall-ui/public/home-city-draw.js) is pure and runs in node.
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/home_city.mjs
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { makeRunner } from './helpers.mjs';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
process.env.NODE_ENV = 'test';
|
|
21
|
+
const city = require(path.join(ROOT, 'modules/lifecycle/home-city.js'));
|
|
22
|
+
const Draw = require(path.join(ROOT, 'modules/hall-ui/public/home-city-draw.js'));
|
|
23
|
+
const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/home__city.json'), 'utf8'));
|
|
24
|
+
|
|
25
|
+
const { test, summary } = makeRunner();
|
|
26
|
+
|
|
27
|
+
const START = new Date('2026-08-31T00:00:00Z');
|
|
28
|
+
function raw(over = {}) {
|
|
29
|
+
return Object.assign({
|
|
30
|
+
N: 4, start: START, version: { id: 'V2', name: 'The ten working areas' },
|
|
31
|
+
areas: [{ goal_id: '10', title: 'Area ten' }, { goal_id: '20', title: 'Area twenty' }],
|
|
32
|
+
areaWeeks: [], leads: [], peopleWeeks: [], ideaCounts: [], ideas: [], gov: [], sites: [], waiting: [],
|
|
33
|
+
builders: [{ id: '1', github_login: 'ana', display_name: 'Ana', avatar_url: null }, { id: '2', github_login: 'bo', display_name: null, avatar_url: 'https://x/2.png' }],
|
|
34
|
+
}, over);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// 1. the shaping (pure)
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
await test('an area splits what came before the window from each week, and art from build', () => {
|
|
42
|
+
const out = city.shapeCity(raw({
|
|
43
|
+
areaWeeks: [
|
|
44
|
+
{ goal_id: '10', wk: -1, art: false, n: 40 }, { goal_id: '10', wk: -1, art: true, n: 3 },
|
|
45
|
+
{ goal_id: '10', wk: 0, art: false, n: 5 }, { goal_id: '10', wk: 3, art: true, n: 2 },
|
|
46
|
+
{ goal_id: '10', wk: 7, art: false, n: 99 }, // outside a four-week window: dropped, never piled on the edge
|
|
47
|
+
{ goal_id: '99', wk: 1, art: false, n: 9 }, // not an area of the version: ignored
|
|
48
|
+
],
|
|
49
|
+
}), null);
|
|
50
|
+
const a = out.areas.find((x) => x.goal_id === '10');
|
|
51
|
+
assert.equal(a.base_build, 40); assert.equal(a.base_art, 3);
|
|
52
|
+
assert.deepEqual(a.build, [5, 0, 0, 0]); assert.deepEqual(a.art, [0, 0, 0, 2]);
|
|
53
|
+
assert.deepEqual(out.weeks.map((w) => w.start), ['2026-08-31', '2026-09-07', '2026-09-14', '2026-09-21']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await test('people are only known builders; the viewer is always there, even with a quiet week', () => {
|
|
57
|
+
const out = city.shapeCity(raw({
|
|
58
|
+
peopleWeeks: [{ id: '1', wk: 2, art: false, n: 4 }, { id: '1', wk: 2, art: true, n: 1 }, { id: '77', wk: 2, art: false, n: 8 }],
|
|
59
|
+
ideaCounts: [{ id: '2', wk: 1, n: 3 }], gov: [{ id: '1', wk: 3, n: 2 }],
|
|
60
|
+
}), { id: '2', rank: 'metic' });
|
|
61
|
+
const ana = out.people.find((p) => p.id === '1'), bo = out.people.find((p) => p.id === '2');
|
|
62
|
+
assert.deepEqual(ana.build, [0, 0, 4, 0]); assert.deepEqual(ana.art, [0, 0, 1, 0]); assert.deepEqual(ana.gov, [0, 0, 0, 2]);
|
|
63
|
+
assert.deepEqual(bo.idea, [0, 3, 0, 0]);
|
|
64
|
+
assert.equal(bo.name, 'bo', 'no display name falls back to the login');
|
|
65
|
+
assert.ok(!out.people.some((p) => p.id === '77'), 'a machine principal or deleted builder (not in builders) is never drawn');
|
|
66
|
+
assert.equal(out.viewer.id, '2');
|
|
67
|
+
assert.ok(out.people.every((p) => p.agent === false), 'a person is never an agent: agents are a site fact');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('leads, ideas and sites keep only what the city can place', () => {
|
|
71
|
+
const out = city.shapeCity(raw({
|
|
72
|
+
leads: [{ goal_id: '10', wk: 1, shipped_by: '1' }, { goal_id: '20', wk: 1, shipped_by: '77' }],
|
|
73
|
+
ideas: [{ id: 5, title: 'Sky', by: '1', wk: 3, goal_id: '20' }, { id: 6, title: 'Old', by: '1', wk: 9, goal_id: null }, { id: 7, title: 'Stray', by: '77', wk: 0, goal_id: '99' }],
|
|
74
|
+
sites: [{ goal_id: '10', builder_id: '1', agent: true }, { goal_id: '99', builder_id: '1', agent: false }, { goal_id: '20', builder_id: '77', agent: false }],
|
|
75
|
+
waiting: [{ goal_id: '20', n: 4 }, { goal_id: '10', n: 0 }, { goal_id: '99', n: 2 }],
|
|
76
|
+
}), null);
|
|
77
|
+
assert.equal(out.areas[0].lead[1], '1'); assert.equal(out.areas[1].lead[1], null);
|
|
78
|
+
assert.deepEqual(out.ideas.map((i) => [i.id, i.goal_id, i.by]), [['5', '20', '1'], ['7', null, null]]);
|
|
79
|
+
assert.deepEqual(out.now.sites, [{ goal_id: '10', builder_id: '1', agent: true }]);
|
|
80
|
+
assert.deepEqual(out.now.waiting, [{ goal_id: '20', count: 4 }]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await test('a private area the viewer may not read keeps its tower but loses its name, and so do its ideas (ADR 0112)', () => {
|
|
84
|
+
const r = raw({
|
|
85
|
+
areas: [{ goal_id: '10', title: 'Area ten', visible: true }, { goal_id: '20', title: 'Secret plan', visible: false }],
|
|
86
|
+
areaWeeks: [{ goal_id: '20', wk: 1, art: false, n: 3 }],
|
|
87
|
+
ideas: [{ id: '7', title: 'Secret idea', by: '1', wk: 1, goal_id: '20' }, { id: '8', title: 'Open idea', by: '1', wk: 1, goal_id: '10' }],
|
|
88
|
+
});
|
|
89
|
+
const d = city.shapeCity(r, { id: '1' });
|
|
90
|
+
const hidden = d.areas.find((a) => a.goal_id === '20');
|
|
91
|
+
assert.equal(hidden.title, null, 'the private name never leaves the server');
|
|
92
|
+
assert.deepEqual(hidden.build, [0, 3, 0, 0], 'its counts are structure and stay');
|
|
93
|
+
assert.equal(d.areas.find((a) => a.goal_id === '10').title, 'Area ten');
|
|
94
|
+
assert.equal(d.ideas.find((x) => x.id === '7').title, null, 'an idea tied to it loses its title too');
|
|
95
|
+
assert.equal(d.ideas.find((x) => x.id === '8').title, 'Open idea');
|
|
96
|
+
assert.ok(!JSON.stringify(d).includes('Secret'), 'no private prose anywhere in the payload');
|
|
97
|
+
const m = Draw.cityModel(d);
|
|
98
|
+
assert.equal(m.areas.find((a) => a.id === '20').title, 'A private area');
|
|
99
|
+
assert.equal(m.ideas.find((x) => x.id === '7').title, 'A private idea');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await test('weeks are clamped: never fewer than four, never more than twenty-six', () => {
|
|
103
|
+
assert.equal(city.clampWeeks(undefined), city.DEFAULT_WEEKS);
|
|
104
|
+
assert.equal(city.clampWeeks('2'), 4);
|
|
105
|
+
assert.equal(city.clampWeeks('500'), 26);
|
|
106
|
+
assert.equal(city.clampWeeks('abc'), city.DEFAULT_WEEKS);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// 2. the reads: bounded, viewer-scoped, module-optional
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function fakePool({ tables = {} } = {}) {
|
|
114
|
+
const seen = [];
|
|
115
|
+
return {
|
|
116
|
+
seen,
|
|
117
|
+
async query(sql, params) {
|
|
118
|
+
seen.push({ sql, params });
|
|
119
|
+
if (/to_regclass/.test(sql)) return { rows: [{ ok: !!tables[String(params[0]).replace('public.', '')] }] };
|
|
120
|
+
if (/date_trunc\('week'/.test(sql)) return { rows: [{ start: START }] };
|
|
121
|
+
if (/FROM versions/.test(sql)) return { rows: [{ id: 'V2', name: 'V2' }] };
|
|
122
|
+
if (/FROM goals g/.test(sql)) return { rows: [{ goal_id: '10', title: 'Area ten' }] };
|
|
123
|
+
if (/FROM builders b/.test(sql)) return { rows: [] };
|
|
124
|
+
return { rows: [] };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await test('every read is bounded by the window, the areas or a LIMIT, and never selects a task body', async () => {
|
|
130
|
+
const pool = fakePool({ tables: { government_board_votes: true, autonomy_runner_heartbeat: true, government_asks: true } });
|
|
131
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'archon' } }, { pool, getFocusVersionId: async () => null });
|
|
132
|
+
const data = pool.seen.filter((q) => !/to_regclass|date_trunc\('week'|FROM versions/.test(q.sql));
|
|
133
|
+
assert.ok(data.length >= 8, 'the summary reads areas, weeks, leads, people, ideas, govern, sites and waiting');
|
|
134
|
+
for (const q of data) {
|
|
135
|
+
const bounded = /LIMIT \d+/.test(q.sql) || /goal_id = ANY\(\$1::bigint\[\]\)/.test(q.sql) || /(shipped_at|captured_at|cast_at|resolved_at) >= \$1/.test(q.sql) || /b\.id = ANY/.test(q.sql);
|
|
136
|
+
assert.ok(bounded, `unbounded read:\n${q.sql}`);
|
|
137
|
+
assert.doesNotMatch(q.sql, /description|body_md|value_summary/, 'the city never downloads prose it does not draw');
|
|
138
|
+
}
|
|
139
|
+
assert.ok(data.filter((q) => /FROM tasks t/.test(q.sql) && !/claims c/.test(q.sql)).every((q) => /GROUP BY/.test(q.sql)), 'task reads come back as counts, not rows');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await test('visibility comes from the signed-in viewer: membership or Archon, never the query', async () => {
|
|
143
|
+
const metic = fakePool();
|
|
144
|
+
await city.readCity({ weeks: 10, viewer: { id: '42', rank: 'metic' } }, { pool: metic, getFocusVersionId: async () => null });
|
|
145
|
+
const q = metic.seen.find((x) => /FROM goals g/.test(x.sql));
|
|
146
|
+
assert.match(q.sql, /visibility IS DISTINCT FROM 'private'/);
|
|
147
|
+
assert.match(q.sql, /goal_members gm WHERE gm\.goal_id = g\.id AND gm\.builder_id = \$3/);
|
|
148
|
+
assert.deepEqual(q.params.slice(1), [false, '42']);
|
|
149
|
+
const archon = fakePool();
|
|
150
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'archon' } }, { pool: archon, getFocusVersionId: async () => null });
|
|
151
|
+
assert.equal(archon.seen.find((x) => /FROM goals g/.test(x.sql)).params[1], true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await test('the independent reads run together, not one after another', async () => {
|
|
155
|
+
let inFlight = 0; let peak = 0;
|
|
156
|
+
const pool = fakePool({ tables: { government_board_votes: true, autonomy_runner_heartbeat: true, government_asks: true } });
|
|
157
|
+
const orig = pool.query.bind(pool);
|
|
158
|
+
pool.query = async (sql, params) => { inFlight += 1; peak = Math.max(peak, inFlight); await new Promise((r) => setTimeout(r, 5)); inFlight -= 1; return orig(sql, params); };
|
|
159
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'archon' } }, { pool, getFocusVersionId: async () => null });
|
|
160
|
+
assert.ok(peak >= 8, `the per-area and per-person reads overlap (peak ${peak})`);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await test('"waits on you" is the docket\'s routing when the government port answers, for any viewer it routes to', async () => {
|
|
164
|
+
const asked = [];
|
|
165
|
+
const government = { yoursByGoal: async (v) => { asked.push(v); return { available: true, goals: { 10: 2, 99: 5, 20: 0 } }; } };
|
|
166
|
+
const pool = fakePool({ tables: { government_asks: true } });
|
|
167
|
+
const r = await city.readCity({ weeks: 10, viewer: { id: '8', rank: 'metic' } }, { pool, government, getFocusVersionId: async () => null });
|
|
168
|
+
assert.deepEqual(asked, [{ id: '8', rank: 'metic' }], 'the port is asked once, for this viewer');
|
|
169
|
+
assert.deepEqual(r.waiting, [{ goal_id: '10', n: 2 }], 'a seated Metic gets their dots; a goal that is not an area is dropped');
|
|
170
|
+
assert.ok(!pool.seen.some((q) => /JOIN task_blockers/.test(q.sql)), 'the approximation is not read when the docket answered');
|
|
171
|
+
const none = await city.readCity({ weeks: 10, viewer: null }, { pool: fakePool(), government, getFocusVersionId: async () => null });
|
|
172
|
+
assert.deepEqual([none.waiting, asked.length], [[], 1], 'a signed-out viewer is never asked about');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test('"waits on you" is held per viewer for a short window, so a reload of Home costs no docket reads', async () => {
|
|
176
|
+
let calls = 0; let t = 1000;
|
|
177
|
+
const government = { yoursByGoal: async () => { calls += 1; return { available: true, goals: { 10: 1 } }; } };
|
|
178
|
+
const run = (viewer) => city.readCity({ weeks: 10, viewer }, { pool: fakePool(), government, now: () => t, getFocusVersionId: async () => null });
|
|
179
|
+
const a = await run({ id: '8', rank: 'metic' });
|
|
180
|
+
t += city.WAITING_TTL_MS - 1;
|
|
181
|
+
const b = await run({ id: '8', rank: 'metic' });
|
|
182
|
+
assert.deepEqual([calls, a.waiting, b.waiting], [1, [{ goal_id: '10', n: 1 }], [{ goal_id: '10', n: 1 }]], 'inside the window: one docket read, the same dots');
|
|
183
|
+
await run({ id: '9', rank: 'metic' });
|
|
184
|
+
assert.equal(calls, 2, 'another viewer is asked for their own answer');
|
|
185
|
+
t += 2;
|
|
186
|
+
await run({ id: '8', rank: 'metic' });
|
|
187
|
+
assert.equal(calls, 3, 'after the window the docket is asked again');
|
|
188
|
+
const failing = { yoursByGoal: async () => ({ available: false, reason: 'unavailable' }) };
|
|
189
|
+
let fcalls = 0; const counted = { yoursByGoal: async (v) => { fcalls += 1; return failing.yoursByGoal(v); } };
|
|
190
|
+
for (let i = 0; i < 2; i += 1) await city.readCity({ weeks: 10, viewer: { id: '8', rank: 'metic' } }, { pool: fakePool(), government: counted, now: () => t, getFocusVersionId: async () => null });
|
|
191
|
+
assert.equal(fcalls, 2, 'an unavailable answer is never held');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await test('"waits on you" falls back to the approximation (Archon only) when the port is absent, older, unavailable or throws', async () => {
|
|
195
|
+
const ports = [null, {}, { yoursByGoal: async () => ({ available: false, reason: 'unavailable' }) }, { yoursByGoal: async () => { throw new Error('db down'); } }];
|
|
196
|
+
for (const government of ports) {
|
|
197
|
+
const metic = fakePool({ tables: { government_asks: true } });
|
|
198
|
+
const m = await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'metic' } }, { pool: metic, government, getFocusVersionId: async () => null });
|
|
199
|
+
assert.ok(!metic.seen.some((q) => /JOIN task_blockers/.test(q.sql)), 'a Metic is not the owner seat: no approximation read');
|
|
200
|
+
assert.deepEqual(m.waiting, []);
|
|
201
|
+
const archon = fakePool({ tables: { government_asks: true } });
|
|
202
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'archon' } }, { pool: archon, government, getFocusVersionId: async () => null });
|
|
203
|
+
const w = archon.seen.find((q) => /JOIN task_blockers/.test(q.sql));
|
|
204
|
+
assert.ok(w, 'an Archon gets the per-area approximation');
|
|
205
|
+
assert.match(w.sql, /a\.waits_on = 'owner'/, 'only blockers whose ask waits on the owner seat (or carry none)');
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await test('the government port\'s yoursByGoal counts, per goal, the goal-shaped matters the docket routes to the viewer', async () => {
|
|
210
|
+
const docket = require(path.join(ROOT, 'modules/government/docket.js'));
|
|
211
|
+
const port = require(path.join(ROOT, 'modules/government/routes/government.js')).governmentPort;
|
|
212
|
+
assert.equal(typeof port.yoursByGoal, 'function', 'the port carries it');
|
|
213
|
+
assert.deepEqual([...docket.GOAL_SOURCES], ['blockers', 'criteria_uat', 'goal_join_requests']);
|
|
214
|
+
const src = (source, building, rows, gate = { anyBuilder: true }) => [source, { source, building, gate, read: async () => rows }];
|
|
215
|
+
const contributions = { sources: new Map([
|
|
216
|
+
src('blockers', 'planning', [{ id: 7, title: 'Pick a host', state: 'needs' }, { id: 8, title: 'Token scope', state: 'needs' }, { id: 9, title: 'Moving one', state: 'moving' }]),
|
|
217
|
+
src('criteria_uat', 'review', [{ id: 31, title: 'Sign off C3', state: 'needs' }], { permission: 'criterion.review' }),
|
|
218
|
+
src('goal_join_requests', 'people', [{ id: 55, title: 'Bo asks to join', state: 'needs' }]),
|
|
219
|
+
src('core_update', 'release', [{ id: 1, title: 'Update', state: 'needs' }]), // not goal-shaped: never read
|
|
220
|
+
]) };
|
|
221
|
+
const seats = { leads: [{ building: 'review', builder_id: 8 }], owner: { builders: [{ id: 7 }] }, holders: {}, access: {} };
|
|
222
|
+
const asks = new Map([['blockers:8', { waits_on: 'lead:review' }]]);
|
|
223
|
+
let mapped = null;
|
|
224
|
+
const goalsOf = async (q) => {
|
|
225
|
+
mapped = q;
|
|
226
|
+
const rows = { 'blockers:7': ['10', '20'], 'blockers:8': ['10'], 'criteria_uat:31': ['20'], 'goal_join_requests:55': ['30'] };
|
|
227
|
+
return [...q.blockers.map((id) => `blockers:${id}`), ...q.criteria.map((id) => `criteria_uat:${id}`), ...q.joinRequests.map((id) => `goal_join_requests:${id}`)]
|
|
228
|
+
.flatMap((m) => (rows[m] || []).map((g) => ({ matter_id: m, goal_id: g })));
|
|
229
|
+
};
|
|
230
|
+
const opts = (permissions) => ({ contributions, permissions, readAsks: async () => asks, readSeats: async () => seats, goalsOf });
|
|
231
|
+
|
|
232
|
+
// The owner (no Lead in Planning or People): the ask-less blocker and the join request fall to them.
|
|
233
|
+
const owner = await docket.yoursByGoal({ id: 7, rank: 'archon' }, opts(['board.vote.cast', 'criterion.review']));
|
|
234
|
+
assert.deepEqual(owner, { available: true, goals: { 10: 1, 20: 1, 30: 1 } });
|
|
235
|
+
assert.deepEqual(mapped, { blockers: ['7'], criteria: [], joinRequests: ['55'] }, 'only matters routed to the viewer are mapped');
|
|
236
|
+
|
|
237
|
+
// The seated Review Lead: the blocker whose ask names their seat, and the criterion at their building.
|
|
238
|
+
const lead = await docket.yoursByGoal({ id: 8, rank: 'metic' }, opts(['board.vote.cast', 'criterion.review']));
|
|
239
|
+
assert.deepEqual(lead, { available: true, goals: { 10: 1, 20: 1 } });
|
|
240
|
+
|
|
241
|
+
// Without the criterion atom the source is gated out, exactly as on the docket.
|
|
242
|
+
const gated = await docket.yoursByGoal({ id: 8, rank: 'metic' }, opts(['board.vote.cast']));
|
|
243
|
+
assert.deepEqual(gated.goals, { 10: 1 });
|
|
244
|
+
|
|
245
|
+
// Without the docket's own atom nothing is routed (docketFor's rule).
|
|
246
|
+
assert.deepEqual(await docket.yoursByGoal({ id: 7 }, opts([])), { available: true, goals: {} });
|
|
247
|
+
// Unreadable seats are said, never a guessed zero.
|
|
248
|
+
assert.deepEqual(await docket.yoursByGoal({ id: 7 }, { ...opts(['board.vote.cast']), readSeats: async () => null }), { available: false, reason: 'unavailable' });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await test('government and autonomy tables are probed first: switched off, they are never named', async () => {
|
|
252
|
+
const pool = fakePool({ tables: {} });
|
|
253
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'archon' } }, { pool, getFocusVersionId: async () => null });
|
|
254
|
+
const all = pool.seen.filter((q) => !/to_regclass/.test(q.sql)).map((q) => q.sql).join('\n');
|
|
255
|
+
assert.doesNotMatch(all, /government_board_votes|autonomy_runner_heartbeat|government_asks/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await test('an Xenos viewer never sees skill-friction ideas', async () => {
|
|
259
|
+
const pool = fakePool();
|
|
260
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'xenos' } }, { pool, getFocusVersionId: async () => null });
|
|
261
|
+
const ideaReads = pool.seen.filter((q) => /FROM idea_inbox/.test(q.sql));
|
|
262
|
+
assert.equal(ideaReads.length, 2);
|
|
263
|
+
for (const q of ideaReads) assert.match(q.sql, /i\.kind <> 'skill-friction' AND i\.title NOT LIKE '\[skill-friction\]%'/);
|
|
264
|
+
const metic = fakePool();
|
|
265
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'metic' } }, { pool: metic, getFocusVersionId: async () => null });
|
|
266
|
+
for (const q of metic.seen.filter((x) => /FROM idea_inbox/.test(x.sql))) assert.doesNotMatch(q.sql, /skill-friction/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await test('no version being built: no areas are read and the city has nothing to draw', async () => {
|
|
270
|
+
const pool = fakePool();
|
|
271
|
+
pool.query = ((orig) => async (sql, params) => (/FROM versions/.test(sql) ? { rows: [] } : orig.call(pool, sql, params)))(pool.query);
|
|
272
|
+
const r = await city.readCity({ weeks: 10, viewer: null }, { pool, getFocusVersionId: async () => null });
|
|
273
|
+
assert.equal(r.version, null);
|
|
274
|
+
assert.deepEqual(r.areas, []);
|
|
275
|
+
assert.ok(!pool.seen.some((q) => /FROM goals g/.test(q.sql)));
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// 3. the handler
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
function fakeRes() {
|
|
283
|
+
const res = { headers: {}, body: null, failed: null };
|
|
284
|
+
res.set = (k, v) => { res.headers[k] = v; return res; };
|
|
285
|
+
res.json = (b) => { res.body = b; return res; };
|
|
286
|
+
res.fail = (code, o) => { res.failed = { code, status: o.status }; return res; };
|
|
287
|
+
return res;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
await test('the handler shapes the read for the caller, privately cached', async () => {
|
|
291
|
+
let asked = null;
|
|
292
|
+
const h = city.homeCityHandler({ read: async (q) => { asked = q; return raw(); } });
|
|
293
|
+
const res = fakeRes();
|
|
294
|
+
await h({ builder: { id: '1', rank: 'thetes' }, query: { weeks: '8' } }, res);
|
|
295
|
+
assert.deepEqual(asked, { weeks: '8', viewer: { id: '1', rank: 'thetes' } });
|
|
296
|
+
assert.equal(res.headers['Cache-Control'], 'private, max-age=60');
|
|
297
|
+
assert.equal(res.body.viewer.id, '1');
|
|
298
|
+
assert.equal(res.body.areas.length, 2);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await test('a failed read is a 500, never a half-drawn city', async () => {
|
|
302
|
+
const h = city.homeCityHandler({ read: async () => { throw new Error('db down'); }, log: { error() {} } });
|
|
303
|
+
const res = fakeRes();
|
|
304
|
+
await h({ builder: { id: '1' }, query: {} }, res);
|
|
305
|
+
assert.deepEqual(res.failed, { code: 'home_city_failed', status: 500 });
|
|
306
|
+
assert.equal(res.body, null);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await test('the route is registered by the lifecycle module', () => {
|
|
310
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/lifecycle/module.json'), 'utf8'));
|
|
311
|
+
assert.ok(manifest.contributes.routes.includes('home-city'));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// 4. the drawing (pure)
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
await test('the fixture is a shape the drawing accepts, and the tallest areas stand at the back', () => {
|
|
319
|
+
const m = Draw.cityModel(FIXTURE);
|
|
320
|
+
assert.equal(m.W, 10);
|
|
321
|
+
assert.ok(m.areas.length > 5);
|
|
322
|
+
assert.ok(m.areas[0].total >= m.areas[m.areas.length - 1].total);
|
|
323
|
+
assert.equal(m.areas[0].y, 0.8, 'the tallest tower is in the back row');
|
|
324
|
+
assert.ok(m.areas.slice(5).every((a) => a.y > 0.8), 'the shorter half stands in front');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await test('more areas than the ground holds are reported, not drawn', () => {
|
|
328
|
+
const many = { weeks: [{ start: '2026-08-31' }], areas: Array.from({ length: 20 }, (_, i) => ({ goal_id: String(i), title: `A${i}`, base_build: 20 - i, build: [0], art: [0] })) };
|
|
329
|
+
const m = Draw.cityModel(many);
|
|
330
|
+
assert.equal(m.areas.length, Draw.MAX_AREAS);
|
|
331
|
+
assert.equal(m.unplaced, 20 - Draw.MAX_AREAS);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
await test('the ladder ranks the week by total, and the viewer is appended when below the top seven', () => {
|
|
335
|
+
const people = Array.from({ length: 9 }, (_, i) => ({ id: String(i), name: `P${i}`, build: [9 - i], art: [0], idea: [0], gov: [0] }));
|
|
336
|
+
const m = Draw.cityModel({ weeks: [{ start: '2026-08-31' }], areas: [], people });
|
|
337
|
+
const top = Draw.ladderFor(m, 0, '8');
|
|
338
|
+
assert.deepEqual(top.map((r) => r.id), ['0', '1', '2', '3', '4', '5', '6', '8']);
|
|
339
|
+
assert.equal(top[7].me, true);
|
|
340
|
+
assert.deepEqual(Draw.ladderFor(m, 0, '3').map((r) => r.id).length, 7, 'a viewer in the top seven is not repeated');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await test('the latest week shows who is on site now; an earlier week shows its four busiest areas', () => {
|
|
344
|
+
const m = Draw.cityModel(FIXTURE);
|
|
345
|
+
const now = Draw.sitesFor(m, m.W - 1);
|
|
346
|
+
assert.deepEqual(Object.keys(now).sort(), FIXTURE.now.sites.map((s) => s.goal_id).sort());
|
|
347
|
+
assert.equal(now['1000095'].agent, true, 'a runner working the site marks it an agent\'s');
|
|
348
|
+
const earlier = Draw.sitesFor(m, 3);
|
|
349
|
+
assert.ok(Object.keys(earlier).length <= 4 && Object.keys(earlier).length > 0);
|
|
350
|
+
assert.ok(Object.values(earlier).every((s) => s.agent === false));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// the scene comes back as shapes; walk them the way home-city.js builds them
|
|
354
|
+
function walk(nodes, fn) { for (const n of nodes) { fn(n); walk(n.kids, fn); } }
|
|
355
|
+
function all(nodes) { const out = []; walk(nodes, (n) => out.push(n)); return out; }
|
|
356
|
+
|
|
357
|
+
await test('towers grow week by week, and salmon only appears on the latest week', () => {
|
|
358
|
+
const m = Draw.cityModel(FIXTURE);
|
|
359
|
+
const first = all(Draw.citySvg(m, 0, { mode: 'dark' }).nodes);
|
|
360
|
+
const last = all(Draw.citySvg(m, m.W - 1, { mode: 'dark' }).nodes);
|
|
361
|
+
const shipped = (ns) => ns.map((n) => /^(\d+) shipped/.exec(n.attrs['data-tip-body'] || '')).filter(Boolean).reduce((s, x) => s + Number(x[1]), 0);
|
|
362
|
+
assert.ok(shipped(last) > shipped(first), 'more has shipped by now than by the first week');
|
|
363
|
+
assert.ok(!first.some((n) => n.attrs.fill === 'var(--accent)'), 'what waits on you is now, not then');
|
|
364
|
+
assert.ok(last.some((n) => n.attrs.class === 'hc-wait'));
|
|
365
|
+
assert.ok(last.some((n) => /'s agent$/.test(n.attrs['data-tip'] || '')), 'an agent\'s site is named as one');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await test('ledger text is never markup: titles and names travel as attribute values and text only', () => {
|
|
369
|
+
const hostile = { weeks: [{ start: '2026-08-31' }], areas: [{ goal_id: '1', title: '<img src=x onerror=alert(1)>', base_build: 5, build: [1], art: [0], lead: ['9'] }],
|
|
370
|
+
people: [{ id: '9', name: '"><script>x</script>', build: [1], art: [0], idea: [0], gov: [0] }],
|
|
371
|
+
ideas: [{ id: '3', week: 0, by: '9', title: '<b>idea</b>' }], now: { sites: [{ goal_id: '1', builder_id: '9' }] } };
|
|
372
|
+
const ns = all(Draw.citySvg(Draw.cityModel(hostile), 0, { mode: 'light' }).nodes);
|
|
373
|
+
const SHAPES = new Set(['defs', 'radialGradient', 'stop', 'path', 'line', 'circle', 'g', 'rect', 'text', 'clipPath', 'image']);
|
|
374
|
+
assert.deepEqual([...new Set(ns.map((n) => n.tag))].filter((t) => !SHAPES.has(t)), [], 'only SVG shapes, whatever a title says');
|
|
375
|
+
assert.ok(ns.some((n) => n.attrs['data-tip'] === '<img src=x onerror=alert(1)>'), 'the title rides as an attribute value, set with setAttribute');
|
|
376
|
+
assert.ok(ns.some((n) => n.tag === 'text' && n.text === '"'), 'a name\'s initial is a text node');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await test('both modes draw with their own faces', () => {
|
|
380
|
+
const m = Draw.cityModel(FIXTURE);
|
|
381
|
+
assert.ok(all(Draw.citySvg(m, m.W - 1, { mode: 'dark' }).nodes).some((n) => n.attrs.fill === '#34343a'));
|
|
382
|
+
assert.ok(all(Draw.citySvg(m, m.W - 1, { mode: 'light' }).nodes).some((n) => n.attrs.fill === '#f6f5f8'));
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
summary();
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// tests/home_city_widget.mjs — Home's project city, the real widget in a browser
|
|
2
|
+
// (task 1005054).
|
|
3
|
+
//
|
|
4
|
+
// Runs modules/hall-ui/public/home-city-draw.js + home-city.js in a real page with
|
|
5
|
+
// the read stubbed by the hall-preview fixture: one bounded read per load, the
|
|
6
|
+
// slider redraws towers and ladder for the week it is on, play steps a week at a
|
|
7
|
+
// time from the first to now and stops, and a refused read leaves Home without an
|
|
8
|
+
// empty plate. Skips where no system Edge or Chrome is installed.
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test tests/home_city_widget.mjs
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test, after } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
|
|
21
|
+
const pub = (f) => fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', f), 'utf8');
|
|
22
|
+
const DRAW = pub('home-city-draw.js');
|
|
23
|
+
const WIDGET = pub('home-city.js');
|
|
24
|
+
const CSS = pub('home-city.css');
|
|
25
|
+
const FIXTURE = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'home__city.json'), 'utf8');
|
|
26
|
+
|
|
27
|
+
let browser = null;
|
|
28
|
+
try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
|
|
29
|
+
after(() => browser && browser.close());
|
|
30
|
+
const skip = !browser && 'no system Edge or Chrome';
|
|
31
|
+
|
|
32
|
+
const stubs = (status) => `
|
|
33
|
+
window.__asked = [];
|
|
34
|
+
window.__CITY = ${FIXTURE};
|
|
35
|
+
window.BongosClient = { createClient: () => ({ request: async (m, p) => {
|
|
36
|
+
window.__asked.push(m + ' ' + p);
|
|
37
|
+
if (${status} !== 200) return { ok: false, status: ${status}, data: null };
|
|
38
|
+
return p.startsWith('/api/bongos/home/city') ? { ok: true, status: 200, data: window.__CITY } : { ok: false, status: 404, data: null };
|
|
39
|
+
} }) };
|
|
40
|
+
window.OTBCraftMode = { iconHtml: (k, cls) => '<svg class="' + cls + '" data-icon="' + k + '"></svg>' };
|
|
41
|
+
window.OTB = { contributeHallWidget(w) { window.__widget = w; } };`;
|
|
42
|
+
|
|
43
|
+
async function boot({ status = 200, width = 1280 } = {}) {
|
|
44
|
+
const ctx = await browser.newContext({ viewport: { width, height: 900 } });
|
|
45
|
+
const page = await ctx.newPage();
|
|
46
|
+
await page.clock.install();
|
|
47
|
+
await page.setContent(`<!doctype html><html><head><style>${CSS}</style></head><body><section id="home-city" class="scroll home-city" hidden></section></body></html>`);
|
|
48
|
+
await page.addScriptTag({ content: stubs(status) });
|
|
49
|
+
await page.addScriptTag({ content: DRAW });
|
|
50
|
+
await page.addScriptTag({ content: WIDGET });
|
|
51
|
+
await page.evaluate(() => window.__widget.mount(document.getElementById('home-city')));
|
|
52
|
+
return { ctx, page };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const state = (page) => page.evaluate(() => ({
|
|
56
|
+
hidden: document.getElementById('home-city').hidden,
|
|
57
|
+
asked: window.__asked,
|
|
58
|
+
week: document.querySelector('.hc-slider').getAttribute('aria-valuetext'),
|
|
59
|
+
svgLen: document.querySelector('.hc-svg').innerHTML.length,
|
|
60
|
+
rungs: [...document.querySelectorAll('.hc-rung:not([hidden])')].sort((a, b) => parseFloat(a.style.top) - parseFloat(b.style.top)).map((r) => ({ me: r.classList.contains('hc-rung--me'), line: r.querySelector('.hc-line').textContent })),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
test('one bounded read; the city, its ladder and the slider appear on now', { skip }, async () => {
|
|
64
|
+
const { ctx, page } = await boot();
|
|
65
|
+
await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
|
|
66
|
+
const s = await state(page);
|
|
67
|
+
await ctx.close();
|
|
68
|
+
assert.deepEqual(s.asked, ['GET /api/bongos/home/city?weeks=10'], 'Home asks for the summary once and nothing else');
|
|
69
|
+
assert.equal(s.hidden, false);
|
|
70
|
+
assert.equal(s.week, 'this week');
|
|
71
|
+
assert.ok(s.rungs.length >= 5);
|
|
72
|
+
assert.ok(s.rungs.some((r) => r.me), 'the viewer\'s row is marked');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the slider redraws the towers and re-ranks the ladder for the week it is on', { skip }, async () => {
|
|
76
|
+
const { ctx, page } = await boot();
|
|
77
|
+
await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
|
|
78
|
+
const now = await state(page);
|
|
79
|
+
await page.evaluate(() => { const s = document.querySelector('.hc-slider'); s.value = '0'; s.dispatchEvent(new Event('input', { bubbles: true })); });
|
|
80
|
+
const first = await state(page);
|
|
81
|
+
await ctx.close();
|
|
82
|
+
assert.equal(first.week, 'week of 31 Aug');
|
|
83
|
+
assert.notEqual(first.svgLen, now.svgLen, 'the scene was redrawn');
|
|
84
|
+
assert.notDeepEqual(first.rungs, now.rungs, 'the ladder is the first week\'s, not this week\'s');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('play steps a week at a time from the first week to now, then stops', { skip }, async () => {
|
|
88
|
+
const { ctx, page } = await boot();
|
|
89
|
+
await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
|
|
90
|
+
await page.click('.hc-play');
|
|
91
|
+
const atStart = await state(page);
|
|
92
|
+
assert.equal(atStart.week, 'week of 31 Aug');
|
|
93
|
+
assert.equal(await page.evaluate(() => document.querySelector('.hc-play').classList.contains('is-playing')), true);
|
|
94
|
+
await page.clock.runFor(800);
|
|
95
|
+
assert.equal((await state(page)).week, 'week of 7 Sept', 'one step is one week');
|
|
96
|
+
await page.clock.runFor(800 * 12);
|
|
97
|
+
const end = await state(page);
|
|
98
|
+
const playing = await page.evaluate(() => document.querySelector('.hc-play').classList.contains('is-playing'));
|
|
99
|
+
await ctx.close();
|
|
100
|
+
assert.equal(end.week, 'this week');
|
|
101
|
+
assert.equal(playing, false, 'play stops on now');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a refused read leaves the section hidden: never an empty plate', { skip }, async () => {
|
|
105
|
+
const { ctx, page } = await boot({ status: 401 });
|
|
106
|
+
await page.evaluate(() => new Promise((r) => setTimeout(r, 0)));
|
|
107
|
+
await page.clock.runFor(50);
|
|
108
|
+
const s = await state(page);
|
|
109
|
+
await ctx.close();
|
|
110
|
+
assert.equal(s.hidden, true);
|
|
111
|
+
assert.equal(s.rungs.length, 0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a phone width scrolls nothing sideways', { skip }, async () => {
|
|
115
|
+
const { ctx, page } = await boot({ width: 390 });
|
|
116
|
+
await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
|
|
117
|
+
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
118
|
+
await ctx.close();
|
|
119
|
+
assert.equal(over, 0);
|
|
120
|
+
});
|
|
@@ -236,7 +236,7 @@ function extract(src, start, end) {
|
|
|
236
236
|
|
|
237
237
|
await test('the standing card adds the place inside the builder\'s main role, only when it says something new', () => {
|
|
238
238
|
const src = read('modules/hall-ui/public/hall-render.js');
|
|
239
|
-
const code = extract(src, 'const lbState = { rows: [] };', '
|
|
239
|
+
const code = extract(src, 'const lbState = { rows: [] };', ' return { acclaimEvidenceHtml');
|
|
240
240
|
const sandbox = { rows: FIX.leaderboard };
|
|
241
241
|
vm.createContext(sandbox);
|
|
242
242
|
vm.runInContext(`${code}; lbState.rows = rows; this.place = leaderboardPlace; this.text = rolePlaceText;`, sandbox);
|
|
@@ -245,7 +245,8 @@ await test('the standing card adds the place inside the builder\'s main role, on
|
|
|
245
245
|
assert.equal(sandbox.text(art), `#1 as an artist`);
|
|
246
246
|
const top = sandbox.place(FIX.leaderboard[0].github_login);
|
|
247
247
|
assert.equal(sandbox.text(top), '', 'the top engineer is #1 either way, so nothing is added');
|
|
248
|
-
|
|
248
|
+
// task 1005054: the stats strip (and its rank tile) left Home; the Standing chip's card carries the role place
|
|
249
|
+
assert.match(src, /\$\{rolePlaceText\(rollPlace\) \? `<span title="Your place among the/);
|
|
249
250
|
assert.match(read('modules/hall-ui/public/builders.js'), /getJSON\(`\$\{API\}\/public\/leaderboard`\)/, 'the hall reads the list with the roles');
|
|
250
251
|
});
|
|
251
252
|
|