@bongos/core 1.19.1012 → 1.19.1013
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 +51 -41
- package/docs/adr/0331-the-sky-is-the-ideators-home.md +6 -1
- package/docs/copy-inventory.md +99 -56
- package/docs/copy-registry.json +830 -465
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/idea-objects.js +4 -1
- package/modules/hall-ui/public/sky-composer.js +155 -0
- package/modules/hall-ui/public/sky-data.js +287 -0
- package/modules/hall-ui/public/sky-draw.js +244 -0
- package/modules/hall-ui/public/sky-interaction.js +137 -0
- package/modules/hall-ui/public/sky-layout.js +81 -0
- package/modules/hall-ui/public/sky-panel.js +141 -0
- package/modules/hall-ui/public/sky-tour.js +80 -0
- package/modules/hall-ui/public/thinking.css +203 -267
- package/modules/hall-ui/public/thinking.html +119 -58
- package/modules/hall-ui/public/thinking.states.json +33 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/me.js +4 -1
- package/src/bongos/serve-internal.js +20 -6
- package/src/module-api.js +1 -1
- package/tests/hall_audit.mjs +27 -0
- package/tests/hall_page_gate_map.mjs +11 -5
- package/tests/hall_sky.mjs +393 -405
- package/tests/hall_thinking_room.mjs +274 -296
- package/tests/helpers.mjs +185 -217
- package/tests/ideator_large_dead_thought_proof.mjs +33 -25
- package/tests/ideator_traceable_proof.mjs +61 -39
- package/modules/hall-ui/public/sky.css +0 -365
- package/modules/hall-ui/public/sky.html +0 -79
- package/modules/hall-ui/public/sky.js +0 -580
- package/modules/hall-ui/public/sky.states.json +0 -20
- package/modules/hall-ui/public/thinking.js +0 -418
|
@@ -1,366 +1,344 @@
|
|
|
1
|
-
// tests/hall_thinking_room.mjs — THE ROOM,
|
|
2
|
-
// (task
|
|
1
|
+
// tests/hall_thinking_room.mjs — THE ROOM inside the sky, and the composer
|
|
2
|
+
// (task 1004231 / sky part 3 of 5, goal 1000095; ADR 0331; criterion
|
|
3
|
+
// `ideator-nothing-is-lost`).
|
|
3
4
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// The room (task 1004098) was a page; it is now the sky's list drawer, one key
|
|
6
|
+
// away, and this suite is what its old suite was for: the last step, where a
|
|
7
|
+
// correct server answer becomes a sentence a person reads. It boots the REAL
|
|
8
|
+
// page through bootSkyPage (tests/helpers.mjs) and reads the drawer it
|
|
9
|
+
// writes; the drawing half is tests/hall_sky.mjs.
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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.
|
|
11
|
+
// THE COMPOSER is the other half here, because the task's binding line is
|
|
12
|
+
// that filing from the sky writes the SAME record the hall form writes: quick
|
|
13
|
+
// is POST /inbox with the line, full is POST /inbox with the template's fields
|
|
14
|
+
// — so a case asserts the request body byte for byte — and gravity is the real
|
|
15
|
+
// landing verdict (POST /inbox/preview-landing over the suggester's pick),
|
|
16
|
+
// never a keyword match, so a case makes the two disagree and checks which
|
|
17
|
+
// one the page believed.
|
|
20
18
|
|
|
21
|
-
import
|
|
22
|
-
import {
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { makeRunner, settle, bootSkyPage } from './helpers.mjs';
|
|
23
21
|
|
|
24
22
|
const { test, summary } = makeRunner();
|
|
23
|
+
// Values born inside the vm carry that realm's prototypes, so a strict deepEqual
|
|
24
|
+
// reports "same structure but not reference-equal"; compare them as plain data.
|
|
25
|
+
const same = (actual, expected, msg) => assert.deepEqual(JSON.parse(JSON.stringify(actual)), expected, msg);
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// chain any case below sets up.
|
|
29
|
-
const drain = async (n = 10) => { for (let i = 0; i < n; i += 1) await settle(); };
|
|
27
|
+
const drain = async (n = 30) => { for (let i = 0; i < n; i += 1) await settle(); };
|
|
28
|
+
const boot = async (opts = {}) => { const p = bootSkyPage(opts); await drain(); return p; };
|
|
30
29
|
|
|
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
30
|
function idea(over = {}) {
|
|
35
31
|
return {
|
|
36
|
-
id: 1,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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: [],
|
|
32
|
+
id: 1, title: 'A thought', kind: 'feature', status: 'open', captured_at: '2026-09-01T00:00:00.000Z',
|
|
33
|
+
triage_note: null, promoted_to_task_id: null, promoted_task_title: null, promoted_task_status: null,
|
|
34
|
+
merged_into_idea_id: null, merged_into_idea_title: null, grade: 'quick', field_values: null,
|
|
35
|
+
lineage_tasks: [], descent_tasks: [],
|
|
49
36
|
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
|
-
},
|
|
37
|
+
state: { state: 'protostar', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: true },
|
|
54
38
|
...over,
|
|
55
39
|
};
|
|
56
40
|
}
|
|
57
|
-
const page = (ideas, over = {}) => ({ ideas, total: ideas.length, offset: 0, limit: 100, ...over });
|
|
58
|
-
|
|
59
41
|
const star = (over = {}) => idea({
|
|
60
|
-
id: 10, title: 'It ignited', status: 'promoted',
|
|
42
|
+
id: 10, title: 'It ignited', status: 'promoted', captured_at: '2026-09-05T00:00:00.000Z',
|
|
61
43
|
lineage_tasks: [{ task_id: 500, origin: 'promote', title: 'The task', status: 'shipped', goal_id: 7 }],
|
|
44
|
+
descent_tasks: [{ task_id: 501, depth: 1, relation: 'split', title: 'A follow-up', status: 'active', goal_id: 7 }],
|
|
62
45
|
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 },
|
|
46
|
+
state: { state: 'star', position: 'well', wellGoalId: '7', awaitingNod: false, brightness: 1, hasWork: true, attributable: true },
|
|
64
47
|
...over,
|
|
65
48
|
});
|
|
66
|
-
const remnant = (over = {}) => star({
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
49
|
+
const remnant = (over = {}) => star({ id: 11, title: 'It burned, then it went', descent_tasks: [], size: { credits: 180, payments: 5, scale: 0.81, historical: true }, state: { state: 'remnant', position: 'well', wellGoalId: '7', awaitingNod: false, brightness: 0, hasWork: true, attributable: true }, ...over });
|
|
50
|
+
const satellite = (over = {}) => idea({ id: 13, title: 'Folded', status: 'merged', merged_into_idea_id: 10, merged_into_idea_title: 'It ignited', state: { state: 'satellite', position: 'well', wellGoalId: '7', awaitingNod: false, brightness: 1, hasWork: true, attributable: true }, ...over });
|
|
51
|
+
const coldBody = (over = {}) => idea({ id: 12, title: 'It never ignited', status: 'discarded', state: { state: 'cold-body', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: true }, ...over });
|
|
52
|
+
const waiting = (over = {}) => idea({
|
|
53
|
+
id: 14, title: 'Caught, waiting for a nod', status: 'promoted',
|
|
54
|
+
lineage_tasks: [{ task_id: 9001, origin: 'route', title: 'Caught, waiting for a nod', status: 'backlog', goal_id: 7 }],
|
|
55
|
+
state: { state: 'protostar', position: 'well', wellGoalId: '7', awaitingNod: true, brightness: 1, hasWork: false, attributable: true },
|
|
70
56
|
...over,
|
|
71
57
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const p =
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
58
|
+
const FIVE = () => [star(), idea(), satellite(), remnant(), coldBody()];
|
|
59
|
+
const mine = (ideas, over = {}) => ({ builder_id: 95, ideas, total: ideas.length, cap: 1000, truncated: false, ...over });
|
|
60
|
+
const well = (over = {}) => ({ goal_id: '7', title: 'The well — a goal', subtitle: null, status: 'open', version_id: 'V2', category: null, task_total: 1, task_done: 1, created_at: '2026-06-01T00:00:00.000Z', criteria: [], tasks: [{ id: '500', title: 'The task', status: 'shipped', credits_reward: 60, created_on: '2026-07-01', idea_id: '10' }], ...over });
|
|
61
|
+
const sky = (goals) => ({ goals, edges: [], counts: {}, cache: {} });
|
|
62
|
+
const versions = () => ({ versions: [{ id: 'V2', status: 'building', shipped_at: null }] });
|
|
63
|
+
const findIdea = (p, id) => p.S().ideas.find((i) => i.id === String(id));
|
|
64
|
+
const bodyOf = (p, method, path) => (p.requests.find((r) => r.method === method && r.url === `/api/bongos${path}`) || {}).body;
|
|
65
|
+
const filed = (over = {}) => ({ status: 201, ok: true, data: { idea: { id: 900, title: 'a thought about the sky', kind: 'feature', status: 'open', captured_at: '2026-09-25T10:00:00.000Z', grade: 'quick', ...over } } });
|
|
66
|
+
|
|
67
|
+
// ═══ the room ════════════════════════════════════════════════════════════════
|
|
68
|
+
|
|
69
|
+
await test('the room lists everything, counts it, and its filters are the five in the shared order with their counts', async () => {
|
|
70
|
+
const p = await boot({ mine: mine(FIVE()), sky: sky([well()]), versions: versions() });
|
|
71
|
+
p.key('l');
|
|
72
|
+
assert.equal(p.el('roomTitle').textContent, 'the room');
|
|
73
|
+
assert.equal(p.el('roomCount').textContent, '5 of 5');
|
|
74
|
+
const f = p.filters();
|
|
75
|
+
assert.match(f, /data-f="all"[^>]*>everything <span[^>]*>5</);
|
|
76
|
+
for (const [k, n] of [['star', 1], ['protostar', 1], ['satellite', 1], ['remnant', 1], ['cold-body', 1]]) assert.match(f, new RegExp(`data-f="${k}"[^>]*>[^<]+<span[^>]*>${n}<`));
|
|
77
|
+
assert.equal((p.rows().match(/class="row"/g) || []).length, 5);
|
|
93
78
|
});
|
|
94
79
|
|
|
95
|
-
await test('
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (html.includes('cold-body')) throw new Error('drew a cold body over a star');
|
|
80
|
+
await test('choosing a filter shows only that object, and the drawer says which', async () => {
|
|
81
|
+
const p = await boot({ mine: mine(FIVE()) });
|
|
82
|
+
p.key('l');
|
|
83
|
+
const btn = p.el('filters').querySelectorAll('button').find((b) => b.dataset.f === 'remnant');
|
|
84
|
+
btn.fire('click', { type: 'click' });
|
|
85
|
+
assert.equal((p.rows().match(/class="row"/g) || []).length, 1);
|
|
86
|
+
assert.match(p.rows(), /data-id="11"/);
|
|
87
|
+
assert.equal(p.el('roomCount').textContent, '1 of 5');
|
|
88
|
+
assert.equal(p.S().filter, 'remnant');
|
|
105
89
|
});
|
|
106
90
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
91
|
+
await test('a row says what became of it, in words a reader can act on', async () => {
|
|
92
|
+
const p = await boot({ mine: mine([...FIVE(), waiting()]), sky: sky([well()]) });
|
|
93
|
+
p.key('l');
|
|
94
|
+
const rows = p.rows();
|
|
95
|
+
const rowOf = (id) => (new RegExp(`data-id="${id}"[\\s\\S]*?</button>`).exec(rows) || [''])[0];
|
|
96
|
+
assert.match(rowOf(10), /2 tasks, 1 shipped/, 'a star counts its roots and its descent, and how many shipped');
|
|
97
|
+
assert.match(rowOf(10), /size 240/);
|
|
98
|
+
assert.match(rowOf(1), /nothing yet/);
|
|
99
|
+
assert.match(rowOf(13), /folded into another thought/);
|
|
100
|
+
assert.match(rowOf(12), /declined at triage/);
|
|
101
|
+
assert.match(rowOf(14), /1 task/);
|
|
102
|
+
assert.match(rowOf(14), /· in The well/);
|
|
103
|
+
assert.match(rowOf(1), /· open space/);
|
|
118
104
|
});
|
|
119
105
|
|
|
120
|
-
await test('a thought
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
106
|
+
await test('the panel spells a thought out: gravity, what it became, size, brightness, position, the descent, the record', async () => {
|
|
107
|
+
const p = await boot({ mine: mine([star(), waiting()]), sky: sky([well()]) });
|
|
108
|
+
p.SKY().pick({ kind: 'idea', ref: findIdea(p, 10) }, false);
|
|
109
|
+
const panel = p.panel();
|
|
110
|
+
assert.match(panel, /<h2>It ignited<\/h2>/);
|
|
111
|
+
assert.match(panel, /gravity<\/span><span class="v"><a href="#" data-goal="7">The well — a goal<\/a>/);
|
|
112
|
+
assert.match(panel, /became<\/span><span class="v">2 tasks, 1 shipped/);
|
|
113
|
+
assert.match(panel, /size — credits<\/span><span class="v num">240/);
|
|
114
|
+
assert.match(panel, /brightness — still alive[\s\S]*all of it still alive/);
|
|
115
|
+
assert.match(panel, /the descent — everything that fell out of it/);
|
|
116
|
+
assert.match(panel, /<span class="st">shipped<\/span><span>The task/);
|
|
117
|
+
assert.match(panel, /<span class="st live">active<\/span><span>A follow-up/);
|
|
118
|
+
assert.match(panel, /href="\/idea\/10"[^>]*>the record</, 'the record is one door away');
|
|
119
|
+
p.SKY().pick({ kind: 'idea', ref: findIdea(p, 14) }, false);
|
|
120
|
+
assert.match(p.panel(), /position<\/span><span class="v">waiting for a nod/);
|
|
131
121
|
});
|
|
132
122
|
|
|
133
|
-
await test('
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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');
|
|
123
|
+
await test('a title is escaped before it reaches the room', async () => {
|
|
124
|
+
const p = await boot({ mine: mine([idea({ title: '<script>alert(1)</script>' })]) });
|
|
125
|
+
p.key('l');
|
|
126
|
+
assert.ok(p.rows().includes('<script>alert(1)</script>'));
|
|
127
|
+
assert.ok(!p.rows().includes('<script>'));
|
|
146
128
|
});
|
|
147
129
|
|
|
148
|
-
await test('
|
|
149
|
-
const p =
|
|
150
|
-
|
|
151
|
-
});
|
|
152
|
-
await drain();
|
|
153
|
-
if (!p.rows().includes('67% still alive')) throw new Error('lost the graded share');
|
|
130
|
+
await test('the figures count the five states over the whole trail', async () => {
|
|
131
|
+
const p = await boot({ mine: mine([...FIVE(), star({ id: 15, title: 'Another star' })]) });
|
|
132
|
+
assert.match(p.stats(), /<b>6<\/b> thoughts · <b>2<\/b> burning · <b>1<\/b> waiting · <b>1<\/b> folded in · <b>1<\/b> burned out · <b>1<\/b> declined/);
|
|
154
133
|
});
|
|
155
134
|
|
|
156
|
-
await test('
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (html.includes('tr-size__bar')) throw new Error('drew a bar over a size nobody could read');
|
|
135
|
+
await test('an empty trail renders the room empty, and the composer is there to fix it', async () => {
|
|
136
|
+
const p = await boot({ mine: mine([]) });
|
|
137
|
+
p.key('l');
|
|
138
|
+
assert.match(p.rows(), /Nothing of that kind in your sky\./);
|
|
139
|
+
assert.equal(p.el('roomCount').textContent, '0 of 0');
|
|
140
|
+
assert.equal(p.el('composerWrap').hidden, false);
|
|
141
|
+
assert.equal(p.el('thought').placeholder, 'release a thought — one line');
|
|
164
142
|
});
|
|
165
143
|
|
|
166
|
-
|
|
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
|
-
});
|
|
144
|
+
// ═══ the composer — quick ═══════════════════════════════════════════════════
|
|
178
145
|
|
|
179
|
-
await test('the
|
|
180
|
-
const p =
|
|
146
|
+
await test('QUICK: Enter files the line through POST /inbox exactly as the hall form does', async () => {
|
|
147
|
+
const p = await boot({ mine: mine(FIVE()), versions: versions() });
|
|
148
|
+
p.handlers.push((m, u) => (m === 'POST' && u.endsWith('/inbox') ? filed() : null));
|
|
149
|
+
p.type('a thought about the sky');
|
|
150
|
+
assert.ok(p.el('file').classList.contains('live'), 'the file button lights as you type');
|
|
151
|
+
p.submit();
|
|
181
152
|
await drain();
|
|
182
|
-
|
|
153
|
+
same(bodyOf(p, 'POST', '/inbox'), { title: 'a thought about the sky', body_md: '', kind: 'feature' }, 'the hall form\'s quick body — title, body_md, kind — and nothing else');
|
|
154
|
+
assert.equal(p.el('thought').value, '', 'the line is released');
|
|
155
|
+
assert.match(p.receipt(), /^filed · nothing caught it · floating in open space$/);
|
|
156
|
+
const i = findIdea(p, 900);
|
|
157
|
+
assert.ok(i, 'the filed thought is in the sky');
|
|
158
|
+
assert.equal(i.st, 'protostar');
|
|
159
|
+
assert.equal(i.well, null);
|
|
160
|
+
assert.ok(p.S().flying && p.S().flying.idea === i, 'and it flies to where it landed');
|
|
161
|
+
p.key('l');
|
|
162
|
+
assert.match(p.rows(), /data-id="900"[\s\S]*?a thought about the sky/, 'and it is a row in the drawer at once');
|
|
183
163
|
});
|
|
184
164
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
});
|
|
165
|
+
await test('an empty line files nothing', async () => {
|
|
166
|
+
const p = await boot({ mine: mine([]) });
|
|
167
|
+
p.type(' ');
|
|
168
|
+
p.submit();
|
|
192
169
|
await drain();
|
|
193
|
-
|
|
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');
|
|
170
|
+
assert.equal(bodyOf(p, 'POST', '/inbox'), undefined);
|
|
196
171
|
});
|
|
197
172
|
|
|
198
|
-
await test('a
|
|
199
|
-
const p =
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
state: { state: 'protostar', position: 'well', wellGoalId: 12, awaitingNod: true, brightness: null, hasWork: false, attributable: true },
|
|
204
|
-
})])],
|
|
205
|
-
});
|
|
173
|
+
await test('a filing that fails keeps the line and says so — nothing is lost', async () => {
|
|
174
|
+
const p = await boot({ mine: mine([]) });
|
|
175
|
+
p.handlers.push((m, u) => (m === 'POST' && u.endsWith('/inbox') ? { status: 400, ok: false, data: { error: { code: 'validation_failed' } } } : null));
|
|
176
|
+
p.type('a thought about the sky');
|
|
177
|
+
p.submit();
|
|
206
178
|
await drain();
|
|
207
|
-
|
|
179
|
+
assert.equal(p.el('thought').value, 'a thought about the sky', 'the line comes back');
|
|
180
|
+
assert.match(p.receipt(), /not filed — validation failed/);
|
|
181
|
+
assert.equal(p.S().ideas.length, 0, 'nothing was invented');
|
|
208
182
|
});
|
|
209
183
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
184
|
+
// ═══ gravity — the real verdict ══════════════════════════════════════════════
|
|
185
|
+
|
|
186
|
+
const gravityWorld = (verdict, suggestions = [{ id: 7, title: 'The well — a goal', score: 3, matched_terms: ['well'] }]) => (m, u, body) => {
|
|
187
|
+
if (m === 'GET' && u.includes('/goals/suggestions')) return { status: 200, ok: true, data: { suggestions } };
|
|
188
|
+
if (m === 'POST' && u.endsWith('/inbox/preview-landing')) return { status: 200, ok: true, data: typeof verdict === 'function' ? verdict(body) : verdict };
|
|
189
|
+
return null;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
await test('GRAVITY: the well that would catch it lights from the real verdict, and the filing names that goal', async () => {
|
|
193
|
+
const p = await boot({ mine: mine([]), sky: sky([well()]), versions: versions() });
|
|
194
|
+
p.handlers.push(gravityWorld({ outcome: 'routed', reason: null, status: 'backlog', goal: { id: 7, title: 'The well — a goal' }, version_id: 'V2', refusal: null }));
|
|
195
|
+
p.handlers.push((m, u) => (m === 'POST' && u.endsWith('/inbox') ? filed({ routing: { routed: true, goal_id: 7 } }) : null));
|
|
196
|
+
p.handlers[p.handlers.length - 1] = ((h) => (m, u) => { const r = h(m, u); if (r) r.data.routed_task = { id: 9002, status: 'backlog', goal_id: 7, title: 'a thought about the sky' }; return r; })(p.handlers[p.handlers.length - 1]);
|
|
197
|
+
p.type('a thought about the well');
|
|
198
|
+
assert.equal(p.timers.pending(), 1, 'a keystroke schedules one debounced read, not a request');
|
|
199
|
+
await p.timers.flush();
|
|
217
200
|
await drain();
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
});
|
|
201
|
+
const sug = p.requests.find((r) => r.url.includes('/goals/suggestions'));
|
|
202
|
+
assert.ok(sug && sug.url.includes('version=V2') && sug.url.includes('title=a%20thought%20about%20the%20well'), 'the suggester is asked per building version, with the line');
|
|
203
|
+
same(bodyOf(p, 'POST', '/inbox/preview-landing'), { kind: 'feature', goal_id: 7 }, 'the preview is asked about the suggested goal');
|
|
204
|
+
assert.match(p.gravity(), /gravity · <b class="hot">The well — a goal<\/b> would catch it — a task in its well, waiting for a nod/);
|
|
205
|
+
assert.equal(p.S().gravityGoal, p.S().goalById.get('7'), 'the well lights in the sky');
|
|
206
|
+
p.submit();
|
|
238
207
|
await drain();
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
208
|
+
same(bodyOf(p, 'POST', '/inbox'), { title: 'a thought about the well', body_md: '', kind: 'feature', goal_id: 7 }, 'the goal the verdict named rides the filing');
|
|
209
|
+
assert.match(p.receipt(), /caught by <b>The well<\/b> · a task in its well, waiting for a nod/);
|
|
210
|
+
const i = findIdea(p, 900);
|
|
211
|
+
assert.equal(i.well, '7');
|
|
212
|
+
assert.equal(i.awaiting, true);
|
|
213
|
+
same(i.lineage.map((t) => [t.id, t.status, t.origin]), [['9002', 'backlog', 'route']]);
|
|
214
|
+
assert.equal(p.S().gravityGoal, null, 'and the line is clear again');
|
|
242
215
|
});
|
|
243
216
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const p = bootThinkingPage({ pages: [page(rows, { total: 4 })] });
|
|
217
|
+
await test('gravity READS the verdict, it does not compute one — the suggester\'s pick means nothing until the matrix says routed', async () => {
|
|
218
|
+
const p = await boot({ mine: mine([]), sky: sky([well()]), versions: versions() });
|
|
219
|
+
p.handlers.push(gravityWorld({ outcome: 'floats', reason: 'judgment_kind', status: null, goal: { id: 7, title: 'The well — a goal' }, version_id: null, refusal: null }));
|
|
220
|
+
p.type('the well the well the well');
|
|
221
|
+
await p.timers.flush();
|
|
250
222
|
await drain();
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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()])] });
|
|
223
|
+
assert.equal(p.S().gravityGoal, null, 'every word matches the goal and the page still lights nothing');
|
|
224
|
+
assert.match(p.gravity(), /nothing catches it — it will float in open space until something does/);
|
|
225
|
+
p.handlers.push((m, u) => (m === 'POST' && u.endsWith('/inbox') ? filed() : null));
|
|
226
|
+
p.submit();
|
|
293
227
|
await drain();
|
|
294
|
-
|
|
295
|
-
const expected = ['everything', ...p.objects().ORDER];
|
|
296
|
-
if (ids.join(',') !== expected.join(',')) throw new Error(`strip is ${ids.join(',')}`);
|
|
228
|
+
assert.equal(bodyOf(p, 'POST', '/inbox').goal_id, undefined, 'and no goal is sent that the verdict did not name');
|
|
297
229
|
});
|
|
298
230
|
|
|
299
|
-
await test('
|
|
300
|
-
const p =
|
|
231
|
+
await test('a refusal is the server\'s own sentence, and a stale answer never paints over a newer one', async () => {
|
|
232
|
+
const p = await boot({ mine: mine([]), versions: versions() });
|
|
233
|
+
p.handlers.push(gravityWorld({ outcome: 'refused', reason: 'protected_scope', status: null, goal: { id: 7, title: 'The well' }, version_id: null, refusal: { reason: 'The well is protected scope; a Metic must file into it.' } }));
|
|
234
|
+
p.type('first draft');
|
|
235
|
+
p.type('second draft');
|
|
236
|
+
assert.equal(p.timers.pending(), 1, 'the debounce is one timer, the newer keystroke replacing the older');
|
|
237
|
+
await p.timers.flush();
|
|
301
238
|
await drain();
|
|
302
|
-
p.filter('
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
239
|
+
assert.equal(p.requests.filter((r) => r.url.endsWith('/inbox/preview-landing')).length, 1, 'one preview for the draft as it stands');
|
|
240
|
+
assert.equal(p.gravity(), 'The well is protected scope; a Metic must file into it.');
|
|
241
|
+
p.type('');
|
|
242
|
+
assert.match(p.gravity(), /^gravity — the goal that catches it lights as you type; letting it float is a real answer$/, 'an empty line rests the sentence');
|
|
306
243
|
});
|
|
307
244
|
|
|
308
|
-
|
|
309
|
-
|
|
245
|
+
// ═══ the composer — full ═════════════════════════════════════════════════════
|
|
246
|
+
|
|
247
|
+
await test('FULL: the five questions are the template\'s, one at a time, with skip and cancel, then one POST /inbox with the fields', async () => {
|
|
248
|
+
const p = await boot({ mine: mine([]), versions: versions() });
|
|
249
|
+
p.handlers.push((m, u) => (m === 'POST' && u.endsWith('/inbox') ? filed({ grade: 'full', completeness_score: 40 }) : null));
|
|
250
|
+
assert.equal(p.el('fullToggle').hidden, false);
|
|
251
|
+
p.click('fullToggle');
|
|
252
|
+
assert.equal(p.el('fullToggle').getAttribute('aria-pressed'), 'true');
|
|
253
|
+
assert.equal(p.el('thought').placeholder, 'release a thought — one line, then five questions');
|
|
254
|
+
p.type('a full thought');
|
|
255
|
+
p.submit();
|
|
256
|
+
assert.match(p.steps(), /<b>full idea · 1 of 5<\/b><span>What made you have the idea<\/span><span class="hint">~25 characters<\/span>/);
|
|
257
|
+
assert.equal(p.el('thought').placeholder, 'What made you have the idea');
|
|
258
|
+
assert.equal(p.el('fileGo').textContent, 'next');
|
|
259
|
+
p.type('because the room was a list and a list is not a sky');
|
|
260
|
+
p.submit();
|
|
261
|
+
assert.match(p.steps(), /2 of 5<\/b><span>Scope it as best as you can here/);
|
|
262
|
+
p.click('stepSkip');
|
|
263
|
+
assert.match(p.steps(), /3 of 5<\/b><span>What should an engineer or artist know prior to building/);
|
|
264
|
+
// cancel: the line comes back as a quick idea, nothing filed
|
|
265
|
+
p.click('stepCancel');
|
|
266
|
+
assert.equal(p.el('fullToggle').getAttribute('aria-pressed'), 'false');
|
|
267
|
+
assert.equal(p.el('thought').value, 'a full thought');
|
|
268
|
+
assert.equal(p.el('fullSteps').hidden, true);
|
|
269
|
+
assert.equal(bodyOf(p, 'POST', '/inbox'), undefined, 'cancel files nothing');
|
|
270
|
+
// and again, all the way through
|
|
271
|
+
p.click('fullToggle');
|
|
272
|
+
p.type('a full thought');
|
|
273
|
+
p.submit();
|
|
274
|
+
const answers = ['because the room was a list and a list is not a sky', '', 'read the template first', 'it is the ideator\'s home', 'the owner reads it daily'];
|
|
275
|
+
for (let n = 0; n < 5; n++) {
|
|
276
|
+
assert.match(p.steps(), new RegExp(`${n + 1} of 5`));
|
|
277
|
+
if (n === 4) assert.equal(p.el('fileGo').textContent, 'file the full idea');
|
|
278
|
+
p.type(answers[n]); p.submit();
|
|
279
|
+
}
|
|
310
280
|
await drain();
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
p.
|
|
318
|
-
if (t.hidden.remnant === true) throw new Error('hid the filter the reader is using');
|
|
281
|
+
same(bodyOf(p, 'POST', '/inbox'), {
|
|
282
|
+
title: 'a full thought', grade: 'full', template_key: 'base-v2',
|
|
283
|
+
field_values: { why_had: answers[0], scope: '', build_context: answers[2], fit: answers[3], success: answers[4] },
|
|
284
|
+
}, 'the hall form\'s full body — grade, template_key and every field by its key');
|
|
285
|
+
assert.match(p.receipt(), /filed as a full idea · <b>1 of 5<\/b> answers above the floor · nothing caught it · floating in open space/);
|
|
286
|
+
assert.equal(findIdea(p, 900).grade, 'full');
|
|
287
|
+
assert.equal(p.el('fullSteps').hidden, true, 'the steps close');
|
|
319
288
|
});
|
|
320
289
|
|
|
321
|
-
await test('
|
|
322
|
-
const p =
|
|
323
|
-
|
|
324
|
-
p.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
290
|
+
await test('Escape inside the questions cancels them; the questions never live in the page', async () => {
|
|
291
|
+
const p = await boot({ mine: mine([]) });
|
|
292
|
+
p.click('fullToggle');
|
|
293
|
+
p.type('a full thought');
|
|
294
|
+
p.submit();
|
|
295
|
+
assert.equal(p.el('fullSteps').hidden, false);
|
|
296
|
+
p.el('thought').fire('keydown', { key: 'Escape', preventDefault() {} });
|
|
297
|
+
assert.equal(p.el('fullSteps').hidden, true);
|
|
298
|
+
assert.equal(p.el('thought').value, 'a full thought');
|
|
299
|
+
const fs = await import('node:fs');
|
|
300
|
+
const src = fs.readFileSync('modules/hall-ui/public/sky-composer.js', 'utf8');
|
|
301
|
+
for (const key of ['why_had', 'build_context']) assert.ok(!new RegExp(`['"]${key}['"]`).test(src), `the composer carries no copy of the template field ${key}`);
|
|
330
302
|
});
|
|
331
303
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
}
|
|
304
|
+
await test('without a template there is no full idea to file, so the switch is not offered', async () => {
|
|
305
|
+
const p = await boot({ mine: mine([]), template: null });
|
|
306
|
+
assert.equal(p.el('fullToggle').hidden, true);
|
|
307
|
+
same(p.SKY().fields(), []);
|
|
341
308
|
});
|
|
342
309
|
|
|
343
|
-
|
|
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
|
-
});
|
|
310
|
+
// ═══ the nod ═════════════════════════════════════════════════════════════════
|
|
353
311
|
|
|
354
|
-
await test('
|
|
355
|
-
const
|
|
312
|
+
await test('THE NOD is drawn only for a holder of idea.triage, and runs the existing promote path', async () => {
|
|
313
|
+
const held = await boot({ mine: mine([waiting()]), sky: sky([well()]), viewer: { id: 95, permissions: { 'idea.triage': true } } });
|
|
314
|
+
assert.equal(held.S().canNod, true);
|
|
315
|
+
held.SKY().pick({ kind: 'idea', ref: findIdea(held, 14) }, false);
|
|
316
|
+
assert.match(held.panel(), /id="nodBtn"[^>]*>nod — make it claimable</);
|
|
317
|
+
held.handlers.push((m, u) => (m === 'POST' && u.endsWith('/tasks/9001/promote') ? { status: 200, ok: true, data: { ok: true } } : null));
|
|
318
|
+
held.click('nodBtn');
|
|
356
319
|
await drain();
|
|
357
|
-
|
|
320
|
+
assert.ok(held.requests.some((r) => r.method === 'POST' && r.url === '/api/bongos/tasks/9001/promote'), 'the nod is the routed task\'s promote');
|
|
321
|
+
assert.equal(held.el('nodBtn').textContent, 'nodded — a builder can claim it now');
|
|
322
|
+
assert.equal(findIdea(held, 14).awaiting, false);
|
|
323
|
+
assert.equal(findIdea(held, 14).state.awaitingNod, false);
|
|
324
|
+
|
|
325
|
+
const not = await boot({ mine: mine([waiting()]), sky: sky([well()]), viewer: { id: 95, permissions: { 'board.vote.cast': true } } });
|
|
326
|
+
assert.equal(not.S().canNod, false);
|
|
327
|
+
not.SKY().pick({ kind: 'idea', ref: findIdea(not, 14) }, false);
|
|
328
|
+
assert.doesNotMatch(not.panel(), /nodBtn/, 'no button for a builder who may not triage');
|
|
329
|
+
assert.match(not.panel(), /waiting for a nod/, 'the fact is still spoken');
|
|
358
330
|
});
|
|
359
331
|
|
|
360
|
-
await test('a
|
|
361
|
-
const p =
|
|
332
|
+
await test('a nod the server refuses says why and stays pressable', async () => {
|
|
333
|
+
const p = await boot({ mine: mine([waiting()]), sky: sky([well()]), viewer: { id: 95, permissions: { 'idea.triage': true } } });
|
|
334
|
+
p.SKY().pick({ kind: 'idea', ref: findIdea(p, 14) }, false);
|
|
335
|
+
p.handlers.push((m, u) => (m === 'POST' && u.endsWith('/promote') ? { status: 409, ok: false, data: { error: { code: 'deps_not_shipped' } } } : null));
|
|
336
|
+
p.click('nodBtn');
|
|
362
337
|
await drain();
|
|
363
|
-
|
|
338
|
+
assert.equal(p.el('nodBtn').textContent, 'could not nod — deps not shipped');
|
|
339
|
+
assert.equal(p.el('nodBtn').disabled, false);
|
|
340
|
+
assert.equal(findIdea(p, 14).state.awaitingNod, true);
|
|
364
341
|
});
|
|
365
342
|
|
|
343
|
+
console.log('');
|
|
366
344
|
summary();
|