@bongos/core 1.19.930 → 1.19.932
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 +43 -18
- package/docs/copy-inventory.md +35 -7
- package/docs/copy-registry.json +352 -9
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/sky.css +365 -0
- package/modules/hall-ui/public/sky.html +79 -0
- package/modules/hall-ui/public/sky.js +580 -0
- package/modules/hall-ui/public/sky.states.json +20 -0
- package/modules/hall-ui/public/thinking.html +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/module-scope-map.js +11 -0
- package/src/bongos/serve-internal.js +6 -1
- package/src/module-api.js +1 -1
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hall_sky.mjs +458 -0
- package/tests/helpers.mjs +121 -0
|
@@ -1160,8 +1160,13 @@ function mountInternalSurfaces(app) {
|
|
|
1160
1160
|
// because the page reads the VIEWER's own trail and nobody else's — the id it
|
|
1161
1161
|
// asks for comes from GET /me, never from the URL, so a rank could protect
|
|
1162
1162
|
// nothing here that the session does not already.
|
|
1163
|
+
// task 1004105 (BV2.R26): `sky` — THE SKY, the ambient view over the same
|
|
1164
|
+
// trail the room lists — joins that tier for exactly the same reasons. It is
|
|
1165
|
+
// the SAME read (GET /inbox/by-builder/:id with the id from GET /me), drawn
|
|
1166
|
+
// as a map instead of a table, so a floor above sign-in would protect on one
|
|
1167
|
+
// page what it admits on the other.
|
|
1163
1168
|
const SIGNEDIN_BUILDERS_PAGE_RE =
|
|
1164
|
-
/^\/builders\/(?:work|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1169
|
+
/^\/builders\/(?:work|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|thinking|sky|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1165
1170
|
// The diagram image assets under /builders/diagrams/ ride the same tier as the
|
|
1166
1171
|
// page that embeds them.
|
|
1167
1172
|
const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.932'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|