@bongos/core 1.19.1007 → 1.19.1009
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 +41 -21
- package/docs/copy-inventory.md +38 -36
- package/docs/copy-registry.json +74 -56
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/idea.js +2 -2
- package/modules/hall-ui/public/ideas.css +21 -0
- package/modules/hall-ui/public/ideas.html +59 -33
- package/modules/hall-ui/public/ideas.js +128 -17
- package/modules/hall-ui/public/ideas.states.json +3 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/fixtures/ideator-prefold/README.md +36 -0
- package/tests/fixtures/ideator-prefold/ideas.html.baseline +210 -0
- package/tests/fixtures/ideator-prefold/ideas.js.baseline +962 -0
- package/tests/hall_record_world.mjs +24 -3
- package/tests/idea_spark_hall.mjs +195 -4
- package/tests/ideator_one_line_path_proof.mjs +338 -0
|
@@ -105,9 +105,30 @@ test('the shell owns BOTH halves of the rail, measured from one value', () => {
|
|
|
105
105
|
'the panel is placed from --topbar-h; ideas.css hand-copied it as 6.5rem and nothing kept the two in step');
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
test('the ideas composer OPTS IN
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
test('the ideas composer OPTS IN, and since the fold it opts in WITH THE METER', () => {
|
|
109
|
+
// The rule this case has always guarded is that a page takes the rail from the
|
|
110
|
+
// shell's own opt-in and never reaches into shell layout from its own sheet.
|
|
111
|
+
// Task 1004087 (spec R04) folded the Full Idea form away by default, so the
|
|
112
|
+
// opt-in moved from the markup to the moment the meter appears: the reserve
|
|
113
|
+
// narrows the one-line composer at every wide width, and holding it open for a
|
|
114
|
+
// panel nobody asked for is the same defect as hand-copying its offset was.
|
|
115
|
+
const ideasJs = read('ideas.js');
|
|
116
|
+
assert.match(ideasHtml, /class="fi-meter app-rail"/, 'the meter still takes the shell\'s rail frame');
|
|
117
|
+
// Matched on a class ATTRIBUTE, not anywhere in the file: the markup carries a
|
|
118
|
+
// comment naming the class to say why it is not there, and a bare substring
|
|
119
|
+
// search cannot tell an explanation from the thing it explains.
|
|
120
|
+
assert.doesNotMatch(ideasHtml, /class="[^"]*app-content--railed/,
|
|
121
|
+
'and the reserve is no longer held open in the markup');
|
|
122
|
+
assert.match(ideasJs, /classList\.add\('app-content--railed'\)/, 'ideas.js grants the reserve');
|
|
123
|
+
assert.match(ideasJs, /classList\.remove\('app-content--railed'\)/,
|
|
124
|
+
'and hands it back — a reserve only ever added is a static one written the long way');
|
|
125
|
+
// The half that would otherwise rot: the page must still not style shell
|
|
126
|
+
// layout. Read with the comments STRIPPED — the sheet's header explains at
|
|
127
|
+
// length which override it used to carry and where that override went, and a
|
|
128
|
+
// check that cannot tell a rule from the note about it fails on the note.
|
|
129
|
+
const ideasRules = ideasCss.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
130
|
+
assert.doesNotMatch(ideasRules, /(^|[\s,>])\.app-(content|rail)\b/m,
|
|
131
|
+
'ideas.css says what the meter LOOKS like, never where the shell puts it');
|
|
111
132
|
});
|
|
112
133
|
|
|
113
134
|
// ---- 2. the button vocabulary that had no rule -----------------------------
|
|
@@ -65,6 +65,13 @@ function boot({
|
|
|
65
65
|
// `{ outcome: null }`, which is the server saying "nothing to state yet"; the
|
|
66
66
|
// line must go quiet for both, and there is a case below for each.
|
|
67
67
|
landing = undefined,
|
|
68
|
+
// task 1004087 (spec R04) — the #full deep link, which auto-opens the folded
|
|
69
|
+
// Full Idea form. Part of boot() because the fold reads it once, at boot.
|
|
70
|
+
hash = '',
|
|
71
|
+
// task 1004087: a template read that FAILS. bootFullIdea returns early, so no
|
|
72
|
+
// form and no meter — distinct from "folded", and the distinction is the
|
|
73
|
+
// reason the meter follows formRendered rather than the boot flag.
|
|
74
|
+
templateOk = true,
|
|
68
75
|
// OUT-OF-ORDER READS (task 1004108). Names of fakes whose answers are PARKED
|
|
69
76
|
// instead of returned: the call is recorded and its response computed at once,
|
|
70
77
|
// but the caller's `await` does not resolve until the test releases it. That is
|
|
@@ -79,8 +86,14 @@ function boot({
|
|
|
79
86
|
const makeEl = () => ({
|
|
80
87
|
dataset: {}, style: {}, textContent: '', value: '', hidden: false, disabled: false,
|
|
81
88
|
readOnly: false, tabIndex: 0, _html: '', _handlers: {},
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
// task 1004087: attributes and classes are RECORDED rather than swallowed.
|
|
90
|
+
// The fold's two tells are an aria-expanded on the toggle and the rail class
|
|
91
|
+
// on the content wrapper, and a no-op stub agrees with every implementation
|
|
92
|
+
// including a broken one.
|
|
93
|
+
_attrs: {}, _classes: new Set(),
|
|
94
|
+
get classList() { const s = this._classes; return { add: (c) => s.add(c), remove: (c) => s.delete(c), contains: (c) => s.has(c) }; },
|
|
95
|
+
setAttribute(k, v) { this._attrs[String(k)] = String(v); },
|
|
96
|
+
getAttribute(k) { const v = this._attrs[String(k)]; return v === undefined ? null : v; },
|
|
84
97
|
addEventListener(t, fn) { this._handlers[t] = fn; },
|
|
85
98
|
querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
|
|
86
99
|
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
@@ -114,7 +127,10 @@ function boot({
|
|
|
114
127
|
|
|
115
128
|
const client = {
|
|
116
129
|
inbox: {
|
|
117
|
-
|
|
130
|
+
// RECORDED (task 1004087): the fold's cost claim is 'the template is not
|
|
131
|
+
// read until someone asks, and is read once'. A fake that does not count
|
|
132
|
+
// its calls cannot tell either half from a page that reads it every time.
|
|
133
|
+
getInboxTemplate: async () => { calls.push(['template', null]); return templateOk ? { status: 200, ok: true, data: { template: TEMPLATE } } : { status: 500, ok: false, data: null }; },
|
|
118
134
|
getInboxSparks: async () => { calls.push(['sparks', null]); return { status: 200, ok: true, data: { sparks, count: count == null ? sparks.length : count } }; },
|
|
119
135
|
getInboxId: async (a) => { calls.push(['idea', a]); return { status: ideaOk ? 200 : 404, ok: ideaOk, data: ideaOk ? { idea } : null }; },
|
|
120
136
|
postInboxScore: async (a) => { calls.push(['score', a]); return { status: 200, ok: true, data: { score: 60, fields: TEMPLATE.fields.map((f) => ({ key: f.key, substance: 24, floor: 40, ratio: 0.6 })) } }; },
|
|
@@ -186,7 +202,7 @@ function boot({
|
|
|
186
202
|
setItem: (k, v) => { store[k] = String(v); },
|
|
187
203
|
removeItem: (k) => { delete store[k]; },
|
|
188
204
|
},
|
|
189
|
-
location: { hostname: 'builders.cloudbongos.com', pathname: '/ideas', search, assign() {} },
|
|
205
|
+
location: { hostname: 'builders.cloudbongos.com', pathname: '/ideas', search, hash, assign() {} },
|
|
190
206
|
addEventListener() {},
|
|
191
207
|
};
|
|
192
208
|
|
|
@@ -200,6 +216,10 @@ function boot({
|
|
|
200
216
|
calls, toasts, store, el, deferred,
|
|
201
217
|
queueHtml: () => el('fi-queue').innerHTML,
|
|
202
218
|
fill: () => { for (const f of TEMPLATE.fields) el(`fi-f-${f.key}`).value = `an answer for ${f.key} long enough to count`; },
|
|
219
|
+
// task 1004087 (spec R04) — the one press that unfolds the Full Idea form.
|
|
220
|
+
// Every case below that reaches the five questions now goes through it,
|
|
221
|
+
// because a real builder does too.
|
|
222
|
+
openFull: async () => { el('fi-open')._handlers.click(); await settle(); await settle(); },
|
|
203
223
|
submit: () => el('fi-form')._handlers.submit({ preventDefault() {} }),
|
|
204
224
|
submitQuick: () => el('fq-form')._handlers.submit({ preventDefault() {} }),
|
|
205
225
|
};
|
|
@@ -345,6 +365,10 @@ await test('the plain composer is unaffected — develop mode is never entered',
|
|
|
345
365
|
// tests/idea_hall_composer.mjs pins over the HTML source; what matters at
|
|
346
366
|
// runtime is that nothing reveals it, and the tell is that no idea was fetched.
|
|
347
367
|
assert.ok(!r.calls.some((c) => c[0] === 'idea'), 'no idea is loaded without ?develop=');
|
|
368
|
+
// task 1004087 (spec R04): in plain mode the five questions are FOLDED, so a
|
|
369
|
+
// builder who wants them presses for them. That press is the whole cost of
|
|
370
|
+
// decision 18, and it is spent here rather than assumed away.
|
|
371
|
+
await r.openFull();
|
|
348
372
|
assert.equal(r.el('fi-title').readOnly, false, 'the title stays editable');
|
|
349
373
|
r.el('fi-title').value = 'a new idea';
|
|
350
374
|
r.fill();
|
|
@@ -789,5 +813,172 @@ await test('a slow preview answering after a faster one never repaints the stale
|
|
|
789
813
|
// page rather than pin it. That is a production bug to file, not a test to
|
|
790
814
|
// write, and the harness is now able to reach it the day it is fixed.
|
|
791
815
|
|
|
816
|
+
|
|
817
|
+
// --- the fold (task 1004087 / spec R04, decision 18) ----------------------
|
|
818
|
+
//
|
|
819
|
+
// Decision 18 put the five questions on the same surface as the one-line
|
|
820
|
+
// composer and called it the riskiest decision in the spec, because a spark's
|
|
821
|
+
// whole value is that it costs nothing to have. The cases below are what
|
|
822
|
+
// "folded" has to mean for that to stay true, and each one is written as a
|
|
823
|
+
// property of the FAST path, not of the form: a page that opened the five
|
|
824
|
+
// questions eagerly would pass a test that only asked "is the form present".
|
|
825
|
+
|
|
826
|
+
await test('the fast path costs nothing: no template read, no scorer, no rail', async () => {
|
|
827
|
+
const r = boot();
|
|
828
|
+
await settle(); await settle();
|
|
829
|
+
assert.ok(!r.calls.some((c) => c[0] === 'template'), 'the template is not read at all until someone asks for the questions');
|
|
830
|
+
assert.ok(!r.calls.some((c) => c[0] === 'score'), 'nothing is scored before anyone asks to write');
|
|
831
|
+
assert.ok(!r.el('fi-fields').innerHTML.includes(TEMPLATE.fields[0].label),
|
|
832
|
+
'and the five questions are not rendered — the template read has not happened at all');
|
|
833
|
+
assert.equal(r.el('fi-form').hidden, true, 'the five questions are folded away');
|
|
834
|
+
assert.equal(r.el('fi-meter').hidden, true, 'and so is their readout');
|
|
835
|
+
assert.equal(r.el('fi-content')._classes.has('app-content--railed'), false,
|
|
836
|
+
'the rail reserve is NOT held open — that reserve narrows the one-line form at every wide width');
|
|
837
|
+
assert.equal(r.el('fi-open').getAttribute('aria-expanded'), 'false');
|
|
838
|
+
// The quick composer is live at the same moment, so the fold costs the fast
|
|
839
|
+
// path nothing in readiness either.
|
|
840
|
+
assert.equal(typeof r.el('fq-form')._handlers.submit, 'function', 'the one-line path is already submittable');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
await test('one press unfolds it, and only then does the page fetch the template', async () => {
|
|
844
|
+
const r = boot();
|
|
845
|
+
await settle(); await settle();
|
|
846
|
+
await r.openFull();
|
|
847
|
+
assert.equal(r.el('fi-form').hidden, false);
|
|
848
|
+
assert.equal(r.el('fi-meter').hidden, false, 'the meter arrives with the form it reads');
|
|
849
|
+
assert.equal(r.el('fi-content')._classes.has('app-content--railed'), true, 'and the rail reserve arrives with the meter');
|
|
850
|
+
assert.equal(r.el('fi-open').getAttribute('aria-expanded'), 'true');
|
|
851
|
+
assert.ok(r.el('fi-fields').innerHTML.includes(TEMPLATE.fields[0].label), 'the five questions rendered from the template read');
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
await test('putting them away hides them and KEEPS the draft — it is not a discard', async () => {
|
|
855
|
+
const r = boot();
|
|
856
|
+
await settle(); await settle();
|
|
857
|
+
await r.openFull();
|
|
858
|
+
r.el('fi-title').value = 'half a thought';
|
|
859
|
+
r.fill();
|
|
860
|
+
r.el(`fi-f-${TEMPLATE.fields[0].key}`)._handlers.input();
|
|
861
|
+
await new Promise((res) => setTimeout(res, 700));
|
|
862
|
+
const saved = JSON.stringify(r.store);
|
|
863
|
+
r.el('fi-open')._handlers.click();
|
|
864
|
+
await settle();
|
|
865
|
+
assert.equal(r.el('fi-form').hidden, true, 'the form folds away again');
|
|
866
|
+
assert.equal(r.el('fi-content')._classes.has('app-content--railed'), false, 'and hands the width back');
|
|
867
|
+
assert.equal(r.el('fi-meter').hidden, true);
|
|
868
|
+
assert.equal(JSON.stringify(r.store), saved, 'nothing written by the draft was thrown away');
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
await test('the template is read ONCE however many times it is folded', async () => {
|
|
872
|
+
const r = boot();
|
|
873
|
+
await settle(); await settle();
|
|
874
|
+
await r.openFull();
|
|
875
|
+
r.el('fi-open')._handlers.click(); await settle();
|
|
876
|
+
await r.openFull();
|
|
877
|
+
// The template fake does not push to `calls`, so the tell is the render: a
|
|
878
|
+
// second boot would replace the rendered fields with the loading line first.
|
|
879
|
+
assert.equal(r.calls.filter((c) => c[0] === 'template').length, 1,
|
|
880
|
+
'the template is fetched on the FIRST open and never again — a re-boot per open would also re-wire every listener');
|
|
881
|
+
assert.ok(r.el('fi-fields').innerHTML.includes(TEMPLATE.fields[0].label), 'and the form survives a fold and an unfold');
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// --- the three things a shut form would strand ----------------------------
|
|
885
|
+
|
|
886
|
+
await test('?develop= opens it without being asked — the whole request was "develop this"', async () => {
|
|
887
|
+
const r = boot({ search: '?develop=77' });
|
|
888
|
+
await settle(); await settle();
|
|
889
|
+
assert.equal(r.el('fi-form').hidden, false, 'a develop link that landed on a folded form would be a dead link');
|
|
890
|
+
assert.equal(r.el('fi-develop').hidden, false);
|
|
891
|
+
assert.equal(r.el('fi-open').getAttribute('aria-expanded'), 'true');
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
await test('the #full deep link opens it — /idea/:id sends people here by that name', async () => {
|
|
895
|
+
const r = boot({ hash: '#full' });
|
|
896
|
+
await settle(); await settle();
|
|
897
|
+
assert.equal(r.el('fi-form').hidden, false);
|
|
898
|
+
assert.equal(r.el('fi-content')._classes.has('app-content--railed'), true);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
await test('a saved draft opens it — half-written work is never left behind a press', async () => {
|
|
902
|
+
const r = boot({ store: { 'hall-full-idea-draft': JSON.stringify({ title: 'a thought from yesterday', values: {} }) } });
|
|
903
|
+
await settle(); await settle();
|
|
904
|
+
assert.equal(r.el('fi-form').hidden, false, 'a draft nobody can see is a draft that was lost');
|
|
905
|
+
assert.equal(r.el('fi-title').value, 'a thought from yesterday', 'and it is restored, not merely revealed');
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
await test('an UNRELATED stored key does not open it — only a real draft does', async () => {
|
|
909
|
+
const r = boot({ store: { 'hall-full-idea-draft:develop:99': JSON.stringify({ title: 'someone else', values: {} }) } });
|
|
910
|
+
await settle(); await settle();
|
|
911
|
+
assert.equal(r.el('fi-form').hidden, true, 'a develop-scoped draft is not this page\'s draft');
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
// --- the markup half ------------------------------------------------------
|
|
915
|
+
//
|
|
916
|
+
// The stub DOM does not parse ideas.html, so every `hidden` above is the one
|
|
917
|
+
// ideas.js set at runtime. These two read the real file, for the same reason
|
|
918
|
+
// the receipts and gravity cases in this suite do: the runtime assertions
|
|
919
|
+
// would all still pass against a page that shipped the form open.
|
|
920
|
+
|
|
921
|
+
await test('ideas.html ships the form hidden and the fast path FIRST', async () => {
|
|
922
|
+
const html = fs.readFileSync(path.join(HALL, 'ideas.html'), 'utf8');
|
|
923
|
+
assert.match(html, /<form id="fi-form" novalidate hidden>/, 'the five questions ship folded');
|
|
924
|
+
assert.match(html, /id="fi-open"[^>]*aria-expanded="false"/, 'and the toggle ships saying so');
|
|
925
|
+
assert.ok(html.indexOf('id="fq-compose-scroll"') < html.indexOf('id="fi-compose-scroll"'),
|
|
926
|
+
'the one-line composer comes before the five questions in the DOM, not merely on screen');
|
|
927
|
+
assert.ok(html.indexOf('id="fq-compose-scroll"') < html.indexOf('id="fi-queue-scroll"'),
|
|
928
|
+
'and before the spark queue, which feeds the form rather than the fast path');
|
|
929
|
+
assert.doesNotMatch(html, /class="app-content app-content--railed"/,
|
|
930
|
+
'the rail reserve is no longer held open in the markup — ideas.js grants it with the meter');
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
await test('/idea/:id sends people to a form that will be open when they arrive', async () => {
|
|
934
|
+
const src = fs.readFileSync(path.join(HALL, 'idea.js'), 'utf8');
|
|
935
|
+
assert.match(src, /\/ideas\?develop=\$\{encodeURIComponent\(idea\.id\)\}/,
|
|
936
|
+
'the spark page develops THAT spark, rather than dropping someone on a bare composer');
|
|
937
|
+
assert.match(src, /\/ideas#full/, 'and the fallback action names the composer the fold answers to');
|
|
938
|
+
assert.doesNotMatch(src, /href="\$\{hallBase\(\)\}\/ideas"/,
|
|
939
|
+
'no link is left pointing at the page with no word about which half it meant');
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
// --- REOPENING, which is the case the first round of this task missed --------
|
|
945
|
+
//
|
|
946
|
+
// The fold's own interaction is open → close → open, and the first version of
|
|
947
|
+
// it shipped a meter that never came back: `renderForm` was the only thing that
|
|
948
|
+
// ever unhid the meter, `bootFullIdea` runs it at most once, so the second open
|
|
949
|
+
// restored the form AND the rail reserve with the readout gone for good. Every
|
|
950
|
+
// close case below passed, because none of them opened again.
|
|
951
|
+
//
|
|
952
|
+
// The lesson is the one in the harness's own header about the parked reads: a
|
|
953
|
+
// probe that reds nothing is not proof the code is right, and neither is a case
|
|
954
|
+
// that stops one step short of the interaction it is named after.
|
|
955
|
+
|
|
956
|
+
await test('reopening brings the METER back, not just the form and the rail', async () => {
|
|
957
|
+
const r = boot();
|
|
958
|
+
await settle(); await settle();
|
|
959
|
+
await r.openFull();
|
|
960
|
+
assert.equal(r.el('fi-meter').hidden, false, 'shown on the first open');
|
|
961
|
+
r.el('fi-open')._handlers.click(); await settle();
|
|
962
|
+
assert.equal(r.el('fi-meter').hidden, true, 'away with the form');
|
|
963
|
+
await r.openFull();
|
|
964
|
+
assert.equal(r.el('fi-form').hidden, false, 'the form comes back');
|
|
965
|
+
assert.equal(r.el('fi-content')._classes.has('app-content--railed'), true, 'and so does the rail reserve');
|
|
966
|
+
assert.equal(r.el('fi-meter').hidden, false,
|
|
967
|
+
'and so does the meter — a rail held open for a panel that never returns is the anti-pattern the fold removes');
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
await test('a template that never loaded shows NO meter, however often it is folded', async () => {
|
|
971
|
+
const r = boot({ templateOk: false });
|
|
972
|
+
await settle(); await settle();
|
|
973
|
+
await r.openFull();
|
|
974
|
+
assert.equal(r.el('fi-meter').hidden, true, 'there is no form, so there is nothing to read out');
|
|
975
|
+
r.el('fi-open')._handlers.click(); await settle();
|
|
976
|
+
await r.openFull();
|
|
977
|
+
assert.equal(r.el('fi-meter').hidden, true,
|
|
978
|
+
'and reopening must not reveal an empty gauge — a readout showing nothing is a score of nothing');
|
|
979
|
+
assert.equal(r.el('fi-content')._classes.has('app-content--railed'), true,
|
|
980
|
+
'the rail still follows the OPEN state, so this is a real distinction and not both flags moving together');
|
|
981
|
+
});
|
|
982
|
+
|
|
792
983
|
console.log(`\nidea_spark_hall: ${passed} passed, ${failed} failed`);
|
|
793
984
|
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// tests/ideator_one_line_path_proof.mjs — THE PROOF for spec decision 18
|
|
2
|
+
// (task 1004101 / BV2.R22 of goal 1000095, spec R06a).
|
|
3
|
+
//
|
|
4
|
+
// THE DECISION THIS GUARDS. Spec decision 18 folded the Full Idea form onto the
|
|
5
|
+
// same surface as the one-line composer and called itself "the decision with the
|
|
6
|
+
// most risk in it", because a spark's whole value is that it costs nothing to
|
|
7
|
+
// have. If the fast path picked up an extra step, a scroll or a decision, this
|
|
8
|
+
// scope would have built something beautiful that people stop using — and they
|
|
9
|
+
// would go back to firing one-liners into Discord.
|
|
10
|
+
//
|
|
11
|
+
// WHY THIS IS NOT MORE UNIT TESTS. tests/idea_spark_hall.mjs already asserts
|
|
12
|
+
// that the folded page behaves correctly. What it cannot see is whether the
|
|
13
|
+
// folded page is WORSE THAN THE ONE IT REPLACED, because it only ever boots the
|
|
14
|
+
// current file. A regression in the fast path is not a broken assertion; it is
|
|
15
|
+
// the same page, still working, costing more than it used to. The only way to
|
|
16
|
+
// see that is to measure both.
|
|
17
|
+
//
|
|
18
|
+
// SO THE BASELINE IS THE REAL PRE-FOLD PAGE, not a description of it.
|
|
19
|
+
// tests/fixtures/ideator-prefold/ holds byte copies of
|
|
20
|
+
// modules/hall-ui/public/ideas.{html,js} at commit f375a1f9 — the commit
|
|
21
|
+
// immediately before the fold landed (task 1004087) — carried as .baseline files
|
|
22
|
+
// so the duplication ratchet does not read a frozen copy as copy/paste to collapse
|
|
23
|
+
// (see that folder's README). Both pages are booted through the SAME stub, instrumented the same way, in the same process. A
|
|
24
|
+
// hand-written "before" number would agree with the "after" because the author
|
|
25
|
+
// made it agree; these two can only agree if the code does.
|
|
26
|
+
//
|
|
27
|
+
// WHAT THIS FILE CAN AND CANNOT MEASURE — read this before trusting it.
|
|
28
|
+
// It measures the things that are DETERMINISTIC: how many gestures filing
|
|
29
|
+
// takes, how much the page does before the fast path is usable, where the fast
|
|
30
|
+
// path sits in the document, and what the fast path depends on. It does NOT
|
|
31
|
+
// measure wall-clock, because a wall-clock assertion in CI is a flake and a
|
|
32
|
+
// median over a stub DOM is not a number about the product. The real elapsed
|
|
33
|
+
// time was measured in a browser and is recorded, with its method and its
|
|
34
|
+
// error bars, in docs/governance/bongos-v2-ideator-one-line-path-proof.md.
|
|
35
|
+
// A reader who wants the seconds should go there; what is enforced on every
|
|
36
|
+
// push is below.
|
|
37
|
+
//
|
|
38
|
+
// Pure vm + file read. No DB, no network, no browser.
|
|
39
|
+
//
|
|
40
|
+
// Run: node tests/ideator_one_line_path_proof.mjs
|
|
41
|
+
|
|
42
|
+
import { strict as assert } from 'node:assert';
|
|
43
|
+
import { createRequire } from 'node:module';
|
|
44
|
+
import fs from 'node:fs';
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import vm from 'node:vm';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
|
|
49
|
+
const require = createRequire(import.meta.url);
|
|
50
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
51
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
52
|
+
const PREFOLD = path.join(ROOT, 'tests/fixtures/ideator-prefold');
|
|
53
|
+
|
|
54
|
+
// The commit the baseline was taken at, so a reader can re-cut it and diff.
|
|
55
|
+
const BASELINE_SHA = 'f375a1f9';
|
|
56
|
+
|
|
57
|
+
const PAGES = {
|
|
58
|
+
before: {
|
|
59
|
+
label: `the page as it was (${BASELINE_SHA}, before the fold)`,
|
|
60
|
+
js: fs.readFileSync(path.join(PREFOLD, 'ideas.js.baseline'), 'utf8'),
|
|
61
|
+
html: fs.readFileSync(path.join(PREFOLD, 'ideas.html.baseline'), 'utf8'),
|
|
62
|
+
},
|
|
63
|
+
after: {
|
|
64
|
+
label: 'the page as it is now (folded)',
|
|
65
|
+
js: fs.readFileSync(path.join(HALL, 'ideas.js'), 'utf8'),
|
|
66
|
+
html: fs.readFileSync(path.join(HALL, 'ideas.html'), 'utf8'),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
71
|
+
const TEMPLATE = {
|
|
72
|
+
key: BASE_TEMPLATE.key,
|
|
73
|
+
label: BASE_TEMPLATE.label,
|
|
74
|
+
fields: BASE_TEMPLATE.fields.map((f) => ({
|
|
75
|
+
key: f.key, label: f.label, required: !!f.required, substance_min: f.substanceMin || 0,
|
|
76
|
+
})),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const SPARKS = [
|
|
80
|
+
{ id: '77', title: 'Let a builder mute one channel', status: 'open', grade: 'spark', captured_at: '2026-08-01T00:00:00Z', filed_by_login: 'thinker' },
|
|
81
|
+
{ id: '78', title: 'A shared changelog', status: 'open', grade: 'spark', captured_at: '2026-07-30T00:00:00Z', filed_by_login: null },
|
|
82
|
+
];
|
|
83
|
+
const VERSIONS = [{ id: 'BV1', status: 'building' }];
|
|
84
|
+
const GOALS = [{ id: '77', title: 'Goal Seventy Seven', version_id: 'BV1' }];
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// the instrumented boot — identical for both pages
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
//
|
|
90
|
+
// THE GESTURE COUNTER IS THE LOAD-BEARING PART. A "gesture" here is one
|
|
91
|
+
// discrete thing a person does with the keyboard or the pointer: focusing a
|
|
92
|
+
// control, changing its value, or pressing something. Typing a title is ONE
|
|
93
|
+
// gesture (a person types a sentence, they do not perform N keystroke
|
|
94
|
+
// decisions), which is the reading that makes the comparison fair — counting
|
|
95
|
+
// keystrokes would let a shorter placeholder look like a faster path.
|
|
96
|
+
//
|
|
97
|
+
// It is counted by INSTRUMENTING THE STUB rather than by reading the source:
|
|
98
|
+
// every value written to a control and every handler fired is recorded, so the
|
|
99
|
+
// count is what the driver below actually had to do to reach a filed spark,
|
|
100
|
+
// on whichever page it was driven against.
|
|
101
|
+
|
|
102
|
+
function boot(which, { templateOk = true } = {}) {
|
|
103
|
+
const page = PAGES[which];
|
|
104
|
+
const els = new Map();
|
|
105
|
+
const gestures = [];
|
|
106
|
+
const calls = [];
|
|
107
|
+
|
|
108
|
+
const makeEl = (id) => ({
|
|
109
|
+
id,
|
|
110
|
+
dataset: {}, style: {}, textContent: '', hidden: false, disabled: false,
|
|
111
|
+
readOnly: false, tabIndex: 0, _html: '', _handlers: {},
|
|
112
|
+
_attrs: {}, _classes: new Set(),
|
|
113
|
+
_value: '',
|
|
114
|
+
get value() { return this._value; },
|
|
115
|
+
set value(v) { this._value = String(v == null ? '' : v); },
|
|
116
|
+
get classList() { const s = this._classes; return { add: (c) => s.add(c), remove: (c) => s.delete(c), contains: (c) => s.has(c) }; },
|
|
117
|
+
setAttribute(k, v) { this._attrs[String(k)] = String(v); },
|
|
118
|
+
getAttribute(k) { const v = this._attrs[String(k)]; return v === undefined ? null : v; },
|
|
119
|
+
addEventListener(t, fn) { this._handlers[t] = fn; },
|
|
120
|
+
querySelector() { return makeEl('anon'); }, querySelectorAll() { return []; }, appendChild() {},
|
|
121
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const documentObj = {
|
|
125
|
+
title: '', body: { dataset: { page: 'ideas' } },
|
|
126
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
127
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); },
|
|
128
|
+
querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
|
|
129
|
+
querySelectorAll() { return []; },
|
|
130
|
+
createElement() { return makeEl('created'); },
|
|
131
|
+
addEventListener() {},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const client = {
|
|
135
|
+
inbox: {
|
|
136
|
+
getInboxTemplate: async () => { calls.push('template'); return templateOk ? { status: 200, ok: true, data: { template: TEMPLATE } } : { status: 500, ok: false, data: null }; },
|
|
137
|
+
getInboxSparks: async () => { calls.push('sparks'); return { status: 200, ok: true, data: { sparks: SPARKS, count: SPARKS.length } }; },
|
|
138
|
+
getInboxId: async () => { calls.push('idea'); return { status: 404, ok: false, data: null }; },
|
|
139
|
+
postInboxScore: async () => { calls.push('score'); return { status: 200, ok: true, data: { score: 60, fields: TEMPLATE.fields.map((f) => ({ key: f.key, substance: 24, floor: 40, ratio: 0.6 })) } }; },
|
|
140
|
+
postInbox: async (a) => { calls.push('capture'); return { status: 201, ok: true, data: { idea: { id: '910', routing: { routed: false, reason: 'no_goal' } } } }; },
|
|
141
|
+
postInboxIdDevelop: async () => { calls.push('develop'); return { status: 200, ok: true, data: { idea: {} } }; },
|
|
142
|
+
postInboxPreviewLanding: async () => { calls.push('preview'); return { status: 200, ok: true, data: { outcome: 'floats', reason: 'no_goal', status: null, goal: null, version_id: null, refusal: null } }; },
|
|
143
|
+
},
|
|
144
|
+
versions: { getVersions: async () => { calls.push('versions'); return { status: 200, ok: true, data: { versions: VERSIONS } }; } },
|
|
145
|
+
goals: {
|
|
146
|
+
getGoals: async () => { calls.push('goals'); return { status: 200, ok: true, data: { goals: GOALS } }; },
|
|
147
|
+
getGoalsSuggestions: async () => { calls.push('suggestions'); return { status: 200, ok: true, data: { suggestions: [] } }; },
|
|
148
|
+
},
|
|
149
|
+
me: { getMe: async () => { calls.push('me'); return { status: 200, ok: true, data: { task_kinds: ['bug', 'feature'] } }; } },
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const windowObj = {
|
|
153
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
154
|
+
BongosClient: { createClient: () => client },
|
|
155
|
+
OTB: {
|
|
156
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
157
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
158
|
+
toast: () => {},
|
|
159
|
+
fillKindFilter: () => {},
|
|
160
|
+
kindLabel: (k) => k,
|
|
161
|
+
},
|
|
162
|
+
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
|
163
|
+
location: { hostname: 'builders.cloudbongos.com', pathname: '/ideas', search: '', hash: '', assign() {} },
|
|
164
|
+
addEventListener() {},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date };
|
|
168
|
+
sandbox.globalThis = sandbox;
|
|
169
|
+
vm.createContext(sandbox);
|
|
170
|
+
vm.runInContext(page.js, sandbox, { filename: `ideas.js[${which}]` });
|
|
171
|
+
|
|
172
|
+
const el = (id) => documentObj.getElementById(id);
|
|
173
|
+
return {
|
|
174
|
+
which, label: page.label, html: page.html, calls, gestures, el,
|
|
175
|
+
// Every driver action below goes through these, so the gesture count is a
|
|
176
|
+
// record of what was actually done rather than a number someone typed.
|
|
177
|
+
type(id, text) { gestures.push(`type:${id}`); el(id).value = text; const h = el(id)._handlers.input; if (h) h(); },
|
|
178
|
+
choose(id, v) { gestures.push(`choose:${id}`); el(id).value = v; const h = el(id)._handlers.change; if (h) h(); },
|
|
179
|
+
press(id) { gestures.push(`press:${id}`); const h = el(id)._handlers.click; if (h) return h(); return undefined; },
|
|
180
|
+
async fileIt() {
|
|
181
|
+
gestures.push('press:fq-submit');
|
|
182
|
+
return el('fq-form')._handlers.submit({ preventDefault() {} });
|
|
183
|
+
},
|
|
184
|
+
submittable() { return typeof el('fq-form')._handlers.submit === 'function'; },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
189
|
+
const boots = async (which, opts) => { const r = boot(which, opts); await settle(); await settle(); await settle(); return r; };
|
|
190
|
+
|
|
191
|
+
let passed = 0;
|
|
192
|
+
let failed = 0;
|
|
193
|
+
const findings = [];
|
|
194
|
+
async function claim(name, fn) {
|
|
195
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
196
|
+
catch (err) { failed++; findings.push(`${name}: ${err.message}`); console.error(` FAIL ${name}\n ${err.message}`); }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log('\nTHE ONE-LINE PATH, BEFORE AND AFTER THE FOLD');
|
|
200
|
+
console.log(` before = ${PAGES.before.label}`);
|
|
201
|
+
console.log(` after = ${PAGES.after.label}\n`);
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// CLAIM 1 — the same gestures
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
await claim('filing one line takes the SAME gestures on both pages, and they are the same gestures', async () => {
|
|
208
|
+
const drive = async (which) => {
|
|
209
|
+
const r = await boots(which);
|
|
210
|
+
r.type('fq-title', 'the hall should remember what I filed');
|
|
211
|
+
await r.fileIt();
|
|
212
|
+
await settle();
|
|
213
|
+
return r;
|
|
214
|
+
};
|
|
215
|
+
const before = await drive('before');
|
|
216
|
+
const after = await drive('after');
|
|
217
|
+
|
|
218
|
+
assert.deepEqual(after.gestures, before.gestures,
|
|
219
|
+
`the gesture sequence changed:\n before ${JSON.stringify(before.gestures)}\n after ${JSON.stringify(after.gestures)}`);
|
|
220
|
+
// Stated absolutely as well as relatively: two pages that both got WORSE in
|
|
221
|
+
// the same way would pass the comparison above and nothing else here.
|
|
222
|
+
assert.deepEqual(after.gestures, ['type:fq-title', 'press:fq-submit'],
|
|
223
|
+
'the fast path is a title and a press — anything else is a step this decision promised not to add');
|
|
224
|
+
assert.ok(before.calls.includes('capture') && after.calls.includes('capture'),
|
|
225
|
+
'and both actually filed — a path that files nothing is not a fast one');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await claim('neither page makes the filer touch the kind or the goal to file', async () => {
|
|
229
|
+
for (const which of ['before', 'after']) {
|
|
230
|
+
const r = await boots(which);
|
|
231
|
+
r.type('fq-title', 'one line');
|
|
232
|
+
await r.fileIt();
|
|
233
|
+
await settle();
|
|
234
|
+
const capture = r.calls.filter((c) => c === 'capture');
|
|
235
|
+
assert.equal(capture.length, 1, `${which}: filing with nothing but a title reaches the capture route`);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// CLAIM 2 — the fast path is usable no later than it was
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
await claim('the form is submittable before ANY read answers — on both pages', async () => {
|
|
244
|
+
// Deliberately NOT awaited: this is the state of the page in the instant
|
|
245
|
+
// after its script runs, before a single promise has settled.
|
|
246
|
+
for (const which of ['before', 'after']) {
|
|
247
|
+
const r = boot(which);
|
|
248
|
+
assert.equal(r.submittable(), true,
|
|
249
|
+
`${which}: the submit handler is attached synchronously, so a fast typist is never racing the network`);
|
|
250
|
+
}
|
|
251
|
+
// Honest reading: this was ALREADY true before the fold. It is recorded as a
|
|
252
|
+
// property preserved, not as an improvement won — see the governance record.
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await claim('the folded page does LESS on arrival: no template read, no scoring, no meter', async () => {
|
|
256
|
+
const before = await boots('before');
|
|
257
|
+
const after = await boots('after');
|
|
258
|
+
|
|
259
|
+
assert.ok(before.calls.includes('template'),
|
|
260
|
+
'the old page fetched the five questions on arrival whether or not anyone wanted them');
|
|
261
|
+
assert.ok(!after.calls.includes('template'),
|
|
262
|
+
'the folded page does not');
|
|
263
|
+
assert.ok(!after.calls.includes('score'), 'and scores nothing');
|
|
264
|
+
assert.ok(after.calls.length < before.calls.length,
|
|
265
|
+
`the folded page issues fewer reads on arrival (before ${before.calls.length}: ${before.calls.join(',')} · after ${after.calls.length}: ${after.calls.join(',')})`);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// CLAIM 3 — the fast path is not behind anything
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
await claim('the one-line composer moved ABOVE the two things that used to precede it', async () => {
|
|
273
|
+
const pos = (html, id) => html.indexOf(`id="${id}"`);
|
|
274
|
+
const b = PAGES.before.html;
|
|
275
|
+
const a = PAGES.after.html;
|
|
276
|
+
|
|
277
|
+
assert.ok(pos(b, 'fi-queue-scroll') < pos(b, 'fq-compose-scroll'),
|
|
278
|
+
'before: the spark queue came first — the baseline says so itself, so this is measured, not remembered');
|
|
279
|
+
assert.ok(pos(a, 'fq-compose-scroll') < pos(a, 'fi-queue-scroll'),
|
|
280
|
+
'after: the one-line composer is first');
|
|
281
|
+
assert.ok(pos(a, 'fq-compose-scroll') < pos(a, 'fi-compose-scroll'),
|
|
282
|
+
'and still ahead of the five questions');
|
|
283
|
+
// The rail is the other half of "how much room the fast path gets".
|
|
284
|
+
assert.match(b, /class="app-content app-content--railed"/,
|
|
285
|
+
'before: the column reserved the meter\'s room at every wide width, occupied or not');
|
|
286
|
+
assert.doesNotMatch(a, /class="[^"]*app-content--railed/,
|
|
287
|
+
'after: it does not, so the one-line form is drawn at the page\'s full width');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// CLAIM 4 — the fast path does not depend on the slow one
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
await claim('a totally broken Full Idea half does not slow, block or shout over the fast path', async () => {
|
|
295
|
+
const before = await boots('before', { templateOk: false });
|
|
296
|
+
const after = await boots('after', { templateOk: false });
|
|
297
|
+
|
|
298
|
+
for (const [which, r] of [['before', before], ['after', after]]) {
|
|
299
|
+
r.type('fq-title', 'filing while the other half is down');
|
|
300
|
+
await r.fileIt();
|
|
301
|
+
await settle();
|
|
302
|
+
assert.ok(r.calls.includes('capture'), `${which}: the filing still goes through`);
|
|
303
|
+
}
|
|
304
|
+
// The difference is what the PAGE says about it. The old page wrote the
|
|
305
|
+
// template's failure into the page-head subtitle — a failure notice sitting
|
|
306
|
+
// over a form that was working perfectly well.
|
|
307
|
+
assert.match(before.el('fi-sub').textContent, /Could not load the idea template/,
|
|
308
|
+
'before: the page announced a broken half the filer had not asked for');
|
|
309
|
+
assert.doesNotMatch(after.el('fi-sub').textContent, /Could not load/,
|
|
310
|
+
'after: a folded half that failed to load says nothing, because nobody opened it');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// CLAIM 5 — the guard on this proof
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
await claim('the baseline is the REAL previous page, not a paraphrase of it', async () => {
|
|
318
|
+
// If someone ever "tidies" the fixture into agreement with the current page,
|
|
319
|
+
// every comparison above becomes vacuous and still passes. These are the
|
|
320
|
+
// properties that made the before the before.
|
|
321
|
+
const b = PAGES.before.js;
|
|
322
|
+
assert.ok(b.includes('const r = await api.inbox.getInboxTemplate({});'),
|
|
323
|
+
'the baseline still fetches the template unconditionally in main()');
|
|
324
|
+
assert.ok(!b.includes('setDisclosure'), 'and knows nothing about a disclosure');
|
|
325
|
+
assert.ok(!PAGES.before.html.includes('fi-open'), 'and has no toggle in its markup');
|
|
326
|
+
assert.notEqual(PAGES.before.js, PAGES.after.js, 'the two pages are not the same file');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
console.log('');
|
|
332
|
+
console.log(`ideator_one_line_path_proof: ${passed} passed, ${failed} failed`);
|
|
333
|
+
if (failed > 0) {
|
|
334
|
+
console.error('\nTHE FAST PATH GOT MORE EXPENSIVE. Spec decision 18 is the decision this scope');
|
|
335
|
+
console.error('flagged as riskiest, and these are its guard rails:');
|
|
336
|
+
for (const f of findings) console.error(` - ${f}`);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|