@bongos/core 1.24.29 → 1.24.31
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 +198 -108
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +12 -0
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/api/openapi.json +174 -1
- package/docs/api-reference.md +12 -2
- package/docs/copy-inventory.md +226 -224
- package/docs/copy-registry.json +583 -549
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +18 -37
- package/docs/page-readings.json +306 -361
- package/modules/copy-desk/module.json +1 -1
- package/modules/copy-desk/routes/copy-desk-studio.js +103 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +2 -2
- 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/ai-studio.html +1 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/builders.js +3 -79
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/craft-mode.js +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/hall-render.js +68 -167
- package/modules/hall-ui/public/hall-widgets.js +4 -45
- package/modules/hall-ui/public/home-city-draw.js +583 -0
- package/modules/hall-ui/public/home-city.css +102 -0
- package/modules/hall-ui/public/home-city.js +339 -0
- package/modules/hall-ui/public/home-gallery.css +129 -0
- package/modules/hall-ui/public/home-gallery.js +349 -0
- package/modules/hall-ui/public/home-studio.css +72 -0
- package/modules/hall-ui/public/home-studio.js +203 -0
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +9 -7
- package/modules/hall-ui/public/index.states.json +73 -8
- package/modules/hall-ui/public/market-watch-item.html +1 -1
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.js +6 -1
- package/modules/hall-ui/public/market-watch.html +141 -54
- package/modules/hall-ui/public/market-watch.js +54 -0
- package/modules/hall-ui/public/market-watch.states.json +2 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +2 -3
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +9 -1
- package/modules/hall-ui/public/style.css +74 -27
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/home-city.md +189 -0
- package/modules/hall-ui/records/loader.md +1 -1
- package/modules/hall-ui/records/market-watch-settings.md +16 -3
- package/modules/hall-ui/records/market-watch.md +3 -1
- package/modules/lifecycle/home-city.js +388 -0
- package/modules/lifecycle/home-gallery.js +320 -0
- package/modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql +36 -0
- package/modules/lifecycle/module.json +2 -0
- package/modules/lifecycle/routes/home-city.js +29 -0
- package/modules/lifecycle/routes/home-gallery.js +37 -0
- package/modules/market-watch/routes/settings.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +38 -0
- package/scripts/bongos/leak-scan-allowlist.js +8 -0
- package/scripts/hall-preview/README.md +1 -0
- package/scripts/hall-preview/server.js +10 -0
- package/src/bongos/serve-internal.js +17 -3
- 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_builders_page_pass.mjs +18 -27
- package/tests/hall_home_ships_bounded.mjs +4 -3
- package/tests/hall_loading.mjs +1 -1
- package/tests/hall_nav.mjs +11 -4
- package/tests/hall_page_gate_map.mjs +7 -4
- package/tests/home_bounded_reads.mjs +16 -31
- package/tests/home_city.mjs +580 -0
- package/tests/home_city_widget.mjs +260 -0
- package/tests/home_gallery.mjs +255 -0
- package/tests/home_gallery_widget.mjs +220 -0
- package/tests/home_studio.mjs +169 -0
- package/tests/home_studio_widget.mjs +157 -0
- package/tests/leaderboard_roles.mjs +3 -2
- package/tests/market_watch_pages.mjs +162 -0
- package/tests/nav_permission_atoms.mjs +2 -2
- package/modules/hall-ui/public/market-watch-settings.html +0 -115
- package/modules/hall-ui/public/market-watch-settings.states.json +0 -14
|
@@ -0,0 +1,580 @@
|
|
|
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 Gov = require(path.join(ROOT, 'modules/hall-ui/public/city-draw.js'));
|
|
24
|
+
const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/home__city.json'), 'utf8'));
|
|
25
|
+
|
|
26
|
+
const { test, summary } = makeRunner();
|
|
27
|
+
|
|
28
|
+
const START = new Date('2026-08-31T00:00:00Z');
|
|
29
|
+
function raw(over = {}) {
|
|
30
|
+
return Object.assign({
|
|
31
|
+
N: 4, start: START, version: { id: 'V2', name: 'The ten working areas' },
|
|
32
|
+
areas: [{ goal_id: '10', title: 'Area ten' }, { goal_id: '20', title: 'Area twenty' }],
|
|
33
|
+
areaWeeks: [], leads: [], peopleWeeks: [], ideaCounts: [], ideas: [], gov: [], sites: [], waiting: [],
|
|
34
|
+
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' }],
|
|
35
|
+
}, over);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// 1. the shaping (pure)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
await test('an area splits what came before the window from each week, and art from build', () => {
|
|
43
|
+
const out = city.shapeCity(raw({
|
|
44
|
+
areaWeeks: [
|
|
45
|
+
{ goal_id: '10', wk: -1, art: false, n: 40 }, { goal_id: '10', wk: -1, art: true, n: 3 },
|
|
46
|
+
{ goal_id: '10', wk: 0, art: false, n: 5 }, { goal_id: '10', wk: 3, art: true, n: 2 },
|
|
47
|
+
{ goal_id: '10', wk: 7, art: false, n: 99 }, // outside a four-week window: dropped, never piled on the edge
|
|
48
|
+
{ goal_id: '99', wk: 1, art: false, n: 9 }, // not an area of the version: ignored
|
|
49
|
+
],
|
|
50
|
+
}), null);
|
|
51
|
+
const a = out.areas.find((x) => x.goal_id === '10');
|
|
52
|
+
assert.equal(a.base_build, 40); assert.equal(a.base_art, 3);
|
|
53
|
+
assert.deepEqual(a.build, [5, 0, 0, 0]); assert.deepEqual(a.art, [0, 0, 0, 2]);
|
|
54
|
+
assert.deepEqual(out.weeks.map((w) => w.start), ['2026-08-31', '2026-09-07', '2026-09-14', '2026-09-21']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await test('people are only known builders; the viewer is always there, even with a quiet week', () => {
|
|
58
|
+
const out = city.shapeCity(raw({
|
|
59
|
+
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 }],
|
|
60
|
+
ideaCounts: [{ id: '2', wk: 1, n: 3 }], gov: [{ id: '1', wk: 3, n: 2 }],
|
|
61
|
+
}), { id: '2', rank: 'metic' });
|
|
62
|
+
const ana = out.people.find((p) => p.id === '1'), bo = out.people.find((p) => p.id === '2');
|
|
63
|
+
assert.deepEqual(ana.build, [0, 0, 4, 0]); assert.deepEqual(ana.art, [0, 0, 1, 0]); assert.deepEqual(ana.gov, [0, 0, 0, 2]);
|
|
64
|
+
assert.deepEqual(bo.idea, [0, 3, 0, 0]);
|
|
65
|
+
assert.equal(bo.name, 'bo', 'no display name falls back to the login');
|
|
66
|
+
assert.ok(!out.people.some((p) => p.id === '77'), 'a machine principal or deleted builder (not in builders) is never drawn');
|
|
67
|
+
assert.equal(out.viewer.id, '2');
|
|
68
|
+
assert.ok(out.people.every((p) => p.agent === false), 'a person is never an agent: agents are a site fact');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await test('leads, ideas and sites keep only what the city can place', () => {
|
|
72
|
+
const out = city.shapeCity(raw({
|
|
73
|
+
leads: [{ goal_id: '10', wk: 1, shipped_by: '1' }, { goal_id: '20', wk: 1, shipped_by: '77' }],
|
|
74
|
+
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' }],
|
|
75
|
+
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 }],
|
|
76
|
+
waiting: [{ goal_id: '20', n: 4 }, { goal_id: '10', n: 0 }, { goal_id: '99', n: 2 }],
|
|
77
|
+
}), null);
|
|
78
|
+
assert.equal(out.areas[0].lead[1], '1'); assert.equal(out.areas[1].lead[1], null);
|
|
79
|
+
assert.deepEqual(out.ideas.map((i) => [i.id, i.goal_id, i.by]), [['5', '20', '1'], ['7', null, null]]);
|
|
80
|
+
assert.deepEqual(out.now.sites, [{ goal_id: '10', builder_id: '1', agent: true }]);
|
|
81
|
+
assert.deepEqual(out.now.waiting, [{ goal_id: '20', count: 4 }]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await test('a private area the viewer may not read keeps its tower but loses its name, and so do its ideas (ADR 0112)', () => {
|
|
85
|
+
const r = raw({
|
|
86
|
+
areas: [{ goal_id: '10', title: 'Area ten', visible: true }, { goal_id: '20', title: 'Secret plan', visible: false }],
|
|
87
|
+
areaWeeks: [{ goal_id: '20', wk: 1, art: false, n: 3 }],
|
|
88
|
+
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' }],
|
|
89
|
+
});
|
|
90
|
+
const d = city.shapeCity(r, { id: '1' });
|
|
91
|
+
const hidden = d.areas.find((a) => a.goal_id === '20');
|
|
92
|
+
assert.equal(hidden.title, null, 'the private name never leaves the server');
|
|
93
|
+
assert.deepEqual(hidden.build, [0, 3, 0, 0], 'its counts are structure and stay');
|
|
94
|
+
assert.equal(d.areas.find((a) => a.goal_id === '10').title, 'Area ten');
|
|
95
|
+
assert.equal(d.ideas.find((x) => x.id === '7').title, null, 'an idea tied to it loses its title too');
|
|
96
|
+
assert.equal(d.ideas.find((x) => x.id === '8').title, 'Open idea');
|
|
97
|
+
assert.ok(!JSON.stringify(d).includes('Secret'), 'no private prose anywhere in the payload');
|
|
98
|
+
const m = Draw.cityModel(d);
|
|
99
|
+
assert.equal(m.areas.find((a) => a.id === '20').title, 'A private area');
|
|
100
|
+
assert.equal(m.ideas.find((x) => x.id === '7').title, 'A private idea');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('weeks are clamped: never fewer than four, never more than twenty-six', () => {
|
|
104
|
+
assert.equal(city.clampWeeks(undefined), city.DEFAULT_WEEKS);
|
|
105
|
+
assert.equal(city.clampWeeks('2'), 4);
|
|
106
|
+
assert.equal(city.clampWeeks('500'), 26);
|
|
107
|
+
assert.equal(city.clampWeeks('abc'), city.DEFAULT_WEEKS);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// 2. the reads: bounded, viewer-scoped, module-optional
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function fakePool({ tables = {} } = {}) {
|
|
115
|
+
const seen = [];
|
|
116
|
+
return {
|
|
117
|
+
seen,
|
|
118
|
+
async query(sql, params) {
|
|
119
|
+
seen.push({ sql, params });
|
|
120
|
+
if (/to_regclass/.test(sql)) return { rows: [{ ok: !!tables[String(params[0]).replace('public.', '')] }] };
|
|
121
|
+
if (/date_trunc\('week'/.test(sql)) return { rows: [{ start: START }] };
|
|
122
|
+
if (/FROM versions/.test(sql)) return { rows: [{ id: 'V2', name: 'V2' }] };
|
|
123
|
+
if (/FROM goals g/.test(sql)) return { rows: [{ goal_id: '10', title: 'Area ten' }] };
|
|
124
|
+
if (/FROM builders b/.test(sql)) return { rows: [] };
|
|
125
|
+
return { rows: [] };
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await test('every read is bounded by the window, the areas or a LIMIT, and never selects a task body', async () => {
|
|
131
|
+
const pool = fakePool({ tables: { government_board_votes: true, autonomy_runner_heartbeat: true, government_asks: true, ideas_votes: true } });
|
|
132
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'archon' } }, { pool, getFocusVersionId: async () => null });
|
|
133
|
+
const data = pool.seen.filter((q) => !/to_regclass|date_trunc\('week'|FROM versions/.test(q.sql));
|
|
134
|
+
assert.ok(data.length >= 8, 'the summary reads areas, weeks, leads, people, ideas, govern, sites and waiting');
|
|
135
|
+
for (const q of data) {
|
|
136
|
+
const bounded = /LIMIT \d+/.test(q.sql) || /goal_id = ANY\(\$1::bigint\[\]\)/.test(q.sql) || /(shipped_at|captured_at|cast_at|resolved_at|recorded_at) >= \$1/.test(q.sql) || /b\.id = ANY/.test(q.sql);
|
|
137
|
+
assert.ok(bounded, `unbounded read:\n${q.sql}`);
|
|
138
|
+
assert.doesNotMatch(q.sql, /description|body_md|value_summary/, 'the city never downloads prose it does not draw');
|
|
139
|
+
}
|
|
140
|
+
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');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await test('visibility comes from the signed-in viewer: membership or Archon, never the query', async () => {
|
|
144
|
+
const metic = fakePool();
|
|
145
|
+
await city.readCity({ weeks: 10, viewer: { id: '42', rank: 'metic' } }, { pool: metic, getFocusVersionId: async () => null });
|
|
146
|
+
const q = metic.seen.find((x) => /FROM goals g/.test(x.sql));
|
|
147
|
+
assert.match(q.sql, /visibility IS DISTINCT FROM 'private'/);
|
|
148
|
+
assert.match(q.sql, /goal_members gm WHERE gm\.goal_id = g\.id AND gm\.builder_id = \$3/);
|
|
149
|
+
assert.deepEqual(q.params.slice(1), [false, '42']);
|
|
150
|
+
const archon = fakePool();
|
|
151
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'archon' } }, { pool: archon, getFocusVersionId: async () => null });
|
|
152
|
+
assert.equal(archon.seen.find((x) => /FROM goals g/.test(x.sql)).params[1], true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await test('the independent reads run together, not one after another', async () => {
|
|
156
|
+
let inFlight = 0; let peak = 0;
|
|
157
|
+
const pool = fakePool({ tables: { government_board_votes: true, autonomy_runner_heartbeat: true, government_asks: true } });
|
|
158
|
+
const orig = pool.query.bind(pool);
|
|
159
|
+
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); };
|
|
160
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'archon' } }, { pool, getFocusVersionId: async () => null });
|
|
161
|
+
assert.ok(peak >= 8, `the per-area and per-person reads overlap (peak ${peak})`);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await test('"waits on you" is the docket\'s routing when the government port answers, for any viewer it routes to', async () => {
|
|
165
|
+
const asked = [];
|
|
166
|
+
const government = { yoursByGoal: async (v) => { asked.push(v); return { available: true, goals: { 10: 2, 99: 5, 20: 0 } }; } };
|
|
167
|
+
const pool = fakePool({ tables: { government_asks: true } });
|
|
168
|
+
const r = await city.readCity({ weeks: 10, viewer: { id: '8', rank: 'metic' } }, { pool, government, getFocusVersionId: async () => null });
|
|
169
|
+
assert.deepEqual(asked, [{ id: '8', rank: 'metic' }], 'the port is asked once, for this viewer');
|
|
170
|
+
assert.deepEqual(r.waiting, [{ goal_id: '10', n: 2 }], 'a seated Metic gets their dots; a goal that is not an area is dropped');
|
|
171
|
+
assert.ok(!pool.seen.some((q) => /JOIN task_blockers/.test(q.sql)), 'the approximation is not read when the docket answered');
|
|
172
|
+
const none = await city.readCity({ weeks: 10, viewer: null }, { pool: fakePool(), government, getFocusVersionId: async () => null });
|
|
173
|
+
assert.deepEqual([none.waiting, asked.length], [[], 1], 'a signed-out viewer is never asked about');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await test('"waits on you" is held per viewer for a short window, so a reload of Home costs no docket reads', async () => {
|
|
177
|
+
let calls = 0; let t = 1000;
|
|
178
|
+
const government = { yoursByGoal: async () => { calls += 1; return { available: true, goals: { 10: 1 } }; } };
|
|
179
|
+
const run = (viewer) => city.readCity({ weeks: 10, viewer }, { pool: fakePool(), government, now: () => t, getFocusVersionId: async () => null });
|
|
180
|
+
const a = await run({ id: '8', rank: 'metic' });
|
|
181
|
+
t += city.WAITING_TTL_MS - 1;
|
|
182
|
+
const b = await run({ id: '8', rank: 'metic' });
|
|
183
|
+
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');
|
|
184
|
+
await run({ id: '9', rank: 'metic' });
|
|
185
|
+
assert.equal(calls, 2, 'another viewer is asked for their own answer');
|
|
186
|
+
t += 2;
|
|
187
|
+
await run({ id: '8', rank: 'metic' });
|
|
188
|
+
assert.equal(calls, 3, 'after the window the docket is asked again');
|
|
189
|
+
const failing = { yoursByGoal: async () => ({ available: false, reason: 'unavailable' }) };
|
|
190
|
+
let fcalls = 0; const counted = { yoursByGoal: async (v) => { fcalls += 1; return failing.yoursByGoal(v); } };
|
|
191
|
+
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 });
|
|
192
|
+
assert.equal(fcalls, 2, 'an unavailable answer is never held');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await test('"waits on you" falls back to the approximation (Archon only) when the port is absent, older, unavailable or throws', async () => {
|
|
196
|
+
const ports = [null, {}, { yoursByGoal: async () => ({ available: false, reason: 'unavailable' }) }, { yoursByGoal: async () => { throw new Error('db down'); } }];
|
|
197
|
+
for (const government of ports) {
|
|
198
|
+
const metic = fakePool({ tables: { government_asks: true } });
|
|
199
|
+
const m = await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'metic' } }, { pool: metic, government, getFocusVersionId: async () => null });
|
|
200
|
+
assert.ok(!metic.seen.some((q) => /JOIN task_blockers/.test(q.sql)), 'a Metic is not the owner seat: no approximation read');
|
|
201
|
+
assert.deepEqual(m.waiting, []);
|
|
202
|
+
const archon = fakePool({ tables: { government_asks: true } });
|
|
203
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'archon' } }, { pool: archon, government, getFocusVersionId: async () => null });
|
|
204
|
+
const w = archon.seen.find((q) => /JOIN task_blockers/.test(q.sql));
|
|
205
|
+
assert.ok(w, 'an Archon gets the per-area approximation');
|
|
206
|
+
assert.match(w.sql, /a\.waits_on = 'owner'/, 'only blockers whose ask waits on the owner seat (or carry none)');
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await test('the government port\'s yoursByGoal counts, per goal, the goal-shaped matters the docket routes to the viewer', async () => {
|
|
211
|
+
const docket = require(path.join(ROOT, 'modules/government/docket.js'));
|
|
212
|
+
const port = require(path.join(ROOT, 'modules/government/routes/government.js')).governmentPort;
|
|
213
|
+
assert.equal(typeof port.yoursByGoal, 'function', 'the port carries it');
|
|
214
|
+
assert.deepEqual([...docket.GOAL_SOURCES], ['blockers', 'criteria_uat', 'goal_join_requests']);
|
|
215
|
+
const src = (source, building, rows, gate = { anyBuilder: true }) => [source, { source, building, gate, read: async () => rows }];
|
|
216
|
+
const contributions = { sources: new Map([
|
|
217
|
+
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' }]),
|
|
218
|
+
src('criteria_uat', 'review', [{ id: 31, title: 'Sign off C3', state: 'needs' }], { permission: 'criterion.review' }),
|
|
219
|
+
src('goal_join_requests', 'people', [{ id: 55, title: 'Bo asks to join', state: 'needs' }]),
|
|
220
|
+
src('core_update', 'release', [{ id: 1, title: 'Update', state: 'needs' }]), // not goal-shaped: never read
|
|
221
|
+
]) };
|
|
222
|
+
const seats = { leads: [{ building: 'review', builder_id: 8 }], owner: { builders: [{ id: 7 }] }, holders: {}, access: {} };
|
|
223
|
+
const asks = new Map([['blockers:8', { waits_on: 'lead:review' }]]);
|
|
224
|
+
let mapped = null;
|
|
225
|
+
const goalsOf = async (q) => {
|
|
226
|
+
mapped = q;
|
|
227
|
+
const rows = { 'blockers:7': ['10', '20'], 'blockers:8': ['10'], 'criteria_uat:31': ['20'], 'goal_join_requests:55': ['30'] };
|
|
228
|
+
return [...q.blockers.map((id) => `blockers:${id}`), ...q.criteria.map((id) => `criteria_uat:${id}`), ...q.joinRequests.map((id) => `goal_join_requests:${id}`)]
|
|
229
|
+
.flatMap((m) => (rows[m] || []).map((g) => ({ matter_id: m, goal_id: g })));
|
|
230
|
+
};
|
|
231
|
+
const opts = (permissions) => ({ contributions, permissions, readAsks: async () => asks, readSeats: async () => seats, goalsOf });
|
|
232
|
+
|
|
233
|
+
// The owner (no Lead in Planning or People): the ask-less blocker and the join request fall to them.
|
|
234
|
+
const owner = await docket.yoursByGoal({ id: 7, rank: 'archon' }, opts(['board.vote.cast', 'criterion.review']));
|
|
235
|
+
assert.deepEqual(owner, { available: true, goals: { 10: 1, 20: 1, 30: 1 } });
|
|
236
|
+
assert.deepEqual(mapped, { blockers: ['7'], criteria: [], joinRequests: ['55'] }, 'only matters routed to the viewer are mapped');
|
|
237
|
+
|
|
238
|
+
// The seated Review Lead: the blocker whose ask names their seat, and the criterion at their building.
|
|
239
|
+
const lead = await docket.yoursByGoal({ id: 8, rank: 'metic' }, opts(['board.vote.cast', 'criterion.review']));
|
|
240
|
+
assert.deepEqual(lead, { available: true, goals: { 10: 1, 20: 1 } });
|
|
241
|
+
|
|
242
|
+
// Without the criterion atom the source is gated out, exactly as on the docket.
|
|
243
|
+
const gated = await docket.yoursByGoal({ id: 8, rank: 'metic' }, opts(['board.vote.cast']));
|
|
244
|
+
assert.deepEqual(gated.goals, { 10: 1 });
|
|
245
|
+
|
|
246
|
+
// Without the docket's own atom nothing is routed (docketFor's rule).
|
|
247
|
+
assert.deepEqual(await docket.yoursByGoal({ id: 7 }, opts([])), { available: true, goals: {} });
|
|
248
|
+
// Unreadable seats are said, never a guessed zero.
|
|
249
|
+
assert.deepEqual(await docket.yoursByGoal({ id: 7 }, { ...opts(['board.vote.cast']), readSeats: async () => null }), { available: false, reason: 'unavailable' });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await test('government and autonomy tables are probed first: switched off, they are never named', async () => {
|
|
253
|
+
const pool = fakePool({ tables: {} });
|
|
254
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'archon' } }, { pool, getFocusVersionId: async () => null });
|
|
255
|
+
const all = pool.seen.filter((q) => !/to_regclass/.test(q.sql)).map((q) => q.sql).join('\n');
|
|
256
|
+
assert.doesNotMatch(all, /government_board_votes|autonomy_runner_heartbeat|government_asks/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await test('an Xenos viewer never sees skill-friction ideas', async () => {
|
|
260
|
+
const pool = fakePool();
|
|
261
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'xenos' } }, { pool, getFocusVersionId: async () => null });
|
|
262
|
+
const ideaReads = pool.seen.filter((q) => /FROM idea_inbox/.test(q.sql));
|
|
263
|
+
assert.equal(ideaReads.length, 2);
|
|
264
|
+
for (const q of ideaReads) assert.match(q.sql, /i\.kind <> 'skill-friction' AND i\.title NOT LIKE '\[skill-friction\]%'/);
|
|
265
|
+
const metic = fakePool();
|
|
266
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'metic' } }, { pool: metic, getFocusVersionId: async () => null });
|
|
267
|
+
for (const q of metic.seen.filter((x) => /FROM idea_inbox/.test(x.sql))) assert.doesNotMatch(q.sql, /skill-friction/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await test('no version being built: no areas are read and the city has nothing to draw', async () => {
|
|
271
|
+
const pool = fakePool();
|
|
272
|
+
pool.query = ((orig) => async (sql, params) => (/FROM versions/.test(sql) ? { rows: [] } : orig.call(pool, sql, params)))(pool.query);
|
|
273
|
+
const r = await city.readCity({ weeks: 10, viewer: null }, { pool, getFocusVersionId: async () => null });
|
|
274
|
+
assert.equal(r.version, null);
|
|
275
|
+
assert.deepEqual(r.areas, []);
|
|
276
|
+
assert.ok(!pool.seen.some((q) => /FROM goals g/.test(q.sql)));
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// 3. the handler
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
function fakeRes() {
|
|
284
|
+
const res = { headers: {}, body: null, failed: null };
|
|
285
|
+
res.set = (k, v) => { res.headers[k] = v; return res; };
|
|
286
|
+
res.json = (b) => { res.body = b; return res; };
|
|
287
|
+
res.fail = (code, o) => { res.failed = { code, status: o.status }; return res; };
|
|
288
|
+
return res;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
await test('the handler shapes the read for the caller, privately cached', async () => {
|
|
292
|
+
let asked = null;
|
|
293
|
+
const h = city.homeCityHandler({ read: async (q) => { asked = q; return raw(); } });
|
|
294
|
+
const res = fakeRes();
|
|
295
|
+
await h({ builder: { id: '1', rank: 'thetes' }, query: { weeks: '8' } }, res);
|
|
296
|
+
assert.deepEqual(asked, { weeks: '8', viewer: { id: '1', rank: 'thetes' } });
|
|
297
|
+
assert.equal(res.headers['Cache-Control'], 'private, max-age=60');
|
|
298
|
+
assert.equal(res.body.viewer.id, '1');
|
|
299
|
+
assert.equal(res.body.areas.length, 2);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await test('a failed read is a 500, never a half-drawn city', async () => {
|
|
303
|
+
const h = city.homeCityHandler({ read: async () => { throw new Error('db down'); }, log: { error() {} } });
|
|
304
|
+
const res = fakeRes();
|
|
305
|
+
await h({ builder: { id: '1' }, query: {} }, res);
|
|
306
|
+
assert.deepEqual(res.failed, { code: 'home_city_failed', status: 500 });
|
|
307
|
+
assert.equal(res.body, null);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await test('the route is registered by the lifecycle module', () => {
|
|
311
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/lifecycle/module.json'), 'utf8'));
|
|
312
|
+
assert.ok(manifest.contributes.routes.includes('home-city'));
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// 4. the drawing (pure)
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
await test('the fixture is a shape the drawing accepts, and the tallest areas stand at the back', () => {
|
|
320
|
+
const m = Draw.cityModel(FIXTURE);
|
|
321
|
+
assert.equal(m.W, 10);
|
|
322
|
+
assert.ok(m.areas.length > 5);
|
|
323
|
+
assert.ok(m.areas[0].total >= m.areas[m.areas.length - 1].total);
|
|
324
|
+
const back = (a) => a.x + a.y;
|
|
325
|
+
assert.deepEqual([m.areas[0].x, m.areas[0].y], Draw.RING[0], 'the tallest tower takes the back corner');
|
|
326
|
+
assert.ok(m.areas.every((a, n) => n === 0 || back(a) >= back(m.areas[n - 1])), 'each shorter area stands no farther back than a taller one');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await test('the ring of plots stays off the civic core and the museum, and no two plots overlap (v16)', () => {
|
|
330
|
+
const F = 1.8, C = Draw.CORE, MUSEUM = { x0: 4.6, y0: 10.9, x1: 11.4, y1: 19.2 };
|
|
331
|
+
const hit = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
|
|
332
|
+
const plots = Draw.RING.map(([x, y]) => ({ x0: x, y0: y, x1: x + F, y1: y + F }));
|
|
333
|
+
plots.forEach((p, n) => {
|
|
334
|
+
assert.ok(!hit(p, C), `plot ${n} stands clear of the civic plaza`);
|
|
335
|
+
assert.ok(!hit(p, MUSEUM), `plot ${n} stands clear of the museum and its garden`);
|
|
336
|
+
plots.slice(n + 1).forEach((q) => assert.ok(!hit(p, q), 'plots do not overlap'));
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await test('more areas than the ground holds are reported, not drawn', () => {
|
|
341
|
+
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] })) };
|
|
342
|
+
const m = Draw.cityModel(many);
|
|
343
|
+
assert.equal(m.areas.length, Draw.MAX_AREAS);
|
|
344
|
+
assert.equal(m.unplaced, 20 - Draw.MAX_AREAS);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await test('the ladder ranks the week by total, and the viewer is appended when below the top seven', () => {
|
|
348
|
+
const people = Array.from({ length: 9 }, (_, i) => ({ id: String(i), name: `P${i}`, build: [9 - i], art: [0], idea: [0], gov: [0] }));
|
|
349
|
+
const m = Draw.cityModel({ weeks: [{ start: '2026-08-31' }], areas: [], people });
|
|
350
|
+
const top = Draw.ladderFor(m, 0, '8');
|
|
351
|
+
assert.deepEqual(top.map((r) => r.id), ['0', '1', '2', '3', '4', '5', '6', '8']);
|
|
352
|
+
assert.equal(top[7].me, true);
|
|
353
|
+
assert.deepEqual(Draw.ladderFor(m, 0, '3').map((r) => r.id).length, 7, 'a viewer in the top seven is not repeated');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await test('the latest week shows who is on site now; an earlier week shows its four busiest areas', () => {
|
|
357
|
+
const m = Draw.cityModel(FIXTURE);
|
|
358
|
+
const now = Draw.sitesFor(m, m.W - 1);
|
|
359
|
+
assert.deepEqual(Object.keys(now).sort(), FIXTURE.now.sites.map((s) => s.goal_id).sort());
|
|
360
|
+
assert.equal(now['1000095'].agent, true, 'a runner working the site marks it an agent\'s');
|
|
361
|
+
const earlier = Draw.sitesFor(m, 3);
|
|
362
|
+
assert.ok(Object.keys(earlier).length <= 4 && Object.keys(earlier).length > 0);
|
|
363
|
+
assert.ok(Object.values(earlier).every((s) => s.agent === false));
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// the scene comes back as shapes; walk them the way home-city.js builds them
|
|
367
|
+
function walk(nodes, fn) { for (const n of nodes) { fn(n); walk(n.kids, fn); } }
|
|
368
|
+
function all(nodes) { const out = []; walk(nodes, (n) => out.push(n)); return out; }
|
|
369
|
+
|
|
370
|
+
await test('towers grow week by week, and salmon only appears on the latest week', () => {
|
|
371
|
+
const m = Draw.cityModel(FIXTURE);
|
|
372
|
+
const first = all(Draw.citySvg(m, 0, { mode: 'dark' }).nodes);
|
|
373
|
+
const last = all(Draw.citySvg(m, m.W - 1, { mode: 'dark' }).nodes);
|
|
374
|
+
const shipped = (ns) => ns.map((n) => /^(\d+) shipped/.exec(n.attrs['data-tip-body'] || '')).filter(Boolean).reduce((s, x) => s + Number(x[1]), 0);
|
|
375
|
+
assert.ok(shipped(last) > shipped(first), 'more has shipped by now than by the first week');
|
|
376
|
+
assert.ok(!first.some((n) => n.attrs.fill === 'var(--accent)'), 'what waits on you is now, not then');
|
|
377
|
+
assert.ok(last.some((n) => n.attrs.class === 'hc-wait'));
|
|
378
|
+
assert.ok(last.some((n) => /'s agent$/.test(n.attrs['data-tip'] || '')), 'an agent\'s site is named as one');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await test('ledger text is never markup: titles and names travel as attribute values and text only', () => {
|
|
382
|
+
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'] }],
|
|
383
|
+
people: [{ id: '9', name: '"><script>x</script>', build: [1], art: [0], idea: [0], gov: [0] }],
|
|
384
|
+
ideas: [{ id: '3', week: 0, by: '9', title: '<b>idea</b>' }], now: { sites: [{ goal_id: '1', builder_id: '9' }] } };
|
|
385
|
+
const ns = all(Draw.citySvg(Draw.cityModel(hostile), 0, { mode: 'light' }).nodes);
|
|
386
|
+
const SHAPES = new Set(['defs', 'radialGradient', 'stop', 'path', 'line', 'circle', 'ellipse', 'g', 'a', 'rect', 'text', 'clipPath', 'image']);
|
|
387
|
+
assert.deepEqual([...new Set(ns.map((n) => n.tag))].filter((t) => !SHAPES.has(t)), [], 'only SVG shapes, whatever a title says');
|
|
388
|
+
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');
|
|
389
|
+
assert.ok(ns.some((n) => n.tag === 'text' && n.text === '"'), 'a name\'s initial is a text node');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// v16 (task 1005058): one city for every craft, its districts as doors
|
|
393
|
+
const linksOf = (ns) => ns.filter((n) => n.tag === 'a');
|
|
394
|
+
await test('the civic core is drawn by city-draw.js itself, and the whole district is ONE door to the street', () => {
|
|
395
|
+
const m = Draw.cityModel(FIXTURE);
|
|
396
|
+
const ns = all(Draw.citySvg(m, m.W - 1, { mode: 'dark', gov: Gov }).nodes);
|
|
397
|
+
const core = linksOf(ns).filter((n) => /\bhc-core\b/.test(n.attrs.class));
|
|
398
|
+
assert.equal(core.length, 1, 'one link for the nine buildings, not nine');
|
|
399
|
+
assert.equal(core[0].attrs.href, Gov.STREET_HREF);
|
|
400
|
+
// the same paths city-draw.js's recipe draws for the Charter, in its own palette
|
|
401
|
+
const k = Gov.isoKit(50, 25, 0, 0, 0.82, Gov.isoTheme('dark'), true), charter = [];
|
|
402
|
+
Gov.isoInstitution(charter, k, 'charter', 0, 0, {});
|
|
403
|
+
const fills = new Set(all([core[0]]).map((n) => n.attrs.fill));
|
|
404
|
+
charter.forEach((p) => assert.ok(fills.has(p.f), 'the core wears the Governor City\'s own faces'));
|
|
405
|
+
assert.ok(all([core[0]]).filter((n) => n.tag === 'path').length > 9 * 3, 'all nine buildings, not a placeholder');
|
|
406
|
+
const bare = all(Draw.citySvg(m, m.W - 1, { mode: 'dark' }).nodes);
|
|
407
|
+
assert.ok(!linksOf(bare).some((n) => /hc-core/.test(n.attrs.class)), 'without city-draw.js loaded the city still draws, just without its core');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
await test('on the builder\'s Home the core and the museum are faded; a craft\'s own district stays in colour', () => {
|
|
411
|
+
const m = Draw.cityModel(FIXTURE);
|
|
412
|
+
const cls = (focus, key) => linksOf(all(Draw.citySvg(m, m.W - 1, { mode: 'light', gov: Gov, focus }).nodes)).find((n) => new RegExp(`\\b${key}\\b`).test(n.attrs.class)).attrs.class;
|
|
413
|
+
assert.match(cls(undefined, 'hc-core'), /hc-quiet/);
|
|
414
|
+
assert.match(cls(undefined, 'hc-museum'), /hc-quiet/);
|
|
415
|
+
assert.doesNotMatch(cls('museum', 'hc-museum'), /hc-quiet/);
|
|
416
|
+
assert.doesNotMatch(cls('core', 'hc-core'), /hc-quiet/);
|
|
417
|
+
const css = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/home-city.css'), 'utf8');
|
|
418
|
+
assert.match(css, /\.hc-quiet\s*\{[^}]*opacity:\s*\.3/, 'faded');
|
|
419
|
+
assert.match(css, /\.hc-quiet:hover,\s*\.hc-svg \.hc-quiet:focus-visible\s*\{[^}]*filter:\s*none/, 'back to colour under the pointer or the keyboard');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
await test('a building opens its goal; the museum and the band of stars are doors to the artist\'s and the ideator\'s Home', () => {
|
|
423
|
+
const m = Draw.cityModel(FIXTURE);
|
|
424
|
+
const ls = linksOf(all(Draw.citySvg(m, m.W - 1, { mode: 'dark', gov: Gov }).nodes));
|
|
425
|
+
const towers = ls.filter((n) => n.attrs.class === 'hc-tower');
|
|
426
|
+
assert.equal(towers.length, m.areas.length);
|
|
427
|
+
towers.forEach((n) => assert.match(n.attrs.href, /^\/goals#\/goal\/\d+$/));
|
|
428
|
+
assert.ok(m.areas.every((a) => towers.some((n) => n.attrs.href === `/goals#/goal/${a.id}`)), 'each area links to its own goal');
|
|
429
|
+
assert.equal(ls.find((n) => /hc-museum/.test(n.attrs.class)).attrs['data-craft-door'], 'artistry');
|
|
430
|
+
const sky = ls.find((n) => /hc-sky/.test(n.attrs.class));
|
|
431
|
+
assert.equal(sky.attrs['data-craft-door'], 'ideating');
|
|
432
|
+
assert.ok(ls.every((n) => n.attrs['aria-label']), 'every door is named for a screen reader');
|
|
433
|
+
const stars = all([sky]).filter((n) => n.tag === 'circle');
|
|
434
|
+
const box = Draw.viewBoxOf(m);
|
|
435
|
+
assert.ok(stars.length > 0 && stars.every((c) => c.attrs.cy < box.OY - 150), 'the stars sit high above the city, apart from it');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await test('a week part-way through grows each tower part-way: the slider glides, it does not jump', () => {
|
|
439
|
+
const m = Draw.cityModel(FIXTURE);
|
|
440
|
+
const a = m.areas.find((x) => x.build[4] + x.art[4] > 0);
|
|
441
|
+
const at = (t) => { const d = Draw.doneAt(a, t); return d.build + d.art; };
|
|
442
|
+
assert.ok(at(3) < at(3.5) && at(3.5) < at(4), 'half a week in is half of that week\'s work');
|
|
443
|
+
assert.equal(at(3.5), (at(3) + at(4)) / 2);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
await test('both modes draw with their own faces', () => {
|
|
447
|
+
const m = Draw.cityModel(FIXTURE);
|
|
448
|
+
assert.ok(all(Draw.citySvg(m, m.W - 1, { mode: 'dark' }).nodes).some((n) => n.attrs.fill === '#34343a'));
|
|
449
|
+
assert.ok(all(Draw.citySvg(m, m.W - 1, { mode: 'light' }).nodes).some((n) => n.attrs.fill === '#f6f5f8'));
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
// the ideator's sky (task 1005059)
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
await test('an idea carries its area, the week it became work, its votes, and whether it waits on the viewer', () => {
|
|
457
|
+
const idea = (o) => Object.assign({ id: '1', title: 'T', by: '2', wk: 3, goal_id: null, aim_id: null, became_wk: null, votes: 0, voted: false, status: 'open' }, o);
|
|
458
|
+
const out = city.shapeCity(raw({
|
|
459
|
+
ideas: [
|
|
460
|
+
idea({ id: 'work', goal_id: '10', aim_id: '20', became_wk: 9, votes: 4 }), // became work: its task's goal wins over the aim; the week is clamped to the window
|
|
461
|
+
idea({ id: 'aim', aim_id: '20', wk: 2 }), // aimed: placed in that area, not yet work
|
|
462
|
+
idea({ id: 'far', aim_id: '99' }), // aimed outside this version: not placed
|
|
463
|
+
idea({ id: 'early', goal_id: '10', became_wk: 1, wk: 2 }), // a task made before the filing reads as the filing week
|
|
464
|
+
idea({ id: 'mine', by: '1' }), idea({ id: 'voted', voted: true }), idea({ id: 'old', wk: 1 }), idea({ id: 'shut', status: 'promoted' }),
|
|
465
|
+
],
|
|
466
|
+
ideaVotes: [{ id: '1', wk: 2, n: 3 }, { id: '77', wk: 2, n: 9 }],
|
|
467
|
+
taskTotal: 50,
|
|
468
|
+
}), { id: '1', rank: 'metic' });
|
|
469
|
+
const by = Object.fromEntries(out.ideas.map((x) => [x.id, x]));
|
|
470
|
+
assert.deepEqual([by.work.area_id, by.work.goal_id, by.work.became, by.work.votes], ['10', '10', 3, 4]);
|
|
471
|
+
assert.deepEqual([by.aim.area_id, by.aim.became], ['20', null]);
|
|
472
|
+
assert.equal(by.far.area_id, null);
|
|
473
|
+
assert.equal(by.early.became, 2);
|
|
474
|
+
assert.deepEqual(out.ideas.filter((x) => x.waits).map((x) => x.id), ['far'], 'new, open, in no area, not mine, not voted on — and only these two weeks');
|
|
475
|
+
assert.deepEqual(out.people.find((p) => p.id === '1').vote, [0, 0, 3, 0], 'idea votes per week, known builders only');
|
|
476
|
+
assert.equal(out.version.tasks, 50);
|
|
477
|
+
assert.ok(city.shapeCity(raw(), null).ideas.every((x) => !x.waits), 'nothing waits on a signed-out viewer');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
await test('the votes are read only where the ideas module keeps them; the viewer id rides only with that read', async () => {
|
|
481
|
+
const off = fakePool();
|
|
482
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'metic' } }, { pool: off, getFocusVersionId: async () => null });
|
|
483
|
+
const q = off.seen.find((x) => /FROM idea_inbox i\s+LEFT JOIN/.test(x.sql));
|
|
484
|
+
assert.doesNotMatch(q.sql, /ideas_votes/);
|
|
485
|
+
assert.equal(q.params.length, 1);
|
|
486
|
+
const on = fakePool({ tables: { ideas_votes: true } });
|
|
487
|
+
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'metic' } }, { pool: on, getFocusVersionId: async () => null });
|
|
488
|
+
const q2 = on.seen.find((x) => /FROM idea_inbox i\s+LEFT JOIN/.test(x.sql));
|
|
489
|
+
assert.match(q2.sql, /LEFT JOIN LATERAL \(SELECT[\s\S]*?FROM ideas_votes v WHERE v\.target_idea_id = i\.id\) iv ON true/); assert.equal(q2.sql.split('FROM ideas_votes').length - 1, 1, 'one look-up per idea, not two'); assert.deepEqual(q2.params.slice(1), ['1']);
|
|
490
|
+
assert.ok(/LIMIT \d+/.test(q2.sql), 'still the bounded newest-first list');
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
await test('the skyline IS the city: every building at its own screen x and height, seen from the street', () => {
|
|
494
|
+
const m = Draw.cityModel(FIXTURE);
|
|
495
|
+
const t = m.W - 1;
|
|
496
|
+
const sil = Draw.skylineOf(m, t, Gov);
|
|
497
|
+
const OX = Draw.viewBoxOf(m).OX, sx = (x, y) => OX + (x - y) * 25;
|
|
498
|
+
m.areas.forEach((a) => {
|
|
499
|
+
const s = sil.find((x) => x.k === 'area' && x.id === a.id);
|
|
500
|
+
const d = Draw.doneAt(a, t);
|
|
501
|
+
assert.equal(s.l, sx(a.x, a.y + 1.8)); assert.equal(s.r, sx(a.x + 1.8, a.y));
|
|
502
|
+
assert.equal(s.h, 4 + Math.max(2, (d.build + d.art) * m.px), 'as tall as its tower in the city');
|
|
503
|
+
});
|
|
504
|
+
// the tower's own drawing agrees: its body's left and right faces span exactly the silhouette's edges
|
|
505
|
+
const I = Draw.theme('dark');
|
|
506
|
+
const xs = (n) => n.attrs.d.slice(1, -1).split('L').map((p) => Number(p.split(' ')[0]));
|
|
507
|
+
const towers = Draw.citySvg(m, t, { mode: 'dark', gov: Gov }).nodes.filter((n) => n.attrs.class === 'hc-tower');
|
|
508
|
+
assert.equal(towers.length, sil.filter((s) => s.k === 'area').length);
|
|
509
|
+
towers.forEach((tw) => {
|
|
510
|
+
const s = sil.find((x) => tw.attrs.href === `/goals#/goal/${x.id}`);
|
|
511
|
+
const left = tw.kids.find((k) => k.attrs.fill === I.work.l), right = tw.kids.find((k) => k.attrs.fill === I.work.r);
|
|
512
|
+
assert.ok(Math.abs(Math.min(...xs(left)) - s.l) <= 0.1 && Math.abs(Math.max(...xs(right)) - s.r) <= 0.1, `${s.id} stands where its tower does`);
|
|
513
|
+
});
|
|
514
|
+
const civic = sil.filter((s) => s.k === 'civic');
|
|
515
|
+
assert.equal(civic.length, Gov.CIVIC_SLOTS.length);
|
|
516
|
+
civic.forEach((s) => assert.equal(s.h, Gov.ROOF[s.id] * 0.82, 'a civic building at the core\'s own scale'));
|
|
517
|
+
assert.equal(sil.filter((s) => s.k === 'museum').length, 2, 'the museum block and its atrium');
|
|
518
|
+
for (let n = 1; n < sil.length; n++) assert.ok(sil[n - 1].depth <= sil[n].depth, 'back to front');
|
|
519
|
+
// it grows with the slider
|
|
520
|
+
const tallest = (u) => Math.max(...Draw.skylineOf(m, u, Gov).filter((s) => s.k === 'area').map((s) => s.h));
|
|
521
|
+
assert.ok(tallest(0) < tallest(t));
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
await test('outlines never show through a nearer building, and the three doors lie on top', () => {
|
|
525
|
+
const m = Draw.cityModel(FIXTURE);
|
|
526
|
+
const ns = all(Draw.skySvg(m, m.W - 1, { mode: 'dark', gov: Gov }).nodes);
|
|
527
|
+
const sky = ns.find((n) => n.attrs.class === 'hc-skyline').kids;
|
|
528
|
+
// each silhouette's outline comes straight after its own fill, so every LATER (nearer) fill covers it
|
|
529
|
+
const fills = sky.filter((n) => n.attrs.class === 'hc-sil');
|
|
530
|
+
assert.equal(fills.length, Draw.skylineOf(m, m.W - 1, Gov).length);
|
|
531
|
+
sky.forEach((n, i) => { if (/hc-ol\b/.test(n.attrs.class || '')) assert.equal(sky[i - 1].attrs.d, n.attrs.d); });
|
|
532
|
+
const hits = ns.filter((n) => n.tag === 'a' && n.attrs.class === 'hc-hit');
|
|
533
|
+
assert.equal(hits.length, fills.length, 'one hit per silhouette, back to front, so the nearest takes the pointer');
|
|
534
|
+
const order = ns.indexOf(ns.find((n) => n.attrs.class === 'hc-hits'));
|
|
535
|
+
assert.ok(order > ns.indexOf(ns.find((n) => n.attrs.class === 'hc-skyline')), 'the hit layer is drawn over the skyline');
|
|
536
|
+
const group = (k) => hits.filter((h) => h.attrs['data-group'] === k);
|
|
537
|
+
assert.ok(group('area').every((h) => h.attrs.href === '/goals'), 'Building opens the goals');
|
|
538
|
+
assert.ok(group('civic').every((h) => h.attrs.href === Gov.STREET_HREF), 'Government opens the street');
|
|
539
|
+
assert.ok(group('museum').every((h) => h.attrs['data-craft-door'] === 'artistry'), 'Art switches to Artistry');
|
|
540
|
+
['area', 'civic', 'museum'].forEach((k) => {
|
|
541
|
+
const g = group(k);
|
|
542
|
+
assert.ok(g.length > 0);
|
|
543
|
+
assert.equal(g.filter((h) => h.attrs.tabindex !== '-1').length, 1, 'one keyboard stop per group');
|
|
544
|
+
assert.ok(g[0].attrs['aria-label']);
|
|
545
|
+
g.forEach((h) => { const y = Math.min(...h.kids[0].attrs.d.match(/V(-?[\d.]+)/g).map((v) => Number(v.slice(1)))); assert.ok(Draw.SKY.h - y >= 30, 'a low building still offers a band to point at'); });
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
await test('the sky: constellations from real areas, a field for the unplaced, rings only at Live, work falls into its tower', () => {
|
|
550
|
+
const m = Draw.cityModel(FIXTURE);
|
|
551
|
+
const ns = all(Draw.skySvg(m, m.W - 1, { mode: 'dark', gov: Gov }).nodes);
|
|
552
|
+
const consts = ns.filter((n) => n.attrs.class === 'hc-const');
|
|
553
|
+
const { groups } = Draw.skyPlaces(m);
|
|
554
|
+
assert.equal(consts.length, groups.length);
|
|
555
|
+
assert.ok(groups.every((g) => m.areas.includes(g.area)), 'a constellation per working area of this version');
|
|
556
|
+
assert.ok(ns.some((n) => n.attrs.class === 'hc-loose'));
|
|
557
|
+
const rings = (u) => all(Draw.skySvg(m, u, { mode: 'dark', gov: Gov }).nodes).filter((n) => n.attrs.class === 'hc-ring').length;
|
|
558
|
+
assert.equal(rings(m.W - 1), m.ideas.filter((x) => x.waits).length);
|
|
559
|
+
assert.equal(rings(m.W - 2), 0, 'what waits on you is now, not history');
|
|
560
|
+
const falls = ns.filter((n) => n.attrs.class === 'hc-fall').length;
|
|
561
|
+
assert.equal(falls, m.ideas.filter((x) => x.became === m.W - 1 && m.areas.some((a) => a.id === x.goal)).length);
|
|
562
|
+
const stars = ns.filter((n) => n.attrs.class === 'hc-star');
|
|
563
|
+
assert.equal(stars.length, m.ideas.filter((x) => !(x.became != null && x.became < m.W - 1)).length, 'an idea that became work earlier lit its window instead');
|
|
564
|
+
assert.ok(stars.every((s) => s.kids.some((k) => k.attrs.r >= 12)), 'each star offers a 24-unit target for its tip');
|
|
565
|
+
const built = Draw.versionBuilt(m, m.W - 1);
|
|
566
|
+
assert.ok(built > 0 && built < 1);
|
|
567
|
+
assert.ok(Draw.versionBuilt(m, 0) < built, 'the moon waxes with the slider');
|
|
568
|
+
assert.equal(Draw.versionBuilt(Object.assign({}, m, { version: null }), 1), null);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
await test('the ideator\'s ladder ranks ideas, votes and work; the builder\'s ladder is unchanged', () => {
|
|
572
|
+
const m = Draw.cityModel(FIXTURE);
|
|
573
|
+
const i = m.W - 1;
|
|
574
|
+
const rows = Draw.ladderFor(m, i, FIXTURE.viewer.id, 'ideating');
|
|
575
|
+
rows.forEach((r) => assert.deepEqual(Object.keys(r.v), ['idea', 'vote', 'work']));
|
|
576
|
+
rows.forEach((r) => assert.equal(r.v.work, m.ideas.filter((x) => x.by === r.id && x.became === i).length));
|
|
577
|
+
Draw.ladderFor(m, i, FIXTURE.viewer.id).forEach((r) => assert.deepEqual(Object.keys(r.v), ['art', 'build', 'idea', 'gov']));
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
summary();
|