@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.
Files changed (113) hide show
  1. package/.bongos-core.json +198 -108
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +12 -0
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/api/openapi.json +174 -1
  8. package/docs/api-reference.md +12 -2
  9. package/docs/copy-inventory.md +226 -224
  10. package/docs/copy-registry.json +583 -549
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-inventory.json +18 -37
  13. package/docs/page-readings.json +306 -361
  14. package/modules/copy-desk/module.json +1 -1
  15. package/modules/copy-desk/routes/copy-desk-studio.js +103 -0
  16. package/modules/copy-desk/tests/copy_no_cms.mjs +2 -2
  17. package/modules/government/db.js +29 -0
  18. package/modules/government/docket.js +61 -4
  19. package/modules/government/routes/government.js +2 -0
  20. package/modules/hall-ui/public/ai-studio.html +1 -1
  21. package/modules/hall-ui/public/atlas.html +1 -1
  22. package/modules/hall-ui/public/blockers.html +1 -1
  23. package/modules/hall-ui/public/board-room.html +1 -1
  24. package/modules/hall-ui/public/builders.js +3 -79
  25. package/modules/hall-ui/public/city.html +1 -1
  26. package/modules/hall-ui/public/collab.html +1 -1
  27. package/modules/hall-ui/public/commands.html +1 -1
  28. package/modules/hall-ui/public/copy-desk.html +1 -1
  29. package/modules/hall-ui/public/craft-mode.js +1 -1
  30. package/modules/hall-ui/public/deploy.html +1 -1
  31. package/modules/hall-ui/public/diagrams.html +1 -1
  32. package/modules/hall-ui/public/drachmae.html +1 -1
  33. package/modules/hall-ui/public/fleet.html +1 -1
  34. package/modules/hall-ui/public/gate.html +1 -1
  35. package/modules/hall-ui/public/goal-map.html +1 -1
  36. package/modules/hall-ui/public/goals.html +1 -1
  37. package/modules/hall-ui/public/government.html +1 -1
  38. package/modules/hall-ui/public/hall-render.js +68 -167
  39. package/modules/hall-ui/public/hall-widgets.js +4 -45
  40. package/modules/hall-ui/public/home-city-draw.js +583 -0
  41. package/modules/hall-ui/public/home-city.css +102 -0
  42. package/modules/hall-ui/public/home-city.js +339 -0
  43. package/modules/hall-ui/public/home-gallery.css +129 -0
  44. package/modules/hall-ui/public/home-gallery.js +349 -0
  45. package/modules/hall-ui/public/home-studio.css +72 -0
  46. package/modules/hall-ui/public/home-studio.js +203 -0
  47. package/modules/hall-ui/public/idea.html +1 -1
  48. package/modules/hall-ui/public/ideas.html +1 -1
  49. package/modules/hall-ui/public/index.html +9 -7
  50. package/modules/hall-ui/public/index.states.json +73 -8
  51. package/modules/hall-ui/public/market-watch-item.html +1 -1
  52. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  53. package/modules/hall-ui/public/market-watch-settings.js +6 -1
  54. package/modules/hall-ui/public/market-watch.html +141 -54
  55. package/modules/hall-ui/public/market-watch.js +54 -0
  56. package/modules/hall-ui/public/market-watch.states.json +2 -1
  57. package/modules/hall-ui/public/modules.html +1 -1
  58. package/modules/hall-ui/public/my-agents.html +1 -1
  59. package/modules/hall-ui/public/primer.html +1 -1
  60. package/modules/hall-ui/public/profile.html +1 -1
  61. package/modules/hall-ui/public/project-settings.html +1 -1
  62. package/modules/hall-ui/public/ranks.html +1 -1
  63. package/modules/hall-ui/public/roadmap.html +1 -1
  64. package/modules/hall-ui/public/roster.html +1 -1
  65. package/modules/hall-ui/public/sessions.html +1 -1
  66. package/modules/hall-ui/public/settings.html +1 -1
  67. package/modules/hall-ui/public/shell.js +2 -3
  68. package/modules/hall-ui/public/street.html +1 -1
  69. package/modules/hall-ui/public/studio.html +9 -1
  70. package/modules/hall-ui/public/style.css +74 -27
  71. package/modules/hall-ui/public/task.html +1 -1
  72. package/modules/hall-ui/public/thinking.html +1 -1
  73. package/modules/hall-ui/public/tweak-editor.html +1 -1
  74. package/modules/hall-ui/public/watch.html +1 -1
  75. package/modules/hall-ui/public/work.html +1 -1
  76. package/modules/hall-ui/records/home-city.md +189 -0
  77. package/modules/hall-ui/records/loader.md +1 -1
  78. package/modules/hall-ui/records/market-watch-settings.md +16 -3
  79. package/modules/hall-ui/records/market-watch.md +3 -1
  80. package/modules/lifecycle/home-city.js +388 -0
  81. package/modules/lifecycle/home-gallery.js +320 -0
  82. package/modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql +36 -0
  83. package/modules/lifecycle/module.json +2 -0
  84. package/modules/lifecycle/routes/home-city.js +29 -0
  85. package/modules/lifecycle/routes/home-gallery.js +37 -0
  86. package/modules/market-watch/routes/settings.js +1 -1
  87. package/package-lock.json +2 -2
  88. package/package.json +1 -1
  89. package/release-notes.json +38 -0
  90. package/scripts/bongos/leak-scan-allowlist.js +8 -0
  91. package/scripts/hall-preview/README.md +1 -0
  92. package/scripts/hall-preview/server.js +10 -0
  93. package/src/bongos/serve-internal.js +17 -3
  94. package/src/module-api.js +1 -1
  95. package/tests/claim_from_task_and_home.mjs +8 -43
  96. package/tests/hall_board_world.mjs +10 -12
  97. package/tests/hall_builders_page_pass.mjs +18 -27
  98. package/tests/hall_home_ships_bounded.mjs +4 -3
  99. package/tests/hall_loading.mjs +1 -1
  100. package/tests/hall_nav.mjs +11 -4
  101. package/tests/hall_page_gate_map.mjs +7 -4
  102. package/tests/home_bounded_reads.mjs +16 -31
  103. package/tests/home_city.mjs +580 -0
  104. package/tests/home_city_widget.mjs +260 -0
  105. package/tests/home_gallery.mjs +255 -0
  106. package/tests/home_gallery_widget.mjs +220 -0
  107. package/tests/home_studio.mjs +169 -0
  108. package/tests/home_studio_widget.mjs +157 -0
  109. package/tests/leaderboard_roles.mjs +3 -2
  110. package/tests/market_watch_pages.mjs +162 -0
  111. package/tests/nav_permission_atoms.mjs +2 -2
  112. package/modules/hall-ui/public/market-watch-settings.html +0 -115
  113. package/modules/hall-ui/public/market-watch-settings.states.json +0 -14
