@bongos/core 1.24.30 → 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 +153 -103
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +139 -1
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +192 -187
- package/docs/copy-registry.json +530 -458
- package/docs/module-api-changelog.md +2 -2
- package/docs/page-inventory.json +15 -35
- package/docs/page-readings.json +226 -263
- 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/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/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 +62 -86
- package/modules/hall-ui/public/home-city-draw.js +360 -52
- package/modules/hall-ui/public/home-city.css +47 -11
- package/modules/hall-ui/public/home-city.js +156 -40
- 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 +4 -1
- 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 +123 -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 +58 -10
- package/modules/lifecycle/home-gallery.js +320 -0
- package/modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql +36 -0
- package/modules/lifecycle/module.json +1 -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 +29 -1
- 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/hall_builders_page_pass.mjs +18 -27
- 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_city.mjs +200 -5
- package/tests/home_city_widget.mjs +167 -27
- 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/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
|
@@ -117,42 +117,33 @@ function renderLandingHtml(joinability) {
|
|
|
117
117
|
return html();
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
// The sign-in redesign made the sign-in button the card's one primary control
|
|
121
|
+
// in EVERY mode; only the door beneath it depends on joinability.
|
|
122
|
+
const SIGNIN = /class="landing__btn landing__btn--block" id="landing-signin" href="\/api\/bongos\/auth\/web\/start"/;
|
|
123
|
+
const signInCount = (html) => (html.match(/href="\/api\/bongos\/auth\/web\/start"/g) || []).length;
|
|
124
|
+
|
|
125
|
+
test('A: the apply door leads with sign-in and folds the request form under it', () => {
|
|
121
126
|
const html = renderLandingHtml('apply');
|
|
122
|
-
assert.match(html,
|
|
123
|
-
assert.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
);
|
|
128
|
-
// The whole point of the change: the footnote link is GONE from this door, so
|
|
129
|
-
// there is exactly one sign-in path and it is the button.
|
|
130
|
-
assert.ok(
|
|
131
|
-
!/Already a member\? <a /.test(html),
|
|
132
|
-
'the apply door no longer repeats sign-in as a prose link under the form',
|
|
133
|
-
);
|
|
134
|
-
assert.match(html, /We store only your GitHub login/, 'the privacy sentence survives the note');
|
|
135
|
-
// Two controls total: request access, and sign in. Not three.
|
|
136
|
-
assert.equal(countButtons(html), 2, 'the apply door carries the request button and the sign-in button');
|
|
127
|
+
assert.match(html, SIGNIN, 'sign-in is a real primary button');
|
|
128
|
+
assert.equal(signInCount(html), 1, 'exactly one sign-in path — never a second prose link');
|
|
129
|
+
assert.match(html, /<details class="landing__disclosure">[\s\S]*id="access-request-form"/, 'the request form sits behind a disclosure');
|
|
130
|
+
assert.ok(html.indexOf('landing-signin') < html.indexOf('access-request-form'), 'sign-in comes first');
|
|
131
|
+
assert.equal(countButtons(html), 2, 'two controls: sign in, and request access');
|
|
137
132
|
});
|
|
138
133
|
|
|
139
|
-
test('A: the
|
|
134
|
+
test('A: the open and invite-only doors carry one sign-in control and no form', () => {
|
|
140
135
|
for (const mode of ['open', 'invite_only']) {
|
|
141
136
|
const html = renderLandingHtml(mode);
|
|
142
|
-
assert.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
);
|
|
146
|
-
assert.equal(countButtons(html), 1, `the ${mode} door carries exactly one sign-in control`);
|
|
147
|
-
assert.match(html, /Already a member\? <a /, `the ${mode} door keeps its prose note for returning builders`);
|
|
137
|
+
assert.match(html, SIGNIN, `the ${mode} door signs in with the same button`);
|
|
138
|
+
assert.equal(signInCount(html), 1, `the ${mode} door has exactly one sign-in path`);
|
|
139
|
+
assert.ok(!/access-request-form/.test(html), `the ${mode} door has nothing to apply with`);
|
|
140
|
+
assert.equal(countButtons(html), 1, `the ${mode} door carries exactly one control`);
|
|
148
141
|
}
|
|
149
142
|
});
|
|
150
143
|
|
|
151
144
|
test('A: an older branding pack with no joinability falls back to the apply door', () => {
|
|
152
|
-
// joinabilityMode() treats anything but open/invite_only as 'apply'
|
|
153
|
-
|
|
154
|
-
// never sent.
|
|
155
|
-
assert.match(renderLandingHtml(null), /landing__returning/);
|
|
145
|
+
// joinabilityMode() treats anything but open/invite_only as 'apply'.
|
|
146
|
+
assert.match(renderLandingHtml(null), /id="access-request-form"/);
|
|
156
147
|
});
|
|
157
148
|
|
|
158
149
|
test('A: the quiet fill is a defined variant, not an inline colour', () => {
|
package/tests/hall_loading.mjs
CHANGED
|
@@ -83,7 +83,7 @@ test('the page loaders step aside by themselves, and never show without :has()',
|
|
|
83
83
|
assert.match(STYLE, /\.hall-loading--wait, \.hall-loading--zones \{ display: none; \}/);
|
|
84
84
|
assert.match(STYLE, /@supports selector\(:has\(a\)\) \{\s*\.hall-loading--wait:not\(:has\(~ :not\(\[hidden\], script, template, style\)\)\) \{ display: flex; \}\s*\.hall-loading--zones:not\(:has\(~ :not\(\[hidden\]\) > :not\(\[hidden\], script, template, style\)\)\) \{ display: flex; \}\s*\}/);
|
|
85
85
|
// A page whose sections all start [hidden] carries the --wait loader FIRST in its main.
|
|
86
|
-
for (const page of ['board-room', 'deploy', 'fleet', 'gate', 'government', '
|
|
86
|
+
for (const page of ['board-room', 'deploy', 'fleet', 'gate', 'government', 'project-settings', 'sessions']) {
|
|
87
87
|
const html = read(`${page}.html`);
|
|
88
88
|
const main = html.slice(html.indexOf('<main id="hall-main">'));
|
|
89
89
|
const first = main.replace(/<main id="hall-main">\s*(<!--[\s\S]*?-->\s*)*/, '');
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -321,9 +321,16 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
321
321
|
// the steering surfaces because that is what it is — the owner's third release gate.
|
|
322
322
|
// 'fleet' (task 1001949) follows it at the metic floor of `provisioning.fleet.manage`:
|
|
323
323
|
// what each hosted project costs is an operator surface, like the rest of this group.
|
|
324
|
-
// 'market-watch-settings' (task 1004938)
|
|
325
|
-
//
|
|
326
|
-
|
|
324
|
+
// 'market-watch-settings' (task 1004938) LEFT the group in task 1005045: the owner saw
|
|
325
|
+
// "Market Watch" twice and had to guess which was which (owner decision 2026-11-03,
|
|
326
|
+
// idea 1001310). The settings are an owner-only Settings tab on /market-watch now.
|
|
327
|
+
assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'government']);
|
|
328
|
+
// task 1005045: and no label is left twice anywhere in the sidebar, so the next pair
|
|
329
|
+
// fails here instead of in front of the owner.
|
|
330
|
+
const labels = [...boot().sidebarHtml.matchAll(/<a class="nav-item" [^>]*>[\s\S]*?<span class="nav-item__label">([^<]*)<\/span>/g)].map((m) => m[1]);
|
|
331
|
+
assert.ok(labels.includes('Market Watch'), 'the labels were actually read');
|
|
332
|
+
assert.deepEqual(labels.filter((l, i) => labels.indexOf(l) !== i), [], 'every sidebar label is said once');
|
|
333
|
+
assert.ok(!nav.flatMap((g) => g.items).some((i) => i.href === '/market-watch-settings'), 'no item points at the retired settings page');
|
|
327
334
|
});
|
|
328
335
|
|
|
329
336
|
test('Economy lives in Docs and points at the reward-schedule page', () => {
|
|
@@ -755,7 +762,7 @@ test('no glyph path data reads as an IP address to the release leak scan', () =>
|
|
|
755
762
|
// block (task 1004713). These drive the real shell and the real gov-positions.js.
|
|
756
763
|
|
|
757
764
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
758
|
-
const GOVERNING = ['board-room', 'city', 'watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', '
|
|
765
|
+
const GOVERNING = ['board-room', 'city', 'watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'government'];
|
|
759
766
|
const lead = (building, title) => ({ kind: 'lead', seat: 'lead:' + building, building, title });
|
|
760
767
|
const block = (held) => ({ held, title: held.length ? held[0].title : null, has_position: held.length > 0 });
|
|
761
768
|
|
|
@@ -56,10 +56,11 @@ const ORACLE = {
|
|
|
56
56
|
// settings. Every read behind it is the viewer's own (GET /me, /me/* prefs,
|
|
57
57
|
// /specialities) and requireBuilder, so sign-in is the whole gate.
|
|
58
58
|
'/builders/my-agents': 'requireBuilderPage',
|
|
59
|
-
// task
|
|
60
|
-
// owner-only
|
|
61
|
-
//
|
|
62
|
-
|
|
59
|
+
// Gone (task 1005045, owner decision 2026-11-03): the owner's Market Watch settings
|
|
60
|
+
// (task 1004938) are an owner-only Settings tab on /market-watch now, and the old URL
|
|
61
|
+
// 302s to /market-watch#settings before any gate runs. A redirect is not a page; the
|
|
62
|
+
// four settings routes keep their own owner-only wall (tests/market_watch_settings.mjs).
|
|
63
|
+
'/builders/market-watch-settings': null,
|
|
63
64
|
// task 1004939: the Market Watch home and tracked-item pages. Every member of the
|
|
64
65
|
// project reads them; both reads are requireBuilder, so sign-in is the whole gate.
|
|
65
66
|
'/builders/market-watch': 'requireBuilderPage',
|
|
@@ -347,6 +348,8 @@ const GONE_PAGES = {
|
|
|
347
348
|
// Task 1003444 — the owner deleted the hall's cross-project directory; the hub
|
|
348
349
|
// community page (task 1003363) is its home, and /people redirects to /roster.
|
|
349
350
|
'/builders/people': 'task 1003444 — the hall has no cross-project directory; /people redirects to /roster',
|
|
351
|
+
// Task 1005045 — one sidebar item for Market Watch: its settings are a tab on the page.
|
|
352
|
+
'/builders/market-watch-settings': 'task 1005045 — the settings are the Settings tab on /market-watch; the old URL redirects there',
|
|
350
353
|
};
|
|
351
354
|
|
|
352
355
|
test('a row that is null because the page left the hall serves no page', () => {
|
package/tests/home_city.mjs
CHANGED
|
@@ -20,6 +20,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
20
20
|
process.env.NODE_ENV = 'test';
|
|
21
21
|
const city = require(path.join(ROOT, 'modules/lifecycle/home-city.js'));
|
|
22
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'));
|
|
23
24
|
const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/home__city.json'), 'utf8'));
|
|
24
25
|
|
|
25
26
|
const { test, summary } = makeRunner();
|
|
@@ -127,12 +128,12 @@ function fakePool({ tables = {} } = {}) {
|
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
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
|
+
const pool = fakePool({ tables: { government_board_votes: true, autonomy_runner_heartbeat: true, government_asks: true, ideas_votes: true } });
|
|
131
132
|
await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'archon' } }, { pool, getFocusVersionId: async () => null });
|
|
132
133
|
const data = pool.seen.filter((q) => !/to_regclass|date_trunc\('week'|FROM versions/.test(q.sql));
|
|
133
134
|
assert.ok(data.length >= 8, 'the summary reads areas, weeks, leads, people, ideas, govern, sites and waiting');
|
|
134
135
|
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
|
+
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);
|
|
136
137
|
assert.ok(bounded, `unbounded read:\n${q.sql}`);
|
|
137
138
|
assert.doesNotMatch(q.sql, /description|body_md|value_summary/, 'the city never downloads prose it does not draw');
|
|
138
139
|
}
|
|
@@ -320,8 +321,20 @@ await test('the fixture is a shape the drawing accepts, and the tallest areas st
|
|
|
320
321
|
assert.equal(m.W, 10);
|
|
321
322
|
assert.ok(m.areas.length > 5);
|
|
322
323
|
assert.ok(m.areas[0].total >= m.areas[m.areas.length - 1].total);
|
|
323
|
-
|
|
324
|
-
assert.
|
|
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
|
+
});
|
|
325
338
|
});
|
|
326
339
|
|
|
327
340
|
await test('more areas than the ground holds are reported, not drawn', () => {
|
|
@@ -370,16 +383,198 @@ await test('ledger text is never markup: titles and names travel as attribute va
|
|
|
370
383
|
people: [{ id: '9', name: '"><script>x</script>', build: [1], art: [0], idea: [0], gov: [0] }],
|
|
371
384
|
ideas: [{ id: '3', week: 0, by: '9', title: '<b>idea</b>' }], now: { sites: [{ goal_id: '1', builder_id: '9' }] } };
|
|
372
385
|
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']);
|
|
386
|
+
const SHAPES = new Set(['defs', 'radialGradient', 'stop', 'path', 'line', 'circle', 'ellipse', 'g', 'a', 'rect', 'text', 'clipPath', 'image']);
|
|
374
387
|
assert.deepEqual([...new Set(ns.map((n) => n.tag))].filter((t) => !SHAPES.has(t)), [], 'only SVG shapes, whatever a title says');
|
|
375
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');
|
|
376
389
|
assert.ok(ns.some((n) => n.tag === 'text' && n.text === '"'), 'a name\'s initial is a text node');
|
|
377
390
|
});
|
|
378
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
|
+
|
|
379
446
|
await test('both modes draw with their own faces', () => {
|
|
380
447
|
const m = Draw.cityModel(FIXTURE);
|
|
381
448
|
assert.ok(all(Draw.citySvg(m, m.W - 1, { mode: 'dark' }).nodes).some((n) => n.attrs.fill === '#34343a'));
|
|
382
449
|
assert.ok(all(Draw.citySvg(m, m.W - 1, { mode: 'light' }).nodes).some((n) => n.attrs.fill === '#f6f5f8'));
|
|
383
450
|
});
|
|
384
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
|
+
|
|
385
580
|
summary();
|