Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_turns.mjs

13.7 KiB, 1 run

created by r2519314175:765, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_turns.mjs — a turn as a thing you can close, choose, fold and walk back to.
2//
3// The thread was a flat list of messages: an answer had no attachment to the question that
4// caused it, so nothing could be collapsed behind it and nothing could be folded but the whole
5// chat. A turn now exists — the nth question and everything that came back from it — and this
6// drives what that makes possible.
7//
8// The check that matters most is the last one. It is easy to build a tick-box that LOOKS like it
9// chose a turn and then quietly fold the whole chat anyway; the UI would be indistinguishable.
10// So the delta is not inspected in the page that produced it — it is read back out of the Diamond
11// history, which is the reducer's own record of what it was handed. If the tick did not narrow
12// the fold, the marker from an unticked turn shows up there and the test fails.
13//
14// The answers are deliberately long. The jump exists for a thread taller than the window, and a
15// short thread cannot scroll its last question to the top however correct the code is — there is
16// nothing beneath it to scroll. Testing the jump against a thread that cannot scroll would prove
17// nothing either way.
18import { open, chat, shot, errors } from './harness.mjs';
19
20const ok = [], bad = [];
21const check = (name, pass, detail) => {
22 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
23 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
24};
25
26const FILL = 'the quick brown fox jumps over the lazy dog '.repeat(60); // a tall answer
27const MARKS = ['MARK-ALPHA', 'MARK-BETA', 'MARK-GAMMA', 'MARK-DELTA'];
28
29const s = await open({ name: 'turns' });
30const p = s.page;
31
32// The Diamond first, so folding into it later needs no navigation away from the chat.
33await p.click('#new-diamond-btn');
34await p.waitForSelector('.dlg-input', { timeout: 8000 });
35await p.fill('.dlg-input', 'Turns Test');
36await p.click('.dlg-ok');
37await p.waitForTimeout(1200);
38
39// Four turns. The marker rides in the user's own message, which is what the delta carries, and
40// the mock echoes it back — so a turn is identifiable from either end.
41for (const m of MARKS) await chat(s, `@text ${m} ${FILL}`);
42
43// ── Turns exist, and outputs belong to one ─────────────────────────────
44
45const shape = await p.evaluate(() => {
46 const out = document.getElementById('chat-output');
47 const users = [...out.querySelectorAll('.chat-msg-user')];
48 const asst = [...out.querySelectorAll('.chat-msg-assistant')];
49 return {
50 turns: users.map(u => u.dataset.turn),
51 asstTagged: asst.length > 0 && asst.every(a => a.dataset.turn !== undefined),
52 asstTurns: asst.map(a => a.dataset.turn),
53 };
54});
55check('every question is a numbered turn',
56 shape.turns.join(',') === '1,2,3,4', shape.turns.join(','));
57check('and every answer carries the turn it belongs to',
58 shape.asstTagged && shape.asstTurns.join(',') === '1,2,3,4', shape.asstTurns.join(','));
59
60// ── A question is the switch for its own answers ───────────────────────
61
62const toggle = await p.evaluate(() => {
63 const out = document.getElementById('chat-output');
64 const u = out.querySelector('.chat-msg-user[data-turn="2"]');
65 const kids = () => [...out.querySelectorAll('[data-turn="2"]')].filter(e => e !== u);
66 const visible = () => kids().filter(e => e.style.display !== 'none').length;
67 const before = visible();
68 u.querySelector('.chat-msg-content').click();
69 const closed = visible();
70 const marked = u.classList.contains('collapsed');
71 u.querySelector('.chat-msg-content').click();
72 return { before, closed, reopened: visible(), marked };
73});
74check('clicking a question hides everything it produced',
75 toggle.before > 0 && toggle.closed === 0, `${toggle.before} → ${toggle.closed}`);
76check('and the closed question says so, rather than standing beside a silent gap',
77 toggle.marked === true);
78check('clicking it again brings the answer back',
79 toggle.reopened === toggle.before, `${toggle.closed} → ${toggle.reopened}`);
80
81// Selecting the text of a question must not fold it away — the box is a switch, but its words
82// are still words. A click that ENDS a selection is a click that was selecting.
83const selecting = await p.evaluate(() => {
84 const out = document.getElementById('chat-output');
85 const u = out.querySelector('.chat-msg-user[data-turn="3"]');
86 const c = u.querySelector('.chat-msg-content');
87 const r = document.createRange();
88 r.selectNodeContents(c);
89 const sel = window.getSelection();
90 sel.removeAllRanges(); sel.addRange(r);
91 c.click();
92 const collapsed = u.classList.contains('collapsed');
93 sel.removeAllRanges();
94 return collapsed;
95});
96check('selecting the text of a question does not fold it away', selecting === false);
97
98// ── The − collapses all, and latches into select mode ──────────────────
99
100const collapsed = await p.evaluate(() => {
101 const out = document.getElementById('chat-output');
102 document.getElementById('collapse-btn').click();
103 const users = [...out.querySelectorAll('.chat-msg-user')];
104 const asst = [...out.querySelectorAll('.chat-msg-assistant')];
105 const tick = out.querySelector('.turn-pick');
106 return {
107 allClosed: users.every(u => u.classList.contains('collapsed')),
108 asstHidden: asst.every(a => a.style.display === 'none'),
109 questionsStillThere: users.every(u => u.offsetParent !== null),
110 latched: document.getElementById('collapse-btn').classList.contains('on'),
111 toolsUp: document.getElementById('select-tools').style.display !== 'none',
112 ticksUp: !!tick && getComputedStyle(tick).display !== 'none',
113 };
114});
115check('the − collapses every answer', collapsed.allClosed && collapsed.asstHidden);
116check('leaving the questions, which are what you are choosing between',
117 collapsed.questionsStillThere);
118check('the button stays depressed, so the mode is visible', collapsed.latched);
119check('and select mode brings out its tools and its ticks',
120 collapsed.toolsUp && collapsed.ticksUp);
121
122const peek = await p.evaluate(() => {
123 const out = document.getElementById('chat-output');
124 const u = out.querySelector('.chat-msg-user[data-turn="1"]');
125 u.querySelector('.chat-msg-content').click();
126 const open = !u.classList.contains('collapsed');
127 u.querySelector('.chat-msg-content').click();
128 return open;
129});
130check('and a question can still be opened while choosing', peek === true);
131
132// ── Select all / Deselect all ──────────────────────────────────────────
133
134const picks = await p.evaluate(() => {
135 const out = document.getElementById('chat-output');
136 const boxes = () => [...out.querySelectorAll('.turn-pick input')];
137 document.getElementById('sel-all').click();
138 const all = boxes().filter(b => b.checked).length;
139 document.getElementById('sel-none').click();
140 const none = boxes().filter(b => b.checked).length;
141 return { all, none, total: boxes().length };
142});
143check('Select all ticks every turn', picks.all === picks.total && picks.total === 4, `${picks.all}/${picks.total}`);
144check('Deselect all clears them', picks.none === 0);
145
146// Folding nothing is refused, rather than silently paying a reducer to fold an empty delta.
147const refused = await p.evaluate(async () => {
148 document.getElementById('sel-fold').click();
149 await new Promise(r => setTimeout(r, 500));
150 const h = document.querySelector('.dlg-card h2');
151 const txt = h ? h.textContent : '';
152 const okb = document.querySelector('.dlg-ok');
153 if (okb) okb.click();
154 await new Promise(r => setTimeout(r, 400));
155 return txt;
156});
157check('folding nothing is refused, not silently paid for',
158 /nothing chosen/i.test(refused), refused || '(no dialog)');
159
160await shot(s, 'turns-select');
161
162// ── The jump walks back through the questions ──────────────────────────
163
164const jump = await p.evaluate(async () => {
165 const out = document.getElementById('chat-output');
166 document.getElementById('collapse-btn').click(); // leave select mode, reopen everything
167 await new Promise(r => setTimeout(r, 300));
168 out.scrollTop = out.scrollHeight;
169 const topOf = n => {
170 const u = out.querySelector(`.chat-msg-user[data-turn="${n}"]`);
171 return Math.round(u.getBoundingClientRect().top - out.getBoundingClientRect().top);
172 };
173 const scrollable = out.scrollHeight > out.clientHeight + 100;
174 document.getElementById('chat-jump').click();
175 await new Promise(r => setTimeout(r, 200));
176 const first = topOf(4);
177 document.getElementById('chat-jump').click();
178 await new Promise(r => setTimeout(r, 200));
179 const second = topOf(3);
180 document.getElementById('chat-jump').click();
181 await new Promise(r => setTimeout(r, 200));
182 const third = topOf(2);
183 return { first, second, third, scrollable };
184});
185check('the thread is taller than its window, so a jump has somewhere to go', jump.scrollable);
186// "At the top" means within a few pixels of the thread's own top edge.
187check('the jump puts the last question at the top',
188 Math.abs(jump.first) <= 4, `${jump.first}px from the top`);
189check('pressing again walks back to the one before it',
190 Math.abs(jump.second) <= 4, `${jump.second}px from the top`);
191check('and again, so a long thread is walked by its questions',
192 Math.abs(jump.third) <= 4, `${jump.third}px from the top`);
193
194// ── The ↓ comes back, and resets the walk ──────────────────────────────
195//
196// Having walked up to turn 2, ↓ returns to the bottom. The reset is the half that
197// is easy to leave out and impossible to see: without it the next ↑ carries on
198// from where the walk had got to (turn 1), rather than from the last question --
199// so the button would mean something different depending on history the user has
200// no way of knowing.
201const end = await p.evaluate(async () => {
202 const out = document.getElementById('chat-output');
203 const btn = document.getElementById('chat-end');
204 if (!btn) return { missing: true };
205 const topOf = n => {
206 const u = out.querySelector(`.chat-msg-user[data-turn="${n}"]`);
207 return Math.round(u.getBoundingClientRect().top - out.getBoundingClientRect().top);
208 };
209 btn.click();
210 await new Promise(r => setTimeout(r, 250));
211 const fromBottom = Math.round(out.scrollHeight - out.scrollTop - out.clientHeight);
212 document.getElementById('chat-jump').click();
213 await new Promise(r => setTimeout(r, 250));
214 return { fromBottom, restarted: topOf(4) };
215});
216check('the ↓ lands at the end of the chat',
217 !end.missing && Math.abs(end.fromBottom) <= 4,
218 end.missing ? '(no ↓ button in the bar)' : `${end.fromBottom}px from the bottom`);
219check('and the walk starts again from the last question, not from where it had got to',
220 !end.missing && Math.abs(end.restarted) <= 4,
221 end.missing ? '(no ↓ button in the bar)' : `turn 4 is ${end.restarted}px from the top`);
222
223// ── Fold selected really folds only what was selected ──────────────────
224//
225// The oracle is not the page: it is the Diamond history, which records the delta the reducer was
226// handed. Turn 2 (MARK-BETA) is ticked; the other three are not.
227
228const menuHead = await p.evaluate(async () => {
229 const out = document.getElementById('chat-output');
230 document.getElementById('collapse-btn').click(); // into select mode
231 await new Promise(r => setTimeout(r, 300));
232 const box = out.querySelector('.chat-msg-user[data-turn="2"] .turn-pick input');
233 box.checked = true;
234 box.dispatchEvent(new Event('click', { bubbles: true }));
235 document.getElementById('sel-fold').click();
236 await new Promise(r => setTimeout(r, 500));
237 const h = document.querySelector('.fold-menu-head');
238 return h ? h.textContent : '(no fold menu)';
239});
240check('the fold menu says how much is going in, not just "Fold into…"',
241 /1 turn/.test(menuHead), menuHead);
242
243await p.evaluate(() => {
244 const item = [...document.querySelectorAll('.fold-menu-item')]
245 .find(b => b.textContent.trim() === 'Turns Test');
246 if (item) item.click();
247});
248await p.waitForTimeout(7000);
249const accept = await p.$('.diff-accept');
250if (accept && !(await accept.isDisabled())) { await accept.click(); await p.waitForTimeout(3000); }
251
252// Read the delta back out of the history — the reducer's own record of what it got.
253await p.click('button.crystal-act:has-text("History")');
254await p.waitForTimeout(900);
255const deltaBtn = await p.$('button.crystal-act:has-text("Delta")');
256let delta = '';
257if (deltaBtn) {
258 await deltaBtn.click();
259 await p.waitForTimeout(800);
260 delta = await p.evaluate(() => (document.querySelector('.dlg-pre') || {}).textContent || '');
261}
262check('the chosen turn reaches the reducer', /MARK-BETA/.test(delta),
263 delta ? `(delta is ${delta.length} chars)` : '(nothing read back)');
264check('and the turns NOT chosen do not',
265 !!delta && !/MARK-ALPHA/.test(delta) && !/MARK-GAMMA/.test(delta) && !/MARK-DELTA/.test(delta),
266 delta ? MARKS.filter(m => delta.includes(m)).join(',') || '(only MARK-BETA)' : '(nothing read back)');
267
268// A fold of some turns is not the chat going anywhere, so the tile must not claim it was.
269const tileText = await p.evaluate(() => {
270 const f = document.querySelector('.tile-fold');
271 return f ? f.textContent.trim() : '(no tile)';
272});
273check('a partial fold does not mark the whole chat "Folded"',
274 tileText !== 'Folded', tileText);
275
276await shot(s, 'turns-folded');
277
278// The gateway is not running for this test — it is a browser-only flow — so its 502s are the
279// absence of a server, not a fault in the thread.
280const errs = errors(s).filter(e => !/favicon|404|401|502|Bad Gateway|net::ERR/.test(e));
281console.log('\nconsole errors:', errs.slice(0, 5));
282check('the thread throws nothing while all this happens', errs.length === 0, errs[0] || '');
283
284await s.close();
285console.log(`\n${ok.length} passed, ${bad.length} failed`);
286if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
287process.exit(bad.length ? 1 : 0);