@bongos/core 1.19.930 → 1.19.931

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.
@@ -66,6 +66,11 @@ const ORACLE = {
66
66
  // from GET /me, never from the URL), so a rank could protect nothing the
67
67
  // session does not already.
68
68
  '/builders/thinking': 'requireBuilderPage',
69
+ // THE SKY (task 1004105 / BV2.R26) — the ambient view over the SAME trail the
70
+ // room lists, drawn as a map. It reads the same endpoint with the same
71
+ // viewer-derived id, so it sits on the same tier: a rank floor here would
72
+ // protect on one page exactly what it admits on the other.
73
+ '/builders/sky': 'requireBuilderPage',
69
74
  '/builders/collab': 'requireBuilderPage',
70
75
  // task 1002710: the orientation reading rooms moved DOWN to this tier from a
71
76
  // thetes+ gate. They are the onboarding documents, so gating them above xenos
@@ -0,0 +1,458 @@
1
+ // tests/hall_sky.mjs — THE SKY, at the rendered layer
2
+ // (task 1004105 / BV2.R26 of goal 1000095, criterion `ideator-sky-tells-the-truth`).
3
+ //
4
+ // This suite boots the REAL modules/hall-ui/public/sky.js and the REAL
5
+ // modules/hall-ui/public/idea-objects.js in a vm and reads the HTML they write.
6
+ // Nothing here asserts against a projection built by hand: every row goes in
7
+ // shaped the way `GET /inbox/by-builder/:id` shapes one, so a test cannot stay
8
+ // green over a page that reads a field the route does not send.
9
+ //
10
+ // WHAT IT IS ACTUALLY GUARDING, and why it is not tests/hall_thinking_room.mjs
11
+ // again. Everything the sky draws is already correct and already tested: the
12
+ // five states (tests/idea_state.mjs), size (tests/idea_size.mjs), brightness
13
+ // (tests/idea_brightness.mjs), the descent (tests/idea_descent_surface.mjs),
14
+ // and the room proves the WORDS. What is new here is that the answers become
15
+ // GEOMETRY, and a drawing has its own ways of lying that nothing upstream can
16
+ // see:
17
+ //
18
+ // - a diameter computed from a second curve, so one thought is two sizes on
19
+ // two pages;
20
+ // - a remnant drawn small, which destroys the entire two-channel model — the
21
+ // criterion's own proof (BV2.R27) is "large and dead", and if the glyph
22
+ // shrinks with brightness there is nothing left to prove;
23
+ // - an unreadable brightness drawn as a dim star, which states that work was
24
+ // removed when the only thing that happened is that nobody could look;
25
+ // - a picture with no words, which is a page a screen reader cannot use;
26
+ // - a well drawn around a goal that never caught the thought, or rings
27
+ // claiming a goal has no criteria when the truth is nobody asked.
28
+ //
29
+ // It also holds the one structural promise of spec decision 11: the sky is
30
+ // never the only path to anything it shows.
31
+
32
+ import assert from 'node:assert/strict';
33
+ import fs from 'node:fs/promises';
34
+ import path from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { makeRunner, settle, bootSkyPage } from './helpers.mjs';
37
+
38
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
39
+ const readFile = (rel) => fs.readFile(path.join(ROOT, rel), 'utf8');
40
+
41
+ const { test, summary } = makeRunner();
42
+
43
+ // The /me read, then a trail page, then the background loop, then /goals and a
44
+ // goal detail each — several awaits deep, so one microtask turn is far short.
45
+ const drain = async (n = 20) => { for (let i = 0; i < n; i += 1) await settle(); };
46
+
47
+ // A trail row in the shape the route emits. Defaults are the dullest possible
48
+ // thought — filed, nothing came of it, floating — so every case below states
49
+ // only the facts it is actually about.
50
+ function idea(over = {}) {
51
+ return {
52
+ id: 1,
53
+ title: 'A thought',
54
+ kind: 'feature',
55
+ status: 'open',
56
+ captured_at: '2026-09-01T00:00:00.000Z',
57
+ triage_note: null,
58
+ promoted_to_task_id: null,
59
+ promoted_task_title: null,
60
+ promoted_task_status: null,
61
+ merged_into_idea_id: null,
62
+ merged_into_idea_title: null,
63
+ lineage_tasks: [],
64
+ descent_tasks: [],
65
+ size: { credits: 0, payments: 0, scale: 0, historical: false },
66
+ state: {
67
+ state: 'protostar', position: 'open-space', wellGoalId: null,
68
+ awaitingNod: false, brightness: null, hasWork: false, attributable: true,
69
+ },
70
+ ...over,
71
+ };
72
+ }
73
+ const page = (ideas, over = {}) => ({ ideas, total: ideas.length, offset: 0, limit: 100, ...over });
74
+
75
+ const star = (over = {}) => idea({
76
+ id: 10, title: 'It ignited', status: 'promoted',
77
+ lineage_tasks: [{ task_id: 500, origin: 'promote', title: 'The task', status: 'shipped', goal_id: 7 }],
78
+ size: { credits: 240, payments: 4, scale: 0.86, historical: false },
79
+ state: { state: 'star', position: 'well', wellGoalId: '7', awaitingNod: false, brightness: 1, hasWork: true, attributable: true },
80
+ ...over,
81
+ });
82
+ const remnant = (over = {}) => star({
83
+ id: 11, title: 'It burned, then it went',
84
+ size: { credits: 180, payments: 5, scale: 0.86, historical: false },
85
+ state: { state: 'remnant', position: 'well', wellGoalId: '7', awaitingNod: false, brightness: 0, hasWork: true, attributable: true },
86
+ ...over,
87
+ });
88
+ const coldBody = (over = {}) => idea({
89
+ id: 12, title: 'It never ignited', status: 'discarded',
90
+ size: { credits: 0, payments: 0, scale: 0, historical: false },
91
+ state: { state: 'cold-body', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: true },
92
+ ...over,
93
+ });
94
+
95
+ // The diameter a glyph was drawn at, and the object it says it is, pulled off
96
+ // the markup by the idea's own button so a case never has to guess which glyph
97
+ // belongs to which thought.
98
+ function glyphOf(html, ideaId) {
99
+ const re = new RegExp(`data-idea="${ideaId}"[\\s\\S]*?<span class="sky-glyph"([^>]*)>`);
100
+ const m = re.exec(html);
101
+ if (!m) return null;
102
+ const attrs = m[1];
103
+ return {
104
+ object: (/data-object="([^"]+)"/.exec(attrs) || [])[1] || null,
105
+ d: Number((/--d:(\d+)px/.exec(attrs) || [])[1]),
106
+ lit: Number((/--lit:([\d.]+)/.exec(attrs) || [])[1]),
107
+ unknownSize: /data-size="unknown"/.test(attrs),
108
+ };
109
+ }
110
+ const labelOf = (html, ideaId) => (new RegExp(`data-idea="${ideaId}"[\\s\\S]*?aria-label="([^"]*)"`).exec(html) || [])[1] || '';
111
+
112
+ // ═══ the five objects, in the shared vocabulary ═════════════════════════════
113
+
114
+ await test('every one of the five draws its own object, and the word comes from the shared table', async () => {
115
+ const all = [
116
+ star(),
117
+ remnant(),
118
+ coldBody(),
119
+ idea({ id: 13, title: 'Folded', status: 'merged', merged_into_idea_id: 999, merged_into_idea_title: 'The host',
120
+ state: { state: 'satellite', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: 1, hasWork: true, attributable: true } }),
121
+ idea({ id: 14, title: 'Waiting' }),
122
+ ];
123
+ const p = bootSkyPage({ pages: [page(all)] });
124
+ await drain();
125
+ const html = p.field();
126
+ const seen = [10, 11, 12, 13, 14].map((id) => glyphOf(html, id).object);
127
+ assert.deepEqual(seen, ['star', 'remnant', 'cold-body', 'satellite', 'protostar']);
128
+ // The words are the shared table's, never this page's.
129
+ const objects = p.objects();
130
+ for (const id of objects.ORDER) assert.ok(p.legend().includes(objects.objectFor(id).label), `the legend says "${objects.objectFor(id).label}"`);
131
+ });
132
+
133
+ await test('the legend is built from ORDER, so the page cannot grow a sixth object or lose one', async () => {
134
+ const p = bootSkyPage({ pages: [page([star()])] });
135
+ await drain();
136
+ const marks = [...p.legend().matchAll(/class="sky-glyph" data-object="([^"]+)"/g)].map((m) => m[1]);
137
+ assert.deepEqual(marks, [...p.objects().ORDER]);
138
+ });
139
+
140
+ // ═══ size is geometry, and the curve is the server's ════════════════════════
141
+
142
+ await test('the diameter tracks the server scale, and nothing on this page re-compresses it', async () => {
143
+ const small = star({ id: 20, size: { credits: 12, payments: 1, scale: 0.1, historical: false } });
144
+ const big = star({ id: 21, size: { credits: 2400, payments: 40, scale: 1, historical: false } });
145
+ const p = bootSkyPage({ pages: [page([small, big])] });
146
+ await drain();
147
+ const a = glyphOf(p.field(), 20);
148
+ const b = glyphOf(p.field(), 21);
149
+ assert.ok(b.d > a.d, 'a bigger scale draws a bigger body');
150
+ // The mapping is AFFINE in the server's scale — that is the whole claim. A
151
+ // page taking its own log of an already-compressed figure is how the sky and
152
+ // the room end up drawing one thought at two sizes, and the only way to catch
153
+ // it is to check a third point against the line the other two define.
154
+ const mid = star({ id: 22, size: { credits: 200, payments: 4, scale: 0.55, historical: false } });
155
+ const q = bootSkyPage({ pages: [page([small, big, mid])] });
156
+ await drain();
157
+ const c = glyphOf(q.field(), 22);
158
+ const expected = a.d + (b.d - a.d) * ((0.55 - 0.1) / (1 - 0.1));
159
+ assert.ok(Math.abs(c.d - expected) <= 1, `0.55 lands on the line the endpoints define (${c.d} vs ~${expected.toFixed(1)})`);
160
+ });
161
+
162
+ await test('A LARGE DEAD THOUGHT RENDERS LARGE AND DEAD — it keeps its diameter and loses its fill', async () => {
163
+ // The criterion's own picture, and the thing BV2.R27 goes on to prove live.
164
+ // The two rows differ ONLY in what happened to the work: same scale on the
165
+ // remnant as on the star, nothing at all on the cold body.
166
+ const p = bootSkyPage({ pages: [page([star(), remnant(), coldBody()])] });
167
+ await drain();
168
+ const html = p.field();
169
+ const alive = glyphOf(html, 10);
170
+ const dead = glyphOf(html, 11);
171
+ const never = glyphOf(html, 12);
172
+ assert.equal(dead.d, alive.d, 'size never falls: the remnant is exactly as big as the star');
173
+ assert.ok(dead.d > never.d, 'and it is visibly bigger than a thought that never mattered');
174
+ assert.equal(dead.object, 'remnant');
175
+ assert.equal(never.object, 'cold-body');
176
+ // Distinguishable in WORDS too, not only in shape — the picture is not the
177
+ // only channel a reader has.
178
+ assert.match(labelOf(html, 11), /burned out/);
179
+ assert.match(labelOf(html, 11), /none of it still alive/);
180
+ assert.match(labelOf(html, 12), /declined/);
181
+ });
182
+
183
+ await test('a size that could not be read draws at the floor and says so, never as a measured zero', async () => {
184
+ const unreadable = star({ id: 30, size: null });
185
+ const zero = coldBody({ id: 31 });
186
+ const p = bootSkyPage({ pages: [page([unreadable, zero])] });
187
+ await drain();
188
+ const a = glyphOf(p.field(), 30);
189
+ const b = glyphOf(p.field(), 31);
190
+ assert.equal(a.unknownSize, true, 'the unreadable one is marked');
191
+ assert.equal(b.unknownSize, false, 'a real zero is not');
192
+ assert.match(labelOf(p.field(), 30), /size unknown/);
193
+ p.pick(30);
194
+ assert.match(p.panel(), /unknown/, 'the panel says unknown rather than a number');
195
+ assert.ok(!/size — the work it caused<\/span><span class="sky-fact__v">0/.test(p.panel()));
196
+ });
197
+
198
+ await test('a historical size carries its marker wherever the number is shown', async () => {
199
+ const p = bootSkyPage({ pages: [page([star({ size: { credits: 96, payments: 3, scale: 0.71, historical: true } })])] });
200
+ await drain();
201
+ p.pick(10);
202
+ assert.match(p.panel(), /part historical/);
203
+ assert.match(p.panel(), /not payouts that were made/);
204
+ });
205
+
206
+ // ═══ brightness: the three answers stay three ═══════════════════════════════
207
+
208
+ await test('brightness rides the glyph, and a dimmer star is drawn dimmer', async () => {
209
+ const full = star({ id: 40 });
210
+ const half = star({ id: 41, state: { ...star().state, brightness: 0.5 } });
211
+ const p = bootSkyPage({ pages: [page([full, half])] });
212
+ await drain();
213
+ assert.equal(glyphOf(p.field(), 40).lit, 1);
214
+ assert.equal(glyphOf(p.field(), 41).lit, 0.5);
215
+ assert.match(labelOf(p.field(), 41), /50% still alive/);
216
+ });
217
+
218
+ await test('AN UNREADABLE BRIGHTNESS LEAVES THE STAR FULLY LIT and puts the doubt in words', async () => {
219
+ // idea-state.js step 3, one layer up and one channel over: `attributable:
220
+ // false` means the removal facts could not be read, and dimming on it would
221
+ // draw a healthy instance as a field of half-dead stars the moment the
222
+ // lifecycle module failed to mount.
223
+ const unknown = star({ id: 50, state: { ...star().state, brightness: null, attributable: false } });
224
+ const out = star({ id: 51, state: { ...star().state, brightness: 0, attributable: true } });
225
+ const p = bootSkyPage({ pages: [page([unknown, out])] });
226
+ await drain();
227
+ assert.equal(glyphOf(p.field(), 50).lit, 1, 'unreadable is drawn at full');
228
+ assert.equal(glyphOf(p.field(), 51).lit, 0, 'a confirmed-dead descent is not');
229
+ assert.match(labelOf(p.field(), 50), /still alive — unknown/);
230
+ });
231
+
232
+ await test('the panel draws no brightness BAR for a share nobody could measure', async () => {
233
+ // A bar at zero and a bar that could not be measured look identical, and one
234
+ // of them is a claim. The words carry it instead.
235
+ const unknown = star({ id: 60, state: { ...star().state, brightness: null, attributable: false } });
236
+ const p = bootSkyPage({ pages: [page([unknown])] });
237
+ await drain();
238
+ p.pick(60);
239
+ assert.ok(!/sky-bar/.test(p.panel()), 'no bar on an unreadable share');
240
+ assert.match(p.panel(), /still alive — unknown/);
241
+
242
+ const q = bootSkyPage({ pages: [page([star({ id: 61, state: { ...star().state, brightness: 0 } })])] });
243
+ await drain();
244
+ q.pick(61);
245
+ assert.match(q.panel(), /sky-bar[^>]*data-lit="out"/, 'a measured zero DOES get a bar, drawn out');
246
+ });
247
+
248
+ await test('a thought with no work yet says there is nothing to be alive, not that it died', async () => {
249
+ const p = bootSkyPage({ pages: [page([idea({ id: 70 })])] });
250
+ await drain();
251
+ p.pick(70);
252
+ assert.match(p.panel(), /nothing to be alive yet/);
253
+ assert.ok(!/none of it still alive/.test(p.panel()));
254
+ });
255
+
256
+ // ═══ gravity: the wells, and the rings inside them ══════════════════════════
257
+
258
+ await test('a well is the goal that HOLDS the work, and its name comes from the goals read', async () => {
259
+ const p = bootSkyPage({
260
+ pages: [page([star(), idea({ id: 80 })])],
261
+ goals: [{ id: '7', title: 'The catching goal' }],
262
+ });
263
+ await drain();
264
+ assert.match(p.field(), /The catching goal/);
265
+ // The floating one is not in it.
266
+ assert.match(p.field(), /Open space/);
267
+ assert.match(p.field(), /no goal has claimed this one/);
268
+ });
269
+
270
+ await test('a well whose goal could not be named keeps its id — never a blank label', async () => {
271
+ const p = bootSkyPage({ pages: [page([star()])], goals: null });
272
+ await drain();
273
+ assert.match(p.field(), /Goal 7/);
274
+ });
275
+
276
+ await test('the rings are the criteria, closing as each is satisfied', async () => {
277
+ const p = bootSkyPage({
278
+ pages: [page([star()])],
279
+ goals: [{ id: '7', title: 'The catching goal' }],
280
+ criteria: { 7: [{ satisfied: true }, { satisfied: true }, { satisfied: false }] },
281
+ });
282
+ await drain();
283
+ const rings = [...p.field().matchAll(/class="sky-ring"( data-closed="1")?/g)].map((m) => !!m[1]);
284
+ assert.deepEqual(rings, [true, true, false]);
285
+ assert.match(p.field(), /2 of 3 criteria met/);
286
+ });
287
+
288
+ await test('a goal whose criteria were NOT READ draws no rings, and does not claim it has none', async () => {
289
+ const p = bootSkyPage({
290
+ pages: [page([star()])],
291
+ goals: [{ id: '7', title: 'The catching goal' }],
292
+ criteria: {},
293
+ });
294
+ await drain();
295
+ assert.ok(!/sky-ring/.test(p.field()), 'no rings at all');
296
+ assert.ok(!/0 of 0 criteria/.test(p.field()), 'and no claim about how many there are');
297
+ assert.match(p.field(), /1 thought/, 'it falls back to what it does know');
298
+ });
299
+
300
+ await test('a satellite is drawn ON the thought it was folded into, when that one is in view', async () => {
301
+ const host = star({ id: 90 });
302
+ const moon = idea({
303
+ id: 91, title: 'Folded in', status: 'merged', merged_into_idea_id: 90, merged_into_idea_title: 'It ignited',
304
+ state: { state: 'satellite', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: 1, hasWork: true, attributable: true },
305
+ });
306
+ const p = bootSkyPage({ pages: [page([host, moon])], goals: [{ id: '7', title: 'The catching goal' }] });
307
+ await drain();
308
+ const html = p.field();
309
+ // Both in the HOST's well, not one in open space — the satellite's own
310
+ // wellGoalId is null, so grouping by that alone would separate them.
311
+ assert.ok(!/Open space/.test(html), 'the satellite did not float off on its own');
312
+ const pos = (id) => {
313
+ const m = new RegExp(`data-idea="${id}"[\\s\\S]*?left:([\\d.-]+)%;top:([\\d.-]+)%`).exec(html);
314
+ return { left: Number(m[1]), top: Number(m[2]) };
315
+ };
316
+ const a = pos(90);
317
+ const b = pos(91);
318
+ const gap = Math.hypot(a.left - b.left, a.top - b.top);
319
+ assert.ok(gap > 0 && gap < 12, `it orbits the host rather than taking a slot of its own (gap ${gap.toFixed(1)}%)`);
320
+ });
321
+
322
+ await test('a protostar in a well says a nod is waiting; one in open space says nobody claimed it', async () => {
323
+ const waiting = idea({
324
+ id: 100, title: 'Caught, not lit', status: 'promoted',
325
+ lineage_tasks: [{ task_id: 7, origin: 'promote', title: 'T', status: 'backlog', goal_id: 7 }],
326
+ state: { state: 'protostar', position: 'well', wellGoalId: '7', awaitingNod: true, brightness: null, hasWork: false, attributable: true },
327
+ });
328
+ const floating = idea({ id: 101, title: 'Nobody caught it' });
329
+ const p = bootSkyPage({ pages: [page([waiting, floating])], goals: [{ id: '7', title: 'A goal' }] });
330
+ await drain();
331
+ assert.match(labelOf(p.field(), 100), /waiting for a nod/);
332
+ assert.match(labelOf(p.field(), 101), /nothing has claimed it/);
333
+ });
334
+
335
+ // ═══ never the only path ════════════════════════════════════════════════════
336
+
337
+ await test('every thought carries its whole sentence, so the picture is readable without seeing it', async () => {
338
+ const p = bootSkyPage({ pages: [page([star({ id: 110, title: 'A named thought' })])] });
339
+ await drain();
340
+ const label = labelOf(p.field(), 110);
341
+ for (const bit of ['A named thought', 'burning', 'size 240', 'all of it still alive']) {
342
+ assert.ok(label.includes(bit), `the aria-label says "${bit}" — got "${label}"`);
343
+ }
344
+ // And it is a real control, not a div with a handler.
345
+ assert.match(p.field(), /<button type="button" class="sky-body"/);
346
+ });
347
+
348
+ await test('the page points at the list before anything is picked, and the panel links out after', async () => {
349
+ const p = bootSkyPage({ pages: [page([star()])], goals: [{ id: '7', title: 'A goal' }] });
350
+ await drain();
351
+ assert.match(p.panel(), /href="\/thinking"/, 'the unpicked panel names the room');
352
+ p.pick(10);
353
+ const html = p.panel();
354
+ assert.match(html, /href="\/idea\/10"/, 'the record');
355
+ assert.match(html, /href="\/goals#\/goal\/7"/, 'the well');
356
+ assert.match(html, /href="\/task\/500"/, 'and the work it became');
357
+ });
358
+
359
+ await test('the shell markup itself carries the way back to the list', async () => {
360
+ const html = await readFile('modules/hall-ui/public/sky.html');
361
+ assert.match(html, /href="\/builders\/thinking"/);
362
+ // …and the room carries the way here, so the pair is reachable both ways.
363
+ const room = await readFile('modules/hall-ui/public/thinking.html');
364
+ assert.match(room, /href="\/builders\/sky"/);
365
+ });
366
+
367
+ // ═══ the read ═══════════════════════════════════════════════════════════════
368
+
369
+ await test('it reads the VIEWER own trail — the id comes from /me, never from the URL', async () => {
370
+ const p = bootSkyPage({ pages: [page([star()])], viewer: { id: 4242 } });
371
+ await drain();
372
+ assert.ok(p.requests.some((u) => u.includes('/inbox/by-builder/4242')));
373
+ assert.ok(!p.requests.some((u) => /builder=/.test(u)), 'no builder query parameter anywhere');
374
+ });
375
+
376
+ await test('the whole trail is read in the background, and the figures count what arrived', async () => {
377
+ const first = page([star({ id: 200 }), star({ id: 201 })], { total: 3, offset: 0 });
378
+ const second = page([remnant({ id: 202 })], { total: 3, offset: 2 });
379
+ const p = bootSkyPage({ pages: [first, second] });
380
+ await drain(40);
381
+ assert.equal(p.requests.filter((u) => u.includes('/inbox/by-builder/')).length, 2);
382
+ assert.match(p.sub(), /2 burning, 1 burned out, 3 in all/);
383
+ });
384
+
385
+ await test('the wells are read AFTER the field is on screen, so the picture never waits on them', async () => {
386
+ const p = bootSkyPage({ pages: [page([star()])], goals: [{ id: '7', title: 'A goal' }] });
387
+ await drain();
388
+ const trailAt = p.requests.findIndex((u) => u.includes('/inbox/by-builder/'));
389
+ const goalsAt = p.requests.findIndex((u) => /\/goals$/.test(u));
390
+ assert.ok(trailAt >= 0 && goalsAt > trailAt, 'the trail is asked for first');
391
+ });
392
+
393
+ await test('an empty trail says so, and points at the one gesture that fixes it', async () => {
394
+ const p = bootSkyPage({ pages: [page([])] });
395
+ await drain();
396
+ assert.match(p.field(), /nothing in your sky/);
397
+ assert.match(p.field(), /href="\/ideas"/);
398
+ });
399
+
400
+ await test('a trail that could not be read says so rather than drawing an empty sky', async () => {
401
+ const p = bootSkyPage({ pages: [page([star()])], trailStatus: 500 });
402
+ await drain();
403
+ assert.match(p.field(), /Could not read your idea trail/);
404
+ assert.ok(!/sky-body/.test(p.field()));
405
+ });
406
+
407
+ // ═══ it derives nothing the server already answered ═════════════════════════
408
+
409
+ await test('when the status and the resolved state disagree, the page draws the STATE', async () => {
410
+ // The room's rule, and it matters more here: a state decided twice is two
411
+ // states, and the sky is the surface most tempted to guess because it draws
412
+ // rather than lists. `status` may only be read for what a thought BECAME.
413
+ //
414
+ // A GREP CANNOT ASK THIS. `headline()` legitimately counts `state === 'star'`
415
+ // — the server's own vocabulary, used as the server emitted it — so banning
416
+ // the five words from the source would fail a correct page while still
417
+ // passing one that re-derived the object from something else entirely. So the
418
+ // question is put as a disagreement the page has to resolve: a row whose
419
+ // `status` says it was discarded and whose resolver says it is a burning
420
+ // star. Anything reading the status draws a cold body.
421
+ const liar = star({
422
+ id: 300, title: 'Discarded, and still burning', status: 'discarded', triage_note: 'a duplicate',
423
+ });
424
+ const p = bootSkyPage({ pages: [page([liar])] });
425
+ await drain();
426
+ assert.equal(glyphOf(p.field(), 300).object, 'star', 'the resolver wins');
427
+ assert.match(labelOf(p.field(), 300), /burning/);
428
+ assert.ok(!/declined/.test(labelOf(p.field(), 300)));
429
+ // …and the other direction: a row the resolver calls a cold body draws one
430
+ // however alive its own columns look.
431
+ const other = star({
432
+ id: 301, title: 'Promoted, and never lit', status: 'promoted',
433
+ state: { state: 'cold-body', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: true },
434
+ });
435
+ const q = bootSkyPage({ pages: [page([other])] });
436
+ await drain();
437
+ assert.equal(glyphOf(q.field(), 301).object, 'cold-body');
438
+ });
439
+
440
+ await test('a state the page has never heard of is drawn through, not swallowed', async () => {
441
+ // idea-objects.js passes an unknown state through rather than defaulting it,
442
+ // because a sixth object would be a real answer the server gave. Drawing it
443
+ // unstyled beats drawing nothing, and beats drawing it as a protostar.
444
+ const sixth = star({ id: 310, state: { ...star().state, state: 'pulsar' } });
445
+ const p = bootSkyPage({ pages: [page([sixth])] });
446
+ await drain();
447
+ assert.equal(glyphOf(p.field(), 310).object, 'pulsar');
448
+ assert.match(labelOf(p.field(), 310), /pulsar/);
449
+ });
450
+
451
+ await test('it computes no brightness and no compression of its own', async () => {
452
+ const src = await readFile('modules/hall-ui/public/sky.js');
453
+ const code = src.split('\n').filter((l) => !/^\s*\/\//.test(l)).join('\n');
454
+ assert.ok(!/Math\.log/.test(code), 'no second compression curve');
455
+ assert.ok(!/descent_tasks\s*\.\s*length\s*\/\s*/.test(code), 'no brightness computed from a descent length');
456
+ });
457
+
458
+ summary();
package/tests/helpers.mjs CHANGED
@@ -855,3 +855,124 @@ export function bootThinkingPage({ pages = [], viewer = { id: 95 }, meStatus = 2
855
855
  filter: (id) => { tabState.active = id; if (tabState.onChange) tabState.onChange(id); },
856
856
  };
857
857
  }
858
+
859
+ // bootSkyPage — boot the REAL modules/hall-ui/public/sky.js in a vm against a
860
+ // stub DOM, and hand back the field, the panel and the legend it wrote plus
861
+ // every request it made.
862
+ //
863
+ // The fourth sibling of bootProfilePage / bootIdeaPage / bootThinkingPage, and
864
+ // here for the reason the third one is: THE SKY (task 1004105 / BV2.R26) is a
865
+ // DRAWING of facts that are all correct upstream, and the only place it can go
866
+ // wrong is the last step. A glyph is geometry, so the questions this can ask
867
+ // that no server test can are: does a remnant keep its diameter and lose its
868
+ // fill, does an unreadable brightness leave a star lit, does the page draw a
869
+ // second compression curve of its own, and does every thought carry its whole
870
+ // sentence for a reader who cannot see the picture.
871
+ //
872
+ // THE REAL idea-objects.js IS LOADED INTO THE SAME SANDBOX, never stubbed —
873
+ // the same rule bootThinkingPage keeps, and for the sharper reason here: the
874
+ // pair exists so the sky and the room cannot call one object two names, and a
875
+ // suite that stubbed the vocabulary would assert the sky says whatever the
876
+ // suite told it to.
877
+ const SKY_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/sky.js'), 'utf8');
878
+ export function bootSkyPage({
879
+ pages = [], viewer = { id: 95 }, meStatus = 200, trailStatus = 200,
880
+ goals = null, criteria = {},
881
+ } = {}) {
882
+ const els = new Map();
883
+ const listeners = new Map();
884
+ const makeEl = (id) => ({
885
+ dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
886
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
887
+ setAttribute() {}, getAttribute() { return null; },
888
+ addEventListener(type, fn) { listeners.set(`${id}:${type}`, fn); },
889
+ querySelector() { return null; }, querySelectorAll() { return []; }, appendChild() {},
890
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
891
+ });
892
+ const documentObj = {
893
+ title: '',
894
+ body: { dataset: { page: 'sky' } },
895
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
896
+ getElementById(id) { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); },
897
+ querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
898
+ querySelectorAll() { return []; },
899
+ createElement() { return makeEl('new'); },
900
+ addEventListener() {},
901
+ };
902
+
903
+ const requests = [];
904
+ const client = {
905
+ request: async (method, url) => {
906
+ requests.push(url);
907
+ if (/\/me$/.test(url)) {
908
+ return meStatus === 200
909
+ ? { status: 200, ok: true, data: { builder: viewer } }
910
+ : { status: meStatus, ok: false, data: null };
911
+ }
912
+ // The two well reads. `goals: null` is a route that could not be read at
913
+ // all, which must leave the wells named by their id rather than blank;
914
+ // `criteria` absent for a goal is "not read", which must leave that well
915
+ // with no rings rather than zero of them.
916
+ const one = /\/goals\/(\d+)$/.exec(url);
917
+ if (one) {
918
+ const list = criteria[one[1]];
919
+ return list === undefined
920
+ ? { status: 404, ok: false, data: null }
921
+ : { status: 200, ok: true, data: { goal: { id: one[1] }, criteria: list } };
922
+ }
923
+ if (/\/goals$/.test(url)) {
924
+ return goals === null
925
+ ? { status: 500, ok: false, data: null }
926
+ : { status: 200, ok: true, data: { goals } };
927
+ }
928
+ if (trailStatus !== 200) return { status: trailStatus, ok: false, data: null };
929
+ const offset = Number((/offset=(\d+)/.exec(url) || [])[1] || 0);
930
+ const page = pages.find((p) => Number(p.offset || 0) === offset) || { ideas: [], total: 0 };
931
+ return { status: 200, ok: true, data: page };
932
+ },
933
+ };
934
+
935
+ const windowObj = {
936
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
937
+ BongosClient: { createClient: () => client },
938
+ CSS: { escape: (s) => String(s) },
939
+ OTB: {
940
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
941
+ $: (sel) => documentObj.querySelector(sel),
942
+ fmtDate: (iso) => String(iso ?? '').slice(0, 10),
943
+ toast() {},
944
+ },
945
+ location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/sky', hash: '', assign() {} },
946
+ localStorage: { getItem: () => null, setItem() {} },
947
+ addEventListener() {},
948
+ };
949
+
950
+ const sandbox = {
951
+ window: windowObj, document: documentObj, location: windowObj.location,
952
+ console, setTimeout, clearTimeout, URLSearchParams, Math, Number,
953
+ };
954
+ sandbox.globalThis = sandbox;
955
+ vm.createContext(sandbox);
956
+ vm.runInContext(IDEA_OBJECTS_JS, sandbox, { filename: 'idea-objects.js' });
957
+ vm.runInContext(SKY_JS, sandbox, { filename: 'sky.js' });
958
+
959
+ return {
960
+ requests,
961
+ objects: () => windowObj.OTBIdeaObjects,
962
+ field: () => documentObj.getElementById('sky-map').innerHTML,
963
+ panel: () => documentObj.getElementById('sky-panel').innerHTML,
964
+ legend: () => documentObj.getElementById('sky-legend').innerHTML,
965
+ sub: () => documentObj.getElementById('sky-sub').textContent,
966
+ foot: () => documentObj.getElementById('sky-foot'),
967
+ // Picking is the page's only control, and it is DELEGATED — one listener on
968
+ // the field for a trail of 300 bodies — so the suite drives it the way a
969
+ // click does rather than by calling an internal the page does not export.
970
+ pick: (ideaId) => {
971
+ const fn = listeners.get('sky-map:click');
972
+ if (!fn) throw new Error('the field wired no click listener');
973
+ fn({ target: { closest: (sel) => (sel === '[data-idea]'
974
+ ? { getAttribute: () => String(ideaId) }
975
+ : null) } });
976
+ },
977
+ };
978
+ }