@@ -0,0 +1,260 @@
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/city-draw.js + home-city-draw.js + home-city.js in a
5
+ // real page with the read stubbed by the hall-preview fixture: one bounded read per
6
+ // load; the slider opens at Live and redraws towers and ladder wherever it is sent;
7
+ // the quiet player (v16, task 1005058) rewinds, plays and fast-forwards, gliding
8
+ // through the weeks, and jumps instead under reduced motion; the museum and the star
9
+ // band switch the craft; a refused read leaves Home without an empty plate. Skips
10
+ // where no system Edge or Chrome is installed.
11
+ //
12
+ // Run: node --test tests/home_city_widget.mjs
13
+ import assert from 'node:assert/strict';
14
+ import { test, after } from 'node:test';
15
+ import { createRequire } from 'node:module';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
23
+ const pub = (f) => fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', f), 'utf8');
24
+ const GOV = pub('city-draw.js');
25
+ const DRAW = pub('home-city-draw.js');
26
+ const WIDGET = pub('home-city.js');
27
+ const CSS = pub('home-city.css');
28
+ const FIXTURE = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'home__city.json'), 'utf8');
29
+
30
+ let browser = null;
31
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
32
+ after(() => browser && browser.close());
33
+ const skip = !browser && 'no system Edge or Chrome';
34
+
35
+ const stubs = (status, craft) => `
36
+ window.__asked = [];
37
+ window.__CITY = ${FIXTURE};
38
+ window.BongosClient = { createClient: () => ({ request: async (m, p) => {
39
+ window.__asked.push(m + ' ' + p);
40
+ if (${status} !== 200) return { ok: false, status: ${status}, data: null };
41
+ return p.startsWith('/api/bongos/home/city') ? { ok: true, status: 200, data: window.__CITY } : { ok: false, status: 404, data: null };
42
+ } }) };
43
+ window.__chosen = [];
44
+ window.__craft = ${JSON.stringify(craft || null)};
45
+ window.OTBCraftMode = { iconHtml: (k, cls) => '<svg class="' + cls + '" data-icon="' + k + '"></svg>', choose: (m) => window.__chosen.push(m), current: () => window.__craft };
46
+ window.OTB = { contributeHallWidget(w) { window.__widget = w; } };`;
47
+
48
+ async function boot({ status = 200, width = 1280, still = true, craft = null } = {}) {
49
+ const ctx = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: still ? 'reduce' : 'no-preference' });
50
+ const page = await ctx.newPage();
51
+ await page.clock.install();
52
+ await page.setContent(`<!doctype html><html><head><style>${CSS}</style></head><body><section id="home-city" class="scroll home-city" hidden></section></body></html>`);
53
+ await page.addScriptTag({ content: stubs(status, craft) });
54
+ await page.addScriptTag({ content: GOV });
55
+ await page.addScriptTag({ content: DRAW });
56
+ await page.addScriptTag({ content: WIDGET });
57
+ await page.evaluate(() => window.__widget.mount(document.getElementById('home-city')));
58
+ return { ctx, page };
59
+ }
60
+
61
+ // The installed clock still flows in real time; a glide test pauses it once the city is up,
62
+ // so a busy machine (CI runs files in parallel) cannot advance the glide between two reads.
63
+ const freeze = async (page) => page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000));
64
+
65
+ const state = (page) => page.evaluate(() => ({
66
+ hidden: document.getElementById('home-city').hidden,
67
+ asked: window.__asked,
68
+ week: document.querySelector('.hc-slider').getAttribute('aria-valuetext'),
69
+ t: Number(document.querySelector('.hc-slider').value),
70
+ live: document.querySelector('.hc-live').textContent,
71
+ svgLen: document.querySelector('.hc-svg').innerHTML.length,
72
+ 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 })),
73
+ }));
74
+
75
+ test('one bounded read; the city, its ladder and the slider appear at Live', { skip }, async () => {
76
+ const { ctx, page } = await boot();
77
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
78
+ const s = await state(page);
79
+ const core = await page.evaluate(() => document.querySelectorAll('.hc-svg a.hc-core path').length);
80
+ await ctx.close();
81
+ assert.deepEqual(s.asked, ['GET /api/bongos/home/city?weeks=10'], 'Home asks for the summary once and nothing else');
82
+ assert.equal(s.hidden, false);
83
+ assert.equal(s.week, 'Live');
84
+ assert.equal(s.live, 'Live');
85
+ assert.ok(core > 27, 'the civic core is drawn from city-draw.js');
86
+ assert.ok(s.rungs.length >= 5);
87
+ assert.ok(s.rungs.some((r) => r.me), 'the viewer\'s row is marked');
88
+ });
89
+
90
+ test('the slider redraws the towers and re-ranks the ladder wherever it is sent; Back to live returns', { skip }, async () => {
91
+ const { ctx, page } = await boot();
92
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
93
+ const now = await state(page);
94
+ await page.evaluate(() => { const s = document.querySelector('.hc-slider'); s.value = '0'; s.dispatchEvent(new Event('input', { bubbles: true })); });
95
+ const first = await state(page);
96
+ assert.equal(first.week, '31 Aug');
97
+ assert.equal(first.live, 'Back to live');
98
+ assert.notEqual(first.svgLen, now.svgLen, 'the scene was redrawn');
99
+ assert.notDeepEqual(first.rungs, now.rungs, 'the ladder is the first week\'s, not this week\'s');
100
+ await page.click('.hc-live');
101
+ const back = await state(page);
102
+ await ctx.close();
103
+ assert.equal(back.week, 'Live');
104
+ });
105
+
106
+ test('the player glides: rewind runs back through the weeks to the start, then stops; fast forward runs to Live', { skip }, async () => {
107
+ const { ctx, page } = await boot({ still: false });
108
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
109
+ await freeze(page);
110
+ await page.click('.hc-p-rw');
111
+ await page.clock.runFor(1000);
112
+ const mid = await state(page);
113
+ assert.ok(mid.t > 0.5 && mid.t < 8.5, `part-way back, not a jump (t=${mid.t})`);
114
+ assert.ok(!Number.isInteger(mid.t), 'between weeks: the city is drawn at a fractional week');
115
+ assert.equal(await page.evaluate(() => document.querySelector('.hc-p-rw').getAttribute('aria-pressed')), 'true');
116
+ await page.clock.runFor(8000);
117
+ const start = await state(page);
118
+ assert.equal(start.t, 0);
119
+ assert.equal(await page.evaluate(() => document.querySelector('.hc-p-rw').getAttribute('aria-pressed')), 'false', 'stops at the start');
120
+ await page.click('.hc-p-ff');
121
+ await page.clock.runFor(8000);
122
+ const end = await state(page);
123
+ await ctx.close();
124
+ assert.equal(end.week, 'Live');
125
+ });
126
+
127
+ test('play runs forward at a walk and pauses on a second press', { skip }, async () => {
128
+ const { ctx, page } = await boot({ still: false });
129
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
130
+ await freeze(page);
131
+ await page.click('.hc-p-play');
132
+ assert.equal((await state(page)).t, 0, 'from Live, play starts from the beginning');
133
+ await page.clock.runFor(1600);
134
+ const a = await state(page);
135
+ assert.ok(a.t > 0.5 && a.t < 1.6, `about a week in 1.6 s (t=${a.t})`);
136
+ await page.click('.hc-p-play');
137
+ const held = (await state(page)).t;
138
+ await page.clock.runFor(1600);
139
+ const b = await state(page);
140
+ await ctx.close();
141
+ assert.equal(b.t, held, 'paused');
142
+ });
143
+
144
+ test('with reduced motion nothing glides: every control jumps straight to where it was going', { skip }, async () => {
145
+ const { ctx, page } = await boot({ still: true });
146
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
147
+ await freeze(page);
148
+ await page.click('.hc-p-rw');
149
+ const s = await state(page);
150
+ await ctx.close();
151
+ assert.equal(s.t, 0);
152
+ });
153
+
154
+ test('the museum and the band of stars switch the craft; the core and a building are plain links', { skip }, async () => {
155
+ const { ctx, page } = await boot();
156
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
157
+ await page.evaluate(() => { document.querySelector('.hc-svg a.hc-museum').dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); document.querySelector('.hc-svg a.hc-sky').dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); });
158
+ const r = await page.evaluate(() => ({ chosen: window.__chosen, core: document.querySelector('.hc-svg a.hc-core').getAttribute('href'), tower: document.querySelector('.hc-svg a.hc-tower').getAttribute('href') }));
159
+ await ctx.close();
160
+ assert.deepEqual(r.chosen, ['artistry', 'ideating']);
161
+ assert.equal(r.core, '/builders/street');
162
+ assert.match(r.tower, /^\/goals#\/goal\/\d+$/);
163
+ });
164
+
165
+ test('a refused read leaves the section hidden: never an empty plate', { skip }, async () => {
166
+ const { ctx, page } = await boot({ status: 401 });
167
+ await page.evaluate(() => new Promise((r) => setTimeout(r, 0)));
168
+ await page.clock.runFor(50);
169
+ const s = await state(page);
170
+ await ctx.close();
171
+ assert.equal(s.hidden, true);
172
+ assert.equal(s.rungs.length, 0);
173
+ });
174
+
175
+ test('a phone width scrolls nothing sideways', { skip }, async () => {
176
+ const { ctx, page } = await boot({ width: 390 });
177
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
178
+ const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
179
+ await ctx.close();
180
+ assert.equal(over, 0);
181
+ });
182
+
183
+ // ---- the ideator's sky (task 1005059) ----
184
+
185
+ // a point inside the hit group that the group itself takes (a nearer building may cover part of it)
186
+ const pointOf = (page, group) => page.evaluate((g) => {
187
+ for (const h of document.querySelectorAll('.hc-hit[data-group="' + g + '"]')) {
188
+ const r = h.getBoundingClientRect();
189
+ for (let fy = 0.95; fy > 0.05; fy -= 0.1) for (let fx = 0.1; fx < 0.95; fx += 0.1) {
190
+ const x = r.left + r.width * fx, y = r.top + r.height * fy, top = document.elementFromPoint(x, y);
191
+ if (top && top.closest('.hc-hit') && top.closest('.hc-hit').dataset.group === g) return { x, y };
192
+ }
193
+ }
194
+ return null;
195
+ }, group);
196
+ const lit = (page) => page.evaluate(() => Object.fromEntries(['area', 'civic', 'museum'].map((g) => [g, [...document.querySelectorAll('.hc-ol-' + g)].every((o) => Number(getComputedStyle(o).strokeOpacity) > 0.5)])));
197
+
198
+ test('in Ideating, Home draws the sky over the skyline from the same one read, and the ladder ranks ideators', { skip }, async () => {
199
+ const { ctx, page } = await boot({ craft: 'ideating' });
200
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
201
+ const r = await page.evaluate(() => ({
202
+ asked: window.__asked, sky: document.getElementById('home-city').classList.contains('hc-is-sky'),
203
+ hits: document.querySelectorAll('.hc-hit').length, towers: document.querySelectorAll('.hc-tower').length,
204
+ label: document.querySelector('.hc-svg').getAttribute('aria-label'), icons: [...document.querySelectorAll('.hc-rung .hc-ico')].length,
205
+ }));
206
+ await ctx.close();
207
+ assert.deepEqual(r.asked, ['GET /api/bongos/home/city?weeks=10']);
208
+ assert.equal(r.sky, true); assert.ok(r.hits > 0); assert.equal(r.towers, 0);
209
+ assert.match(r.label, /night sky/);
210
+ assert.ok(r.icons > 0);
211
+ });
212
+
213
+ test('hovering the skyline outlines exactly one whole group: Building, Government or Art', { skip }, async () => {
214
+ const { ctx, page } = await boot({ craft: 'ideating' });
215
+ await page.waitForFunction(() => document.querySelectorAll('.hc-hit').length > 0);
216
+ const seen = {};
217
+ for (const g of ['area', 'civic', 'museum']) {
218
+ const p = await pointOf(page, g);
219
+ assert.ok(p, g + ' has a point it takes');
220
+ await page.mouse.move(p.x, p.y);
221
+ await page.waitForTimeout(400);
222
+ seen[g] = await lit(page);
223
+ }
224
+ await page.mouse.move(2, 2);
225
+ await page.waitForTimeout(400);
226
+ const rest = await lit(page);
227
+ await ctx.close();
228
+ assert.deepEqual(seen.area, { area: true, civic: false, museum: false });
229
+ assert.deepEqual(seen.civic, { area: false, civic: true, museum: false });
230
+ assert.deepEqual(seen.museum, { area: false, civic: false, museum: true });
231
+ assert.deepEqual(rest, { area: false, civic: false, museum: false });
232
+ });
233
+
234
+ test('each group is one door: the goals, the street, and the artist\'s Home', { skip }, async () => {
235
+ const { ctx, page } = await boot({ craft: 'ideating' });
236
+ await page.waitForFunction(() => document.querySelectorAll('.hc-hit').length > 0);
237
+ await page.evaluate(() => document.querySelector('.hc-hit[data-group="museum"]').dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })));
238
+ const r = await page.evaluate(() => ({
239
+ chosen: window.__chosen,
240
+ area: [...new Set([...document.querySelectorAll('.hc-hit[data-group="area"]')].map((a) => a.getAttribute('href')))],
241
+ civic: [...new Set([...document.querySelectorAll('.hc-hit[data-group="civic"]')].map((a) => a.getAttribute('href')))],
242
+ stops: [...document.querySelectorAll('.hc-hit')].filter((a) => a.getAttribute('tabindex') !== '-1').map((a) => a.dataset.group),
243
+ small: [...document.querySelectorAll('.hc-hit')].map((a) => a.getBoundingClientRect()).filter((b) => b.height < 24).length,
244
+ }));
245
+ await ctx.close();
246
+ assert.deepEqual(r.chosen, ['artistry']);
247
+ assert.deepEqual(r.area, ['/goals']);
248
+ assert.deepEqual(r.civic, ['/builders/street']);
249
+ assert.deepEqual(r.stops.sort(), ['area', 'civic', 'museum']);
250
+ assert.equal(r.small, 0, 'every hit clears the 24px floor at 1280');
251
+ });
252
+
253
+ test('switching the craft swaps the sky and the city in place, with no second read', { skip }, async () => {
254
+ const { ctx, page } = await boot({ craft: 'ideating' });
255
+ await page.waitForFunction(() => document.querySelectorAll('.hc-hit').length > 0);
256
+ await page.evaluate(() => { window.__craft = 'building'; document.dispatchEvent(new CustomEvent('otb:craft', { detail: { mode: 'building' } })); });
257
+ const r = await page.evaluate(() => ({ asked: window.__asked.length, sky: document.getElementById('home-city').classList.contains('hc-is-sky'), towers: document.querySelectorAll('.hc-tower').length, hits: document.querySelectorAll('.hc-hit').length }));
258
+ await ctx.close();
259
+ assert.deepEqual(r, { asked: 1, sky: false, towers: 10, hits: 0 });
260
+ });
@@ -0,0 +1,255 @@
1
+ // tests/home_gallery.mjs — the artist's Home, the gallery (task 1005060, Home v16 step 3).
2
+ //
3
+ // The server half (modules/lifecycle/home-gallery.js) with no database: the shaping of a
4
+ // work, the bounded read against a recording fake pool (one query, LIMIT, Most popular
5
+ // orders by hearts, a private goal and a security-sensitive task stay off the wall, a
6
+ // missing table answers a wall with no hearts, a picture whose file is gone is not hung),
7
+ // the heart (once per builder, revocable, refused for anything the wall would not hang)
8
+ // and both handlers. The page's pure label words (modules/hall-ui/public/home-gallery.js)
9
+ // run in node. The real widget in a browser is tests/home_gallery_widget.mjs.
10
+ //
11
+ // Run: node tests/home_gallery.mjs
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { createRequire } from 'node:module';
17
+ import { makeRunner } from './helpers.mjs';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ process.env.NODE_ENV = 'test';
22
+ const G = require(path.join(ROOT, 'modules/lifecycle/home-gallery.js'));
23
+ const Lib = require(path.join(ROOT, 'modules/hall-ui/public/home-gallery.js'));
24
+ const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/home__gallery.json'), 'utf8'));
25
+ const MIGRATION = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql'), 'utf8');
26
+ const MANIFEST = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/lifecycle/module.json'), 'utf8'));
27
+
28
+ const { test, summary } = makeRunner();
29
+
30
+ const PIC = (id, hex) => `/api/bongos/task-visuals/task-${id}-${hex}.jpg`;
31
+ function row(over = {}) {
32
+ return Object.assign({
33
+ id: '7', title: 'Tweak: Studio (builders:studio)', value_summary: 'The studio reads calmer.', shipped_at: new Date('2026-10-21T10:00:00Z'),
34
+ source: 'page-tweak', source_ref: 'page-tweak/builders:studio/r1', visual_url: null, visual_alt: null,
35
+ light: PIC(7, 'aaaaaaaaaaaaaaaa'), dark: PIC(7, 'bbbbbbbbbbbbbbbb'), goal_title: null,
36
+ artist_id: '2', display_name: 'Sana Okafor', github_login: 'sana', artist_since: new Date('2026-03-02T00:00:00Z'),
37
+ hearts: 3, mine: false, total: 1,
38
+ }, over);
39
+ }
40
+
41
+ // A recording pool: answers to_regclass, the wall, the heart's checks and writes.
42
+ function fakePool({ table = true, wall = [], isWork = true, after = { hearts: 1, mine: true } } = {}) {
43
+ const calls = [];
44
+ return {
45
+ calls,
46
+ async query(sql, params) {
47
+ calls.push({ sql, params });
48
+ if (/to_regclass/.test(sql)) return { rows: [{ ok: table }] };
49
+ if (/count\(\*\) OVER/.test(sql)) return { rows: wall };
50
+ if (/WHERE t\.id = \$4/.test(sql)) return { rows: isWork ? [{ id: String(params[3]) }] : [] };
51
+ if (/^INSERT|^DELETE/.test(sql.trim())) return { rows: [] };
52
+ if (/bool_or/.test(sql)) return { rows: [after] };
53
+ throw new Error('unexpected query: ' + sql.slice(0, 80));
54
+ },
55
+ };
56
+ }
57
+ const always = () => true;
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // 1. the shaping (pure)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ await test('a page tweak hangs as its page: the title, the round, where it hangs, both renders', () => {
64
+ const w = G.shapeWork(row({ source_ref: 'page-tweak/builders:studio/r2' }));
65
+ assert.equal(w.title, 'Studio');
66
+ assert.equal(w.medium, 'A page tweak, round 2');
67
+ assert.equal(w.place, 'The builders’ hall');
68
+ assert.deepEqual(w.art, { light: PIC(7, 'aaaaaaaaaaaaaaaa'), dark: PIC(7, 'bbbbbbbbbbbbbbbb'), alt: null });
69
+ assert.deepEqual(w.artist, { id: '2', name: 'Sana Okafor', login: 'sana', since: '2026-03-02' });
70
+ assert.equal(w.shipped, '2026-10-21');
71
+ assert.equal(G.shapeWork(row()).medium, 'A page tweak', 'a first round says no round number');
72
+ });
73
+
74
+ await test('any other art ship hangs its one ship-time picture in both modes, under its goal', () => {
75
+ const w = G.shapeWork(row({ source: 'task-cli', source_ref: null, title: 'WA6.RP04 · The profile leads with the work', light: null, dark: null, visual_url: PIC(9, 'cccccccccccccccc'), visual_alt: 'The profile', goal_title: 'Profiles' }));
76
+ assert.equal(w.title, 'The profile leads with the work', 'the plan code is not part of a wall label');
77
+ assert.equal(w.medium, 'Page design');
78
+ assert.equal(w.place, 'Profiles');
79
+ assert.deepEqual(w.art, { light: PIC(9, 'cccccccccccccccc'), dark: PIC(9, 'cccccccccccccccc'), alt: 'The profile' });
80
+ });
81
+
82
+ await test('a heart count is never negative and mine is strictly true', () => {
83
+ const w = G.shapeWork(row({ hearts: -4, mine: 't' }));
84
+ assert.equal(w.hearts, 0);
85
+ assert.equal(w.mine, false);
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // 2. the wall's read
90
+ // ---------------------------------------------------------------------------
91
+
92
+ await test('one bounded query: shipped art with a picture, LIMIT 60, Most popular orders by hearts first', async () => {
93
+ const pool = fakePool({ wall: [row()] });
94
+ const raw = await G.readGallery({ order: 'popular', viewer: { id: 5, rank: 'metic' } }, { pool, exists: always });
95
+ const q = pool.calls.find((c) => /count\(\*\) OVER/.test(c.sql));
96
+ assert.ok(q, 'the wall is one query');
97
+ assert.match(q.sql, /LIMIT 60\b/);
98
+ assert.match(q.sql, /ORDER BY hearts DESC, t\.shipped_at DESC, t\.id DESC/);
99
+ assert.match(q.sql, /t\.status = 'shipped'/);
100
+ assert.match(q.sql, /t\.security_sensitive IS NOT TRUE/, 'a security-sensitive picture never hangs');
101
+ assert.match(q.sql, /g\.visibility IS DISTINCT FROM 'private' OR \$2::boolean\s+OR EXISTS \(SELECT 1 FROM goal_members/, 'a private goal hangs only for its members and an Archon');
102
+ assert.match(q.sql, /t\.visual_url IS NOT NULL OR sl\.light IS NOT NULL OR sl\.dark IS NOT NULL/);
103
+ assert.match(q.sql, /system_role IS NULL/, 'a machine principal is never an artist');
104
+ assert.deepEqual(q.params, [['artist', 'ui'], false, '5'], 'visibility comes from the signed-in viewer');
105
+ assert.equal(raw.order, 'popular');
106
+ });
107
+
108
+ await test('Latest orders by the ship date alone; an unknown order is Most popular', async () => {
109
+ const pool = fakePool({ wall: [] });
110
+ await G.readGallery({ order: 'latest', viewer: { id: 1, rank: 'archon' } }, { pool, exists: always });
111
+ const q = pool.calls.find((c) => /count\(\*\) OVER/.test(c.sql));
112
+ assert.match(q.sql, /ORDER BY t\.shipped_at DESC, t\.id DESC/);
113
+ assert.doesNotMatch(q.sql, /hearts DESC/);
114
+ assert.equal(q.params[1], true, 'an Archon sees every goal');
115
+ assert.equal(G.cleanOrder('loudest'), 'popular');
116
+ });
117
+
118
+ await test('with the hearts table absent the wall still hangs, with no hearts', async () => {
119
+ const pool = fakePool({ table: false, wall: [row({ hearts: 0 })] });
120
+ assert.equal(G.shapeGallery({ order: 'popular', rows: [] }).total, 0);
121
+ const raw = await G.readGallery({ order: 'popular', viewer: { id: 1 } }, { pool, exists: always });
122
+ const q = pool.calls.find((c) => /count\(\*\) OVER/.test(c.sql));
123
+ assert.doesNotMatch(q.sql, /lifecycle_work_appreciations/);
124
+ assert.equal(raw.rows.length, 1);
125
+ });
126
+
127
+ await test('a picture whose file is gone is dropped, and a work left with none is not hung', async () => {
128
+ const gone = new Set([PIC(7, 'aaaaaaaaaaaaaaaa'), PIC(8, 'dddddddddddddddd')]);
129
+ const asked = [];
130
+ const out = await G.keepHung([
131
+ row({ total: 3 }),
132
+ row({ id: '8', light: PIC(8, 'dddddddddddddddd'), dark: null, total: 3 }),
133
+ row({ id: '9', light: null, dark: null, visual_url: PIC(9, 'cccccccccccccccc'), total: 3 }),
134
+ ], async (u) => { asked.push(u); return !gone.has(u); });
135
+ assert.deepEqual(out.map((r) => r.id), ['7', '9']);
136
+ assert.equal(asked.length, new Set(asked).size, 'each picture is checked once, however many works share it');
137
+ assert.equal(out[0].light, null);
138
+ assert.equal(out[0].dark, PIC(7, 'bbbbbbbbbbbbbbbb'));
139
+ assert.equal(out[0].total, 2, 'the wall\'s size counts only what hangs');
140
+ assert.equal(G.shapeWork(out[0]).art.light, PIC(7, 'bbbbbbbbbbbbbbbb'), 'a mode with no render shows the other');
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // 3. the heart: appreciation, once per builder, revocable
145
+ // ---------------------------------------------------------------------------
146
+
147
+ await test('a heart is one row per (work, builder): given with ON CONFLICT DO NOTHING, taken back with a delete', async () => {
148
+ const pool = fakePool({ after: { hearts: 4, mine: true } });
149
+ const on = await G.setHeart({ taskId: 7, on: true, viewer: { id: 5, rank: 'thetes' } }, { pool });
150
+ assert.deepEqual(on, { ok: true, id: '7', hearts: 4, mine: true });
151
+ const ins = pool.calls.find((c) => /^INSERT/.test(c.sql.trim()));
152
+ assert.match(ins.sql, /ON CONFLICT DO NOTHING/);
153
+ assert.deepEqual(ins.params, [7, '5'], 'the builder is the caller');
154
+ const pool2 = fakePool({ after: { hearts: 3, mine: null } });
155
+ const off = await G.setHeart({ taskId: 7, on: false, viewer: { id: 5 } }, { pool: pool2 });
156
+ assert.deepEqual(off, { ok: true, id: '7', hearts: 3, mine: false });
157
+ assert.ok(pool2.calls.some((c) => /^DELETE FROM lifecycle_work_appreciations WHERE task_id = \$1::bigint AND builder_id = \$2::bigint/.test(c.sql.trim())));
158
+ });
159
+
160
+ await test('a heart is refused for anything the viewer\'s wall would not hang, with the wall\'s own rule', async () => {
161
+ const pool = fakePool({ isWork: false });
162
+ const out = await G.setHeart({ taskId: 99, on: true, viewer: { id: 5 } }, { pool });
163
+ assert.deepEqual(out, { ok: false, code: 'not_a_work', status: 404 });
164
+ const check = pool.calls.find((c) => /WHERE t\.id = \$4/.test(c.sql));
165
+ assert.match(check.sql, /t\.security_sensitive IS NOT TRUE/);
166
+ assert.match(check.sql, /goal_members/);
167
+ assert.ok(!pool.calls.some((c) => /^INSERT/.test(c.sql.trim())), 'nothing is written');
168
+ const none = await G.setHeart({ taskId: 7, on: true, viewer: { id: 5 } }, { pool: fakePool({ table: false }) });
169
+ assert.equal(none.code, 'gallery_unavailable');
170
+ });
171
+
172
+ await test('the schema: one heart per (work, builder), going with the work and the builder', () => {
173
+ assert.match(MIGRATION, /CREATE TABLE IF NOT EXISTS lifecycle_work_appreciations/);
174
+ assert.match(MIGRATION, /PRIMARY KEY \(task_id, builder_id\)/);
175
+ assert.match(MIGRATION, /task_id\s+bigint\s+NOT NULL REFERENCES tasks\(id\) ON DELETE CASCADE/);
176
+ assert.match(MIGRATION, /builder_id bigint\s+NOT NULL REFERENCES builders\(id\) ON DELETE CASCADE/);
177
+ assert.ok(MANIFEST.contributes.routes.includes('home-gallery'), 'the routes are mounted');
178
+ });
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // 4. the handlers
182
+ // ---------------------------------------------------------------------------
183
+
184
+ function fakeRes() {
185
+ const r = { code: 200, body: null, headers: {} };
186
+ r.set = (k, v) => { r.headers[k] = v; return r; };
187
+ r.json = (b) => { r.body = b; return r; };
188
+ r.fail = (code, o) => { r.code = o.status; r.body = { error: { code, message: o.message } }; return r; };
189
+ return r;
190
+ }
191
+
192
+ await test('GET /home/gallery passes the order and the signed-in viewer, never a query viewer', async () => {
193
+ let asked = null;
194
+ const h = G.galleryHandler({ read: async (a) => { asked = a; return { order: 'latest', rows: [row({ total: 5 })] }; } });
195
+ const res = fakeRes();
196
+ await h({ query: { order: 'latest', viewer: '1' }, builder: { id: 5, rank: 'metic' } }, res);
197
+ assert.deepEqual(asked, { order: 'latest', viewer: { id: 5, rank: 'metic' } });
198
+ assert.equal(res.body.total, 5);
199
+ assert.equal(res.body.works[0].title, 'Studio');
200
+ assert.equal(res.headers['Cache-Control'], 'private, no-store');
201
+ });
202
+
203
+ await test('PUT /home/gallery/:id/heart wants { on: boolean } and a positive id; a refusal keeps its status', async () => {
204
+ const seen = [];
205
+ const h = G.heartHandler({ write: async (a) => { seen.push(a); return a.taskId === 404 ? { ok: false, code: 'not_a_work', status: 404 } : { ok: true, id: String(a.taskId), hearts: 2, mine: a.on }; } });
206
+ let res = fakeRes(); await h({ params: { id: 'x' }, body: { on: true }, builder: { id: 5 } }, res);
207
+ assert.equal(res.code, 400);
208
+ res = fakeRes(); await h({ params: { id: '7' }, body: { on: 'yes' }, builder: { id: 5 } }, res);
209
+ assert.equal(res.code, 400);
210
+ res = fakeRes(); await h({ params: { id: '7' }, body: { on: true, builder_id: 1 }, builder: { id: 5, rank: 'xenos' } }, res);
211
+ assert.deepEqual(res.body, { id: '7', hearts: 2, mine: true });
212
+ assert.deepEqual(seen[0].viewer, { id: 5, rank: 'xenos' }, 'the heart is the caller\'s, never the body\'s');
213
+ res = fakeRes(); await h({ params: { id: '404' }, body: { on: true }, builder: { id: 5 } }, res);
214
+ assert.equal(res.code, 404);
215
+ });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // 5. the wall label's words (the page's pure half)
219
+ // ---------------------------------------------------------------------------
220
+
221
+ await test('a museum label: the signature, the bio, the credit line', () => {
222
+ assert.equal(Lib.signature('Sana Okafor', '2026-11-03'), 'Sana O. ’26');
223
+ assert.equal(Lib.signature('octocat', '2025-01-01'), 'octocat ’25');
224
+ assert.equal(Lib.bio('2026-03-02'), 'On this project since March 2026');
225
+ assert.equal(Lib.bio(null), 'On this project');
226
+ assert.equal(Lib.credit({ shipped: '2026-11-04', hearts: 1 }), 'Gift of the artist to the project. Shipped 4 November 2026. Appreciated by 1 builder.');
227
+ assert.equal(Lib.credit({ shipped: '2026-11-04', hearts: 31 }).endsWith('Appreciated by 31 builders.'), true);
228
+ });
229
+
230
+ await test('a page title\'s branding holes are filled, and an empty hole leaves no dangling dash', () => {
231
+ assert.equal(Lib.fillHoles('{{productName}}: {{tagline}}', { productName: 'Cloud Bongos' }), 'Cloud Bongos');
232
+ assert.equal(Lib.fillHoles('{{productName}} — Contact', {}), 'Contact');
233
+ assert.equal(Lib.fillHoles('Studio', {}), 'Studio');
234
+ });
235
+
236
+ await test('a picture is shown only from the hall\'s own task-visuals route, in the mode asked', () => {
237
+ const w = { art: { light: PIC(7, 'aaaaaaaaaaaaaaaa'), dark: PIC(7, 'bbbbbbbbbbbbbbbb') } };
238
+ assert.equal(Lib.artFor(w, 'dark'), PIC(7, 'bbbbbbbbbbbbbbbb'));
239
+ assert.equal(Lib.artFor(w, 'light'), PIC(7, 'aaaaaaaaaaaaaaaa'));
240
+ assert.equal(Lib.artFor({ art: { light: 'https://evil.example/x.png' } }, 'light'), null);
241
+ assert.equal(Lib.artFor({ art: { light: '/api/bongos/task-visuals/../../etc/passwd' } }, 'light'), null);
242
+ assert.equal(Lib.artFor({ art: { dark: PIC(3, 'cccccccccccccccc') } }, 'light'), PIC(3, 'cccccccccccccccc'), 'a mode with no render shows the other');
243
+ });
244
+
245
+ await test('the preview fixture is the server\'s own shape, every picture on the hall\'s route', () => {
246
+ assert.ok(FIXTURE.works.length >= 8);
247
+ FIXTURE.works.forEach((w) => {
248
+ assert.ok(Lib.artFor(w, 'light') && Lib.artFor(w, 'dark'), `work ${w.id} has a picture in both modes`);
249
+ assert.deepEqual(Object.keys(w).sort(), ['art', 'artist', 'hearts', 'id', 'medium', 'mine', 'page', 'place', 'shipped', 'title', 'words']);
250
+ });
251
+ const hearts = FIXTURE.works.map((w) => w.hearts);
252
+ assert.deepEqual(hearts, [...hearts].sort((a, b) => b - a), 'Most popular is the fixture\'s order');
253
+ });
254
+
255
+ summary();