@bongos/core 1.19.928 → 1.19.929
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 +78 -48
- package/docs/copy-inventory.md +36 -15
- package/docs/copy-registry.json +239 -28
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea-objects.js +109 -0
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +11 -0
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +286 -0
- package/modules/hall-ui/public/thinking.html +74 -0
- package/modules/hall-ui/public/thinking.js +418 -0
- package/modules/hall-ui/public/thinking.states.json +20 -0
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/serve-internal.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/hall_nav.mjs +1 -1
- package/tests/hall_page_gate_map.mjs +6 -0
- package/tests/hall_thinking_room.mjs +366 -0
- package/tests/helpers.mjs +124 -0
- package/tests/nav_permission_atoms.mjs +1 -1
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// tests/hall_thinking_room.mjs — THE ROOM, at the rendered layer
|
|
2
|
+
// (task 1004098 / BV2.R03 of goal 1000095, criterion `ideator-nothing-is-lost`).
|
|
3
|
+
//
|
|
4
|
+
// This suite boots the REAL modules/hall-ui/public/thinking.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. Every number this page draws is computed and
|
|
11
|
+
// tested server-side already — the five states (tests/idea_state.mjs), size
|
|
12
|
+
// (tests/idea_size.mjs), brightness (tests/idea_brightness.mjs), the descent
|
|
13
|
+
// (tests/idea_descent_surface.mjs). What none of those can see is the last
|
|
14
|
+
// step, where a correct answer is turned into a sentence: a renderer that
|
|
15
|
+
// collapses "we could not read the removal facts" into "none of it survived",
|
|
16
|
+
// or that computes its own compression curve, or that counts its figures over
|
|
17
|
+
// one window while labelling them as a life's work, fails nothing upstream. So
|
|
18
|
+
// the cases below are mostly about the THREE ANSWERS staying three, and about
|
|
19
|
+
// the page never deriving what the server already decided.
|
|
20
|
+
|
|
21
|
+
import { makeRunner, settle } from './helpers.mjs';
|
|
22
|
+
import { bootThinkingPage } from './helpers.mjs';
|
|
23
|
+
|
|
24
|
+
const { test, summary } = makeRunner();
|
|
25
|
+
|
|
26
|
+
// Several awaits deep (the /me read, then a page, then the background loop),
|
|
27
|
+
// so one microtask turn is not enough. Ten is comfortably past the longest
|
|
28
|
+
// chain any case below sets up.
|
|
29
|
+
const drain = async (n = 10) => { for (let i = 0; i < n; i += 1) await settle(); };
|
|
30
|
+
|
|
31
|
+
// A trail row in the shape the route emits. Defaults are the dullest possible
|
|
32
|
+
// thought — filed, nothing came of it — so every case below states only the
|
|
33
|
+
// facts it is actually about.
|
|
34
|
+
function idea(over = {}) {
|
|
35
|
+
return {
|
|
36
|
+
id: 1,
|
|
37
|
+
title: 'A thought',
|
|
38
|
+
kind: 'feature',
|
|
39
|
+
status: 'open',
|
|
40
|
+
captured_at: '2026-09-01T00:00:00.000Z',
|
|
41
|
+
triage_note: null,
|
|
42
|
+
promoted_to_task_id: null,
|
|
43
|
+
promoted_task_title: null,
|
|
44
|
+
promoted_task_status: null,
|
|
45
|
+
merged_into_idea_id: null,
|
|
46
|
+
merged_into_idea_title: null,
|
|
47
|
+
lineage_tasks: [],
|
|
48
|
+
descent_tasks: [],
|
|
49
|
+
size: { credits: 0, payments: 0, scale: 0, historical: false },
|
|
50
|
+
state: {
|
|
51
|
+
state: 'protostar', position: 'open-space', wellGoalId: null,
|
|
52
|
+
awaitingNod: false, brightness: null, hasWork: false, attributable: true,
|
|
53
|
+
},
|
|
54
|
+
...over,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const page = (ideas, over = {}) => ({ ideas, total: ideas.length, offset: 0, limit: 100, ...over });
|
|
58
|
+
|
|
59
|
+
const star = (over = {}) => idea({
|
|
60
|
+
id: 10, title: 'It ignited', status: 'promoted',
|
|
61
|
+
lineage_tasks: [{ task_id: 500, origin: 'promote', title: 'The task', status: 'shipped', goal_id: 7 }],
|
|
62
|
+
size: { credits: 240, payments: 4, scale: 0.86, historical: false },
|
|
63
|
+
state: { state: 'star', position: 'well', wellGoalId: 7, awaitingNod: false, brightness: 1, hasWork: true, attributable: true },
|
|
64
|
+
...over,
|
|
65
|
+
});
|
|
66
|
+
const remnant = (over = {}) => star({
|
|
67
|
+
id: 11, title: 'It burned, then it went',
|
|
68
|
+
size: { credits: 180, payments: 5, scale: 0.81, historical: false },
|
|
69
|
+
state: { state: 'remnant', position: 'well', wellGoalId: 7, awaitingNod: false, brightness: 0, hasWork: true, attributable: true },
|
|
70
|
+
...over,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// The five objects reach the reader as the design's words
|
|
75
|
+
|
|
76
|
+
await test('every one of the five objects renders its own label from the shared table', async () => {
|
|
77
|
+
const rows = [
|
|
78
|
+
star(),
|
|
79
|
+
idea({ id: 2, state: { state: 'protostar', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: true } }),
|
|
80
|
+
idea({ id: 3, status: 'merged', merged_into_idea_id: 9, merged_into_idea_title: 'The other one', state: { state: 'satellite', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: 1, hasWork: true, attributable: true } }),
|
|
81
|
+
remnant(),
|
|
82
|
+
idea({ id: 5, status: 'discarded', state: { state: 'cold-body', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: false } }),
|
|
83
|
+
];
|
|
84
|
+
const p = bootThinkingPage({ pages: [page(rows)] });
|
|
85
|
+
await drain();
|
|
86
|
+
const html = p.rows();
|
|
87
|
+
const objects = p.objects();
|
|
88
|
+
for (const key of objects.ORDER) {
|
|
89
|
+
const label = objects.objectFor(key).label;
|
|
90
|
+
if (!html.includes(`data-object="${key}"`)) throw new Error(`no row marked ${key}`);
|
|
91
|
+
if (!html.includes(`>${label}<`)) throw new Error(`${key} never says "${label}"`);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('the page does not re-derive the object — the server\'s state wins over the row\'s status', async () => {
|
|
96
|
+
// A discarded row the resolver nevertheless called a star. That combination
|
|
97
|
+
// is REAL (idea-state.js step 2: work beats a discard, because triage can
|
|
98
|
+
// decline a duplicate whose twin already routed and paid), and a page that
|
|
99
|
+
// switched on `status` instead of `state` would draw it as a cold body.
|
|
100
|
+
const p = bootThinkingPage({ pages: [page([star({ status: 'discarded' })])] });
|
|
101
|
+
await drain();
|
|
102
|
+
const html = p.rows();
|
|
103
|
+
if (!html.includes('data-object="star"')) throw new Error('the status overrode the state');
|
|
104
|
+
if (html.includes('cold-body')) throw new Error('drew a cold body over a star');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Size and brightness are two channels — the whole reason the model has two
|
|
109
|
+
|
|
110
|
+
await test('a remnant renders LARGE and DEAD: full size, no colour, and it says so', async () => {
|
|
111
|
+
const p = bootThinkingPage({ pages: [page([remnant()])] });
|
|
112
|
+
await drain();
|
|
113
|
+
const html = p.rows();
|
|
114
|
+
if (!html.includes('180')) throw new Error('lost the true figure');
|
|
115
|
+
if (!html.includes('--w:81%')) throw new Error('the bar did not keep its size');
|
|
116
|
+
if (!html.includes('data-lit="out"')) throw new Error('the bar kept its colour');
|
|
117
|
+
if (!html.includes('none of it still alive')) throw new Error('never said the work is gone');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await test('a thought that never mattered is distinguishable from a large dead one at a glance', async () => {
|
|
121
|
+
// The failure the two-channel model exists to prevent: one number would draw
|
|
122
|
+
// these two the same. The proof task (BV2.R21) demonstrates it live; this is
|
|
123
|
+
// the pin under it.
|
|
124
|
+
const p = bootThinkingPage({ pages: [page([remnant(), idea({ id: 20, title: 'Nothing came of it' })])] });
|
|
125
|
+
await drain();
|
|
126
|
+
const html = p.rows();
|
|
127
|
+
if (!html.includes('--w:81%')) throw new Error('the dead thought lost its size');
|
|
128
|
+
// The unignited one has no bar at all, only the dash.
|
|
129
|
+
const bars = html.match(/tr-size__bar/g) || [];
|
|
130
|
+
if (bars.length !== 1) throw new Error(`expected exactly one bar, got ${bars.length}`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await test('an UNREADABLE brightness is not a dead one', async () => {
|
|
134
|
+
// attributable:false with work — the lifecycle module could not be asked.
|
|
135
|
+
// idea-state.js is emphatic that this is not `share === 0`, and a renderer
|
|
136
|
+
// that dimmed the bar or said "none of it still alive" would state, in a
|
|
137
|
+
// sentence a person reads, something nobody checked.
|
|
138
|
+
const p = bootThinkingPage({
|
|
139
|
+
pages: [page([star({ state: { state: 'star', position: 'well', wellGoalId: 7, awaitingNod: false, brightness: null, hasWork: true, attributable: false } })])],
|
|
140
|
+
});
|
|
141
|
+
await drain();
|
|
142
|
+
const html = p.rows();
|
|
143
|
+
if (!html.includes('still alive — unknown')) throw new Error('did not say the share is unknown');
|
|
144
|
+
if (html.includes('none of it still alive')) throw new Error('called an unreadable share a dead one');
|
|
145
|
+
if (html.includes('data-lit="out"')) throw new Error('greyed a bar on a share nobody could read');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('a graded brightness reaches the reader as a percentage', async () => {
|
|
149
|
+
const p = bootThinkingPage({
|
|
150
|
+
pages: [page([star({ state: { state: 'star', position: 'well', wellGoalId: 7, awaitingNod: false, brightness: 0.67, hasWork: true, attributable: true } })])],
|
|
151
|
+
});
|
|
152
|
+
await drain();
|
|
153
|
+
if (!p.rows().includes('67% still alive')) throw new Error('lost the graded share');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('`size: null` reads as unknown, never as zero', async () => {
|
|
157
|
+
// trail-size.js sets null when the reward port could not be asked at all.
|
|
158
|
+
// Drawing that as a dash would claim the thought earned nothing.
|
|
159
|
+
const p = bootThinkingPage({ pages: [page([star({ size: null })])] });
|
|
160
|
+
await drain();
|
|
161
|
+
const html = p.rows();
|
|
162
|
+
if (!html.includes('unknown')) throw new Error('did not say the ledger could not be read');
|
|
163
|
+
if (html.includes('tr-size__bar')) throw new Error('drew a bar over a size nobody could read');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await test('a zero size is a dash, and a historical one carries its marker', async () => {
|
|
167
|
+
const p = bootThinkingPage({
|
|
168
|
+
pages: [page([
|
|
169
|
+
idea({ id: 30 }),
|
|
170
|
+
star({ id: 31, size: { credits: 96, payments: 3, scale: 0.71, historical: true } }),
|
|
171
|
+
])],
|
|
172
|
+
});
|
|
173
|
+
await drain();
|
|
174
|
+
const html = p.rows();
|
|
175
|
+
if (!html.includes('—')) throw new Error('a zero size drew no dash');
|
|
176
|
+
if (!html.includes('part historical')) throw new Error('a historical size did not say so');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await test('the bar width is the server\'s compressed scale, not a second curve', async () => {
|
|
180
|
+
const p = bootThinkingPage({ pages: [page([star({ size: { credits: 9999, payments: 40, scale: 0.42, historical: false } })])] });
|
|
181
|
+
await drain();
|
|
182
|
+
if (!p.rows().includes('--w:42%')) throw new Error('the page compressed the number itself');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// What a row says it became
|
|
187
|
+
|
|
188
|
+
await test('a folded thought names the thought that absorbed it, and links to it', async () => {
|
|
189
|
+
const p = bootThinkingPage({
|
|
190
|
+
pages: [page([idea({ id: 3, status: 'merged', merged_into_idea_id: 9, merged_into_idea_title: 'The other one', state: { state: 'satellite', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: 1, hasWork: true, attributable: true } })])],
|
|
191
|
+
});
|
|
192
|
+
await drain();
|
|
193
|
+
const html = p.rows();
|
|
194
|
+
if (!html.includes('folded into')) throw new Error('did not say it was folded');
|
|
195
|
+
if (!html.includes('/idea/9')) throw new Error('did not link the absorbing thought');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await test('a thought awaiting a nod says so — the one actionable phrase on the page', async () => {
|
|
199
|
+
const p = bootThinkingPage({
|
|
200
|
+
pages: [page([idea({
|
|
201
|
+
id: 4, status: 'open',
|
|
202
|
+
lineage_tasks: [{ task_id: 77, origin: 'route', title: 'The routed task', status: 'backlog', goal_id: 12 }],
|
|
203
|
+
state: { state: 'protostar', position: 'well', wellGoalId: 12, awaitingNod: true, brightness: null, hasWork: false, attributable: true },
|
|
204
|
+
})])],
|
|
205
|
+
});
|
|
206
|
+
await drain();
|
|
207
|
+
if (!p.rows().includes('waiting for a nod')) throw new Error('a nod queue nobody can see is a list someone must remember');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await test('a thought that caused nothing says so plainly, and a declined one gives the reason', async () => {
|
|
211
|
+
const p = bootThinkingPage({
|
|
212
|
+
pages: [page([
|
|
213
|
+
idea({ id: 5 }),
|
|
214
|
+
idea({ id: 6, status: 'discarded', triage_note: 'The digest already exists.', state: { state: 'cold-body', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: false } }),
|
|
215
|
+
])],
|
|
216
|
+
});
|
|
217
|
+
await drain();
|
|
218
|
+
const html = p.rows();
|
|
219
|
+
if (!html.includes('nothing yet')) throw new Error('an unignited thought read as an absence');
|
|
220
|
+
if (!html.includes('The digest already exists.')) throw new Error('lost the triager\'s reason');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await test('the descent below the roots is counted, not folded into the root count', async () => {
|
|
224
|
+
// attachDescentToTrail deliberately EXCLUDES the roots from descent_tasks, so
|
|
225
|
+
// a page that read one as the whole would under-report every deep thought.
|
|
226
|
+
const p = bootThinkingPage({
|
|
227
|
+
pages: [page([star({
|
|
228
|
+
lineage_tasks: [
|
|
229
|
+
{ task_id: 500, origin: 'promote', title: 'One', status: 'shipped', goal_id: 7 },
|
|
230
|
+
{ task_id: 501, origin: 'develop', title: 'Two', status: 'shipped', goal_id: 7 },
|
|
231
|
+
],
|
|
232
|
+
descent_tasks: [
|
|
233
|
+
{ task_id: 600, depth: 1, relation: 'split', title: 'Three', status: 'shipped' },
|
|
234
|
+
{ task_id: 601, depth: 2, relation: 'rewrite', title: 'Four', status: 'shipped' },
|
|
235
|
+
],
|
|
236
|
+
})])],
|
|
237
|
+
});
|
|
238
|
+
await drain();
|
|
239
|
+
const html = p.rows();
|
|
240
|
+
if (!html.includes('2 tasks')) throw new Error('lost a root');
|
|
241
|
+
if (!html.includes('2 more below them')) throw new Error('lost the descent');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// The figures are a claim about a whole trail
|
|
246
|
+
|
|
247
|
+
await test('the figures count the five states, and "filed" is the server\'s total', async () => {
|
|
248
|
+
const rows = [star(), star({ id: 12 }), remnant(), idea({ id: 13 })];
|
|
249
|
+
const p = bootThinkingPage({ pages: [page(rows, { total: 4 })] });
|
|
250
|
+
await drain();
|
|
251
|
+
const figs = p.figs();
|
|
252
|
+
// 4 filed, 3 ignited (two stars + the remnant), 2 burning, 1 burned out.
|
|
253
|
+
for (const [n, label] of [['4', 'filed'], ['3', 'ignited'], ['2', 'still burning'], ['1', 'burned out']]) {
|
|
254
|
+
if (!new RegExp(`>${n}</span>\\s*<span class="tr-fig__label">${label}<`).test(figs)) {
|
|
255
|
+
throw new Error(`figure "${label}" is not ${n}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await test('the page keeps reading until the trail is exhausted, so the figures cover all of it', async () => {
|
|
261
|
+
// The defect this is under: figures computed over ONE window look exactly
|
|
262
|
+
// like figures computed over a life.
|
|
263
|
+
const p = bootThinkingPage({
|
|
264
|
+
pages: [
|
|
265
|
+
page([star(), star({ id: 12 })], { total: 3, offset: 0 }),
|
|
266
|
+
page([remnant()], { total: 3, offset: 2 }),
|
|
267
|
+
],
|
|
268
|
+
});
|
|
269
|
+
await drain(20);
|
|
270
|
+
if (p.requests.filter((u) => /by-builder/.test(u)).length !== 2) throw new Error('stopped after one window');
|
|
271
|
+
if (!/>3<\/span>\s*<span class="tr-fig__label">filed</.test(p.figs())) throw new Error('lost the total');
|
|
272
|
+
if (!/>1<\/span>\s*<span class="tr-fig__label">burned out</.test(p.figs())) throw new Error('never counted the second window');
|
|
273
|
+
if (p.foot().hidden) throw new Error('the foot vanished once loading finished');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await test('a trail longer than the cap says so rather than quietly describing its newest slice', async () => {
|
|
277
|
+
const big = (offset) => page(
|
|
278
|
+
Array.from({ length: 100 }, (_, i) => star({ id: offset * 100 + i })),
|
|
279
|
+
{ total: 900, offset: offset * 100 }
|
|
280
|
+
);
|
|
281
|
+
const p = bootThinkingPage({ pages: [big(0), big(1), big(2), big(3)] });
|
|
282
|
+
await drain(40);
|
|
283
|
+
const foot = p.foot().innerHTML;
|
|
284
|
+
if (!/newest 300/.test(foot)) throw new Error(`the cap is silent: ${foot}`);
|
|
285
|
+
if (p.requests.filter((u) => /by-builder/.test(u)).length !== 3) throw new Error('read past the cap');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// The filter strip
|
|
290
|
+
|
|
291
|
+
await test('the filter strip is built from the five states, in the shared order', async () => {
|
|
292
|
+
const p = bootThinkingPage({ pages: [page([star()])] });
|
|
293
|
+
await drain();
|
|
294
|
+
const ids = p.tabs().ids;
|
|
295
|
+
const expected = ['everything', ...p.objects().ORDER];
|
|
296
|
+
if (ids.join(',') !== expected.join(',')) throw new Error(`strip is ${ids.join(',')}`);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await test('choosing a filter shows only that object', async () => {
|
|
300
|
+
const p = bootThinkingPage({ pages: [page([star(), remnant()])] });
|
|
301
|
+
await drain();
|
|
302
|
+
p.filter('remnant');
|
|
303
|
+
const html = p.rows();
|
|
304
|
+
if (!html.includes('It burned, then it went')) throw new Error('the remnant is missing');
|
|
305
|
+
if (html.includes('It ignited')) throw new Error('the filter let a star through');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await test('a filter nobody has any of is hidden — but never "everything", and never the active one', async () => {
|
|
309
|
+
const p = bootThinkingPage({ pages: [page([star()])] });
|
|
310
|
+
await drain();
|
|
311
|
+
const t = p.tabs();
|
|
312
|
+
if (t.hidden.remnant !== true) throw new Error('an empty filter is offered');
|
|
313
|
+
if (t.hidden.star === true) throw new Error('hid a filter with rows behind it');
|
|
314
|
+
if (t.hidden.everything === true) throw new Error('hid the way back to everything');
|
|
315
|
+
// Select an empty one, then re-render: the control a reader is looking
|
|
316
|
+
// through must not disappear out from under them.
|
|
317
|
+
p.filter('remnant');
|
|
318
|
+
if (t.hidden.remnant === true) throw new Error('hid the filter the reader is using');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await test('an empty filter says which one is empty, and an empty trail invites a first thought', async () => {
|
|
322
|
+
const p = bootThinkingPage({ pages: [page([star()])] });
|
|
323
|
+
await drain();
|
|
324
|
+
p.filter('remnant');
|
|
325
|
+
if (!/Nothing of yours is burned out/.test(p.rows())) throw new Error('an empty filter said nothing useful');
|
|
326
|
+
|
|
327
|
+
const q = bootThinkingPage({ pages: [page([])] });
|
|
328
|
+
await drain();
|
|
329
|
+
if (!/File a thought/.test(q.rows())) throw new Error('an empty trail read as a failure');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// It is the AUTHOR'S own view
|
|
334
|
+
|
|
335
|
+
await test('the trail read is for the viewer\'s own id, taken from /me', async () => {
|
|
336
|
+
const p = bootThinkingPage({ pages: [page([star()])], viewer: { id: 4242 } });
|
|
337
|
+
await drain();
|
|
338
|
+
if (!p.requests.some((u) => u.includes('/inbox/by-builder/4242'))) {
|
|
339
|
+
throw new Error(`never read the viewer's own trail: ${p.requests.join(' ')}`);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await test('the table carries five columns and no id column', async () => {
|
|
344
|
+
// The agreed shape, and the reason: on the one page where every row is yours,
|
|
345
|
+
// an id is a lookup key with nothing to look up.
|
|
346
|
+
const p = bootThinkingPage({ pages: [page([star()])] });
|
|
347
|
+
await drain();
|
|
348
|
+
const html = p.rows();
|
|
349
|
+
const heads = html.match(/<th[ >]/g) || [];
|
|
350
|
+
if (heads.length !== 5) throw new Error(`expected 5 columns, got ${heads.length}`);
|
|
351
|
+
if (/ledger__num/.test(html)) throw new Error('the id column came back');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await test('a title is escaped before it reaches the page', async () => {
|
|
355
|
+
const p = bootThinkingPage({ pages: [page([star({ title: '<script>x</script>' })])] });
|
|
356
|
+
await drain();
|
|
357
|
+
if (p.rows().includes('<script>')) throw new Error('rendered raw markup from a row');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
await test('a failed trail read leaves an honest line, not a blank page', async () => {
|
|
361
|
+
const p = bootThinkingPage({ pages: [], trailStatus: 500 });
|
|
362
|
+
await drain();
|
|
363
|
+
if (!/Could not read your idea trail/.test(p.rows())) throw new Error('failed silently');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
summary();
|
package/tests/helpers.mjs
CHANGED
|
@@ -731,3 +731,127 @@ export function bootIdeaPage({ idea = null, status = 200, template = null, viewe
|
|
|
731
731
|
|
|
732
732
|
// One microtask turn — profile.js boots through a chain of awaits.
|
|
733
733
|
export const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
734
|
+
|
|
735
|
+
// bootThinkingPage — boot the REAL modules/hall-ui/public/thinking.js in a vm
|
|
736
|
+
// against a stub DOM, and hand back what it wrote into each of the page's four
|
|
737
|
+
// regions plus every request it made.
|
|
738
|
+
//
|
|
739
|
+
// The third sibling of bootProfilePage / bootIdeaPage, and here for the reason
|
|
740
|
+
// the second one is: THE ROOM (task 1004098 / BV2.R03) is asserted at the
|
|
741
|
+
// RENDERED layer. Everything this page shows already has a server-side test —
|
|
742
|
+
// the five states, size, the descent, brightness — and every one of them can
|
|
743
|
+
// still reach a reader wrong, because what a reader is handed is HTML. Testing
|
|
744
|
+
// the read alone is exactly how a renderer bug survives (the first of the three
|
|
745
|
+
// findings inherited from abandoned task 1004054).
|
|
746
|
+
//
|
|
747
|
+
// THE REAL idea-objects.js IS LOADED INTO THE SAME SANDBOX, never stubbed. It
|
|
748
|
+
// is half of the decision this page makes — the server says WHICH object, that
|
|
749
|
+
// file says what the object is CALLED — so a suite that stubbed it would assert
|
|
750
|
+
// the page renders a label the suite itself invented.
|
|
751
|
+
const THINKING_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/thinking.js'), 'utf8');
|
|
752
|
+
const IDEA_OBJECTS_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/idea-objects.js'), 'utf8');
|
|
753
|
+
export function bootThinkingPage({ pages = [], viewer = { id: 95 }, meStatus = 200, trailStatus = 200 } = {}) {
|
|
754
|
+
const els = new Map();
|
|
755
|
+
const makeEl = () => ({
|
|
756
|
+
dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
|
|
757
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
758
|
+
setAttribute() {}, getAttribute() { return null; }, addEventListener() {},
|
|
759
|
+
querySelector() { return null; }, querySelectorAll() { return []; }, appendChild() {},
|
|
760
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
761
|
+
});
|
|
762
|
+
const documentObj = {
|
|
763
|
+
title: '',
|
|
764
|
+
body: { dataset: { page: 'thinking' } },
|
|
765
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
766
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
767
|
+
querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
|
|
768
|
+
querySelectorAll() { return []; },
|
|
769
|
+
createElement() { return makeEl(); },
|
|
770
|
+
addEventListener() {},
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// The kit's tab strip, reduced to what the page actually uses: the ids it was
|
|
774
|
+
// built with, a per-id count, a per-id hidden flag and a way to activate one.
|
|
775
|
+
// `querySelector` here answers a REAL object rather than null, because the
|
|
776
|
+
// page's "hide a filter nobody has any of" rule is a behaviour the suite must
|
|
777
|
+
// be able to read — a stub answering null for every selector would make that
|
|
778
|
+
// rule untestable while looking exactly like a passing one.
|
|
779
|
+
const tabState = { ids: [], counts: {}, hidden: {}, active: null, onChange: null };
|
|
780
|
+
const tabsEl = makeEl();
|
|
781
|
+
tabsEl.querySelector = (sel) => {
|
|
782
|
+
const m = /\[data-tab="(.+)"\]/.exec(String(sel));
|
|
783
|
+
if (!m) return null;
|
|
784
|
+
const id = m[1];
|
|
785
|
+
if (!tabState.ids.includes(id)) return null;
|
|
786
|
+
return { set hidden(v) { tabState.hidden[id] = !!v; }, get hidden() { return !!tabState.hidden[id]; } };
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
const requests = [];
|
|
790
|
+
const client = {
|
|
791
|
+
request: async (method, url) => {
|
|
792
|
+
requests.push(url);
|
|
793
|
+
if (/\/me$/.test(url)) {
|
|
794
|
+
return meStatus === 200
|
|
795
|
+
? { status: 200, ok: true, data: { builder: viewer } }
|
|
796
|
+
: { status: meStatus, ok: false, data: null };
|
|
797
|
+
}
|
|
798
|
+
if (trailStatus !== 200) return { status: trailStatus, ok: false, data: null };
|
|
799
|
+
const offset = Number((/offset=(\d+)/.exec(url) || [])[1] || 0);
|
|
800
|
+
// The caller supplies whole PAGES keyed by offset, so the page's
|
|
801
|
+
// background paging loop is exercised rather than bypassed — the figures
|
|
802
|
+
// over the list are a claim about a whole trail, and a stub that always
|
|
803
|
+
// answered the first window would never let that claim be wrong.
|
|
804
|
+
const page = pages.find((p) => Number(p.offset || 0) === offset) || { ideas: [], total: 0 };
|
|
805
|
+
return { status: 200, ok: true, data: page };
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const windowObj = {
|
|
810
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
811
|
+
BongosClient: { createClient: () => client },
|
|
812
|
+
CSS: { escape: (s) => String(s) },
|
|
813
|
+
OTB: {
|
|
814
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
815
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
816
|
+
fmtDate: (iso) => String(iso ?? '').slice(0, 10),
|
|
817
|
+
toast() {},
|
|
818
|
+
},
|
|
819
|
+
OTBKit: {
|
|
820
|
+
makeTabs({ tabs = [], onChange = null }) {
|
|
821
|
+
tabState.ids = tabs.map((t) => t.id);
|
|
822
|
+
tabState.labels = tabs.map((t) => t.label);
|
|
823
|
+
tabState.onChange = onChange;
|
|
824
|
+
tabState.active = tabs.length ? tabs[0].id : null;
|
|
825
|
+
return {
|
|
826
|
+
el: tabsEl,
|
|
827
|
+
active: () => tabState.active,
|
|
828
|
+
activate(id) { tabState.active = id; if (onChange) onChange(id); },
|
|
829
|
+
setCount(id, n) { tabState.counts[id] = n; },
|
|
830
|
+
};
|
|
831
|
+
},
|
|
832
|
+
},
|
|
833
|
+
location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/thinking', hash: '', assign() {} },
|
|
834
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
835
|
+
addEventListener() {},
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
const sandbox = {
|
|
839
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
840
|
+
console, setTimeout, clearTimeout, URLSearchParams,
|
|
841
|
+
};
|
|
842
|
+
sandbox.globalThis = sandbox;
|
|
843
|
+
vm.createContext(sandbox);
|
|
844
|
+
vm.runInContext(IDEA_OBJECTS_JS, sandbox, { filename: 'idea-objects.js' });
|
|
845
|
+
vm.runInContext(THINKING_JS, sandbox, { filename: 'thinking.js' });
|
|
846
|
+
|
|
847
|
+
return {
|
|
848
|
+
requests,
|
|
849
|
+
objects: () => windowObj.OTBIdeaObjects,
|
|
850
|
+
rows: () => documentObj.getElementById('tr-body').innerHTML,
|
|
851
|
+
figs: () => documentObj.getElementById('tr-figs').innerHTML,
|
|
852
|
+
foot: () => documentObj.getElementById('tr-foot'),
|
|
853
|
+
sub: () => documentObj.getElementById('tr-sub').textContent,
|
|
854
|
+
tabs: () => tabState,
|
|
855
|
+
filter: (id) => { tabState.active = id; if (tabState.onChange) tabState.onChange(id); },
|
|
856
|
+
};
|
|
857
|
+
}
|
|
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
87
87
|
// the file and left the query alone, so a returning browser kept the cached copy —
|
|
88
88
|
// a fix that reaches nobody looks identical to a fix that does not work.
|
|
89
89
|
|
|
90
|
-
const SHELL_SHA = '
|
|
90
|
+
const SHELL_SHA = '8a93e0b4ca715b9c';
|
|
91
91
|
|
|
92
92
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
93
93
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|