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. |
| 18 | import { open, chat, shot, errors } from './harness.mjs'; |
| 19 | |
| 20 | const ok = [], bad = []; |
| 21 | const check = (name, pass, detail) => { |
| 22 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 23 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 24 | }; |
| 25 | |
| 26 | const FILL = 'the quick brown fox jumps over the lazy dog '.repeat(60); // a tall answer |
| 27 | const MARKS = ['MARK-ALPHA', 'MARK-BETA', 'MARK-GAMMA', 'MARK-DELTA']; |
| 28 | |
| 29 | const s = await open({ name: 'turns' }); |
| 30 | const p = s.page; |
| 31 | |
| 32 | // The Diamond first, so folding into it later needs no navigation away from the chat. |
| 33 | await p.click('#new-diamond-btn'); |
| 34 | await p.waitForSelector('.dlg-input', { timeout: 8000 }); |
| 35 | await p.fill('.dlg-input', 'Turns Test'); |
| 36 | await p.click('.dlg-ok'); |
| 37 | await 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. |
| 41 | for (const m of MARKS) await chat(s, `@text ${m} ${FILL}`); |
| 42 | |
| 43 | // ── Turns exist, and outputs belong to one ───────────────────────────── |
| 44 | |
| 45 | const 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 | }); |
| 55 | check('every question is a numbered turn', |
| 56 | shape.turns.join(',') === '1,2,3,4', shape.turns.join(',')); |
| 57 | check('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 | |
| 62 | const 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 | }); |
| 74 | check('clicking a question hides everything it produced', |
| 75 | toggle.before > 0 && toggle.closed === 0, `${toggle.before} → ${toggle.closed}`); |
| 76 | check('and the closed question says so, rather than standing beside a silent gap', |
| 77 | toggle.marked === true); |
| 78 | check('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. |
| 83 | const 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 | }); |
| 96 | check('selecting the text of a question does not fold it away', selecting === false); |
| 97 | |
| 98 | // ── The − collapses all, and latches into select mode ────────────────── |
| 99 | |
| 100 | const 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 | }); |
| 115 | check('the − collapses every answer', collapsed.allClosed && collapsed.asstHidden); |
| 116 | check('leaving the questions, which are what you are choosing between', |
| 117 | collapsed.questionsStillThere); |
| 118 | check('the button stays depressed, so the mode is visible', collapsed.latched); |
| 119 | check('and select mode brings out its tools and its ticks', |
| 120 | collapsed.toolsUp && collapsed.ticksUp); |
| 121 | |
| 122 | const 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 | }); |
| 130 | check('and a question can still be opened while choosing', peek === true); |
| 131 | |
| 132 | // ── Select all / Deselect all ────────────────────────────────────────── |
| 133 | |
| 134 | const 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 | }); |
| 143 | check('Select all ticks every turn', picks.all === picks.total && picks.total === 4, `${picks.all}/${picks.total}`); |
| 144 | check('Deselect all clears them', picks.none === 0); |
| 145 | |
| 146 | // Folding nothing is refused, rather than silently paying a reducer to fold an empty delta. |
| 147 | const 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 | }); |
| 157 | check('folding nothing is refused, not silently paid for', |
| 158 | /nothing chosen/i.test(refused), refused || '(no dialog)'); |
| 159 | |
| 160 | await shot(s, 'turns-select'); |
| 161 | |
| 162 | // ── The jump walks back through the questions ────────────────────────── |
| 163 | |
| 164 | const 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 | }); |
| 185 | check('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. |
| 187 | check('the jump puts the last question at the top', |
| 188 | Math.abs(jump.first) <= 4, `${jump.first}px from the top`); |
| 189 | check('pressing again walks back to the one before it', |
| 190 | Math.abs(jump.second) <= 4, `${jump.second}px from the top`); |
| 191 | check('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. |
| 201 | const 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 | }); |
| 216 | check('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`); |
| 219 | check('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 | |
| 228 | const 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 | }); |
| 240 | check('the fold menu says how much is going in, not just "Fold into…"', |
| 241 | /1 turn/.test(menuHead), menuHead); |
| 242 | |
| 243 | await p.evaluate(() => { |
| 244 | const item = [...document.querySelectorAll('.fold-menu-item')] |
| 245 | .find(b => b.textContent.trim() === 'Turns Test'); |
| 246 | if (item) item.click(); |
| 247 | }); |
| 248 | await p.waitForTimeout(7000); |
| 249 | const accept = await p.$('.diff-accept'); |
| 250 | if (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. |
| 253 | await p.click('button.crystal-act:has-text("History")'); |
| 254 | await p.waitForTimeout(900); |
| 255 | const deltaBtn = await p.$('button.crystal-act:has-text("Delta")'); |
| 256 | let delta = ''; |
| 257 | if (deltaBtn) { |
| 258 | await deltaBtn.click(); |
| 259 | await p.waitForTimeout(800); |
| 260 | delta = await p.evaluate(() => (document.querySelector('.dlg-pre') || {}).textContent || ''); |
| 261 | } |
| 262 | check('the chosen turn reaches the reducer', /MARK-BETA/.test(delta), |
| 263 | delta ? `(delta is ${delta.length} chars)` : '(nothing read back)'); |
| 264 | check('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. |
| 269 | const tileText = await p.evaluate(() => { |
| 270 | const f = document.querySelector('.tile-fold'); |
| 271 | return f ? f.textContent.trim() : '(no tile)'; |
| 272 | }); |
| 273 | check('a partial fold does not mark the whole chat "Folded"', |
| 274 | tileText !== 'Folded', tileText); |
| 275 | |
| 276 | await 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. |
| 280 | const errs = errors(s).filter(e => !/favicon|404|401|502|Bad Gateway|net::ERR/.test(e)); |
| 281 | console.log('\nconsole errors:', errs.slice(0, 5)); |
| 282 | check('the thread throws nothing while all this happens', errs.length === 0, errs[0] || ''); |
| 283 | |
| 284 | await s.close(); |
| 285 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 286 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 287 | process.exit(bad.length ? 1 : 0); |