oxedyne/daimond/dev/verify_railface.mjs
16.3 KiB, 1 run
created by r2519314175:623, 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_railface.mjs — the rail's second divider, and the crystal's own face. |
| 2 | // |
| 3 | // Two small things the user asked for, both of them about a boundary being |
| 4 | // visible. |
| 5 | // |
| 6 | // The divider: Diamonds accumulate, and the list grew with them until the chat |
| 7 | // tiles were off the bottom of the rail — with the only handle in the rail |
| 8 | // belonging to the Admin pane, which no longer splits anything. So the boundary |
| 9 | // between the two lists moves now, and A HEIGHT IN PIXELS is saved with the rest |
| 10 | // of the layout. It was a share until 2026-08-28, and a share is spent against a |
| 11 | // room that the Status strip below changes on its own: see section 4. |
| 12 | // |
| 13 | // The face: the AI panel shows either a conversation or a Diamond's crystal, and |
| 14 | // nothing said which. The crystal takes the mark beside its name and squares its |
| 15 | // corners — square against rounded everywhere else, which has to hold in three |
| 16 | // themes and both skins, since the warm skin's whole idea is a larger radius. |
| 17 | // |
| 18 | // node dev/verify_railface.mjs |
| 19 | // |
| 20 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway. |
| 21 | |
| 22 | import { open, signInAs, shot, errors } from './harness.mjs'; |
| 23 | |
| 24 | const ok = [], bad = []; |
| 25 | const check = (name, pass, detail) => { |
| 26 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 27 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 28 | }; |
| 29 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 30 | |
| 31 | const s = await open({ name: 'railface' }); |
| 32 | const p = s.page; |
| 33 | |
| 34 | const rail = () => p.evaluate(() => { |
| 35 | const top = document.getElementById('rail-top'); |
| 36 | const list = document.getElementById('diamond-list'); |
| 37 | const sess = document.getElementById('session-list'); |
| 38 | const h = document.getElementById('handle-rail-split'); |
| 39 | const kids = [...(top ? top.children : [])]; |
| 40 | return { |
| 41 | handle: !!h, |
| 42 | shown: !!h && getComputedStyle(h).display !== 'none', |
| 43 | // Between the Diamonds list and the Chats head, or it divides the wrong pair. |
| 44 | between: !!h && kids.indexOf(h) === kids.indexOf(list) + 1 |
| 45 | && (kids[kids.indexOf(h) + 1] || {}).className === 'railhead', |
| 46 | list: list ? Math.round(list.getBoundingClientRect().height) : 0, |
| 47 | sess: sess ? Math.round(sess.getBoundingClientRect().height) : 0, |
| 48 | // What is on disk, both formats, so the check can say the old one has gone |
| 49 | // rather than only that the new one is there. |
| 50 | saved: (() => { try { return JSON.parse(localStorage.getItem('daimond-layout') || '{}').railH; } catch (e) { return null; } })(), |
| 51 | share: (() => { try { return JSON.parse(localStorage.getItem('daimond-layout') || '{}').railSplit; } catch (e) { return null; } })(), |
| 52 | }; |
| 53 | }); |
| 54 | /// The Admin drawer is an overlay that rises over the rail, and unlocking opens |
| 55 | /// it — so it sits on top of the divider until it is dismissed. Not a fault in |
| 56 | /// the divider, but it does have to be got out of the way before one can be |
| 57 | /// dragged, exactly as a user would. |
| 58 | async function closeAdmin() { |
| 59 | const x = await p.$('#admin-close'); |
| 60 | if (x && await x.isVisible()) { await x.click({ force: true }); await sleep(400); } |
| 61 | } |
| 62 | /// Drag the divider by `dy` pixels. Returns false when there is nothing to drag. |
| 63 | async function drag(dy) { |
| 64 | const el = await p.$('#handle-rail-split'); |
| 65 | if (!el) return false; |
| 66 | const b = await el.boundingBox(); |
| 67 | if (!b) return false; |
| 68 | await p.mouse.move(b.x + b.width / 2, b.y + b.height / 2); |
| 69 | await p.mouse.down(); |
| 70 | await p.mouse.move(b.x + b.width / 2, b.y + b.height / 2 + dy, { steps: 12 }); |
| 71 | await p.mouse.up(); |
| 72 | await sleep(400); |
| 73 | return true; |
| 74 | } |
| 75 | |
| 76 | // A few Diamonds and a chat, so both lists have something in them. |
| 77 | for (const n of ['One', 'Two', 'Three']) { |
| 78 | await p.click('#new-diamond-btn', { force: true }); |
| 79 | await p.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 80 | await p.fill('.dlg-input', n); |
| 81 | await p.click('.dlg-ok', { force: true }); |
| 82 | await sleep(700); |
| 83 | } |
| 84 | await p.click('#new-session-btn', { force: true }); |
| 85 | await sleep(600); |
| 86 | |
| 87 | // ── 1. There is a divider, and it divides the right pair ───────────── |
| 88 | const r0 = await rail(); |
| 89 | check('the rail has a divider between Diamonds and Chats', r0.handle && r0.shown); |
| 90 | check('and it sits between the Diamonds list and the Chats head', r0.between === true); |
| 91 | |
| 92 | // ── 2. It moves the boundary ───────────────────────────────────────── |
| 93 | check('the divider can be dragged', await drag(150)); |
| 94 | const r1 = await rail(); |
| 95 | check('dragging down gives the Diamonds list the room', |
| 96 | r1.list > r0.list + 60, `${r0.list}px → ${r1.list}px`); |
| 97 | check('and takes it from the Chats list, which keeps a usable height', |
| 98 | r1.sess < r0.sess && r1.sess > 40, `chats ${r0.sess}px → ${r1.sess}px`); |
| 99 | await shot(s, 'railface-dragged'); |
| 100 | |
| 101 | // ── 3. Neither list can be crushed out of existence ────────────────── |
| 102 | await drag(-900); |
| 103 | const r2 = await rail(); |
| 104 | check('dragged hard up, the Diamonds list keeps a height it can be seen at', |
| 105 | r2.list >= 60, `${r2.list}px`); |
| 106 | await drag(900); |
| 107 | const r3 = await rail(); |
| 108 | check('dragged hard down, the Chats list keeps one too', |
| 109 | r3.sess >= 60, `${r3.sess}px`); |
| 110 | |
| 111 | // ── 4. The height is remembered, in pixels ─────────────────────────── |
| 112 | // A HEIGHT, not a share, since 2026-08-28. A share is spent against the room the |
| 113 | // two lists have, and that room changes for reasons that have nothing to do with |
| 114 | // the divider: the Status strip below them is a stack of rows that come and go, |
| 115 | // and one row appearing took 28px out of the pane and carried the Chats head 14 |
| 116 | // further. `applyRailSplit` in js/daimond.js holds the reasoning, and |
| 117 | // dev/verify_frame.mjs holds the check that the class is fixed rather than the |
| 118 | // instance. |
| 119 | // Back up the pane rather than hard against its floor: a divider at 72px comes |
| 120 | // back at 72px however it is stored, so the reload below would prove nothing. |
| 121 | await drag(-150); |
| 122 | const r4 = await rail(); |
| 123 | check('the drag is saved with the rest of the layout, as a height in pixels', |
| 124 | typeof r4.saved === 'number' && r4.saved > 60 && Math.abs(r4.saved - r4.list) <= 2, |
| 125 | `stored ${r4.saved}, drawn ${r4.list}px`); |
| 126 | check('and no share is written any more, so there is one format on disk', |
| 127 | r4.share === undefined, JSON.stringify(r4.share)); |
| 128 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 129 | await signInAs(s, 'railface'); |
| 130 | await sleep(1800); |
| 131 | await closeAdmin(); |
| 132 | const r5 = await rail(); |
| 133 | // THE PIXELS, and this check used to compare shares. The furniture below the two |
| 134 | // lists is a different height on an unlocked session than on a freshly created |
| 135 | // one, so the same share came back as a different number of pixels -- which is |
| 136 | // exactly the movement the height was introduced to stop. |
| 137 | check('and the divider comes back at the height it was left at', |
| 138 | Math.abs(r5.list - r4.list) <= 2, `${r4.list}px → ${r5.list}px`); |
| 139 | |
| 140 | // ── 5. A height made on a tall window survives a short one ─────────── |
| 141 | // He drags the divider low on a large screen and then opens Daimond on a laptop. |
| 142 | // A raw pixel figure could push the Chats list off the bottom of the rail, so |
| 143 | // what is APPLIED is held inside the room in force -- and what is STORED is his |
| 144 | // gesture, left alone, so the large screen gives it back exactly. The clamp is on |
| 145 | // read; nothing rewrites the figure to fit a window he is only passing through. |
| 146 | await p.setViewportSize({ width: 1440, height: 1100 }); |
| 147 | await sleep(700); |
| 148 | await drag(220); |
| 149 | const tall = await rail(); |
| 150 | await p.setViewportSize({ width: 1440, height: 820 }); |
| 151 | await sleep(700); |
| 152 | const short = await rail(); |
| 153 | check('a divider dragged low on a tall window leaves the Chats list usable on a short one', |
| 154 | short.sess >= 60 && short.list >= 60, `diamonds ${short.list}px, chats ${short.sess}px`); |
| 155 | check('and the short window does not rewrite what he chose', |
| 156 | typeof tall.saved === 'number' && short.saved === tall.saved, |
| 157 | `${tall.saved} → ${short.saved}`); |
| 158 | await p.setViewportSize({ width: 1440, height: 1100 }); |
| 159 | await sleep(700); |
| 160 | const again = await rail(); |
| 161 | check('so the tall window gives the divider back where he put it', |
| 162 | Math.abs(again.list - tall.list) <= 1, `${tall.list}px → ${short.list}px → ${again.list}px`); |
| 163 | |
| 164 | // ── 6. A layout saved before the divider was a height ──────────────── |
| 165 | // Every layout ever saved holds a `railSplit`, because it was written whether or |
| 166 | // not anybody had touched the handle. One holding a share somebody chose is spent |
| 167 | // once, at the size in force, and written back as a height. Not left readable for |
| 168 | // ever, and not silently discarded either. |
| 169 | await p.evaluate(() => { |
| 170 | const j = JSON.parse(localStorage.getItem('daimond-layout') || '{}'); |
| 171 | delete j.railH; |
| 172 | j.railSplit = 0.78; |
| 173 | localStorage.setItem('daimond-layout', JSON.stringify(j)); |
| 174 | }); |
| 175 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 176 | await signInAs(s, 'railface'); |
| 177 | await sleep(1800); |
| 178 | await closeAdmin(); |
| 179 | const mig = await rail(); |
| 180 | const share = x => x.list / (x.list + x.sess); |
| 181 | // TO THE PIXEL, and it took `railPin` to get there. The first layout runs before |
| 182 | // the Status strip's rows exist, in a pane some 130px taller than the one that |
| 183 | // settles, so converting the share THERE pinned the divider well below where it |
| 184 | // was left -- measured at 0.869 for a stored 0.78, and the clamp then made that |
| 185 | // permanent. The share goes on cutting instead, and what is written down is the |
| 186 | // cut, taken from a room that had settled. |
| 187 | check('a share saved by an older build is honoured, at the size in force', |
| 188 | Math.abs(share(mig) - 0.78) <= 0.02, share(mig).toFixed(3)); |
| 189 | check('and is written back as a height, the share gone from the layout', |
| 190 | typeof mig.saved === 'number' && mig.saved >= mig.list && mig.share === undefined, |
| 191 | `railH ${mig.saved}, drawn ${mig.list}px, railSplit ${JSON.stringify(mig.share)}`); |
| 192 | |
| 193 | // ── 7. Double-click puts it back to even, and unpins it ────────────── |
| 194 | // Guarded, so this file can be RUN against the code before the divider existed: |
| 195 | // a hard dblclick on a missing element aborts the pass instead of failing the |
| 196 | // check that is meant to catch its absence. |
| 197 | if (await p.$('#handle-rail-split')) await p.dblclick('#handle-rail-split', { force: true }); |
| 198 | await sleep(500); |
| 199 | const r6 = await rail(); |
| 200 | check('a double-click resets the divider to an even split', |
| 201 | Math.abs(r6.list - r6.sess) <= 24, `${r6.list}px vs ${r6.sess}px`); |
| 202 | // An even split is not a choice, so it goes on following the window -- which is |
| 203 | // what keeps every user who has never touched the handle on the behaviour they |
| 204 | // have always had. The reset undoes the pinning as well as the position. |
| 205 | await p.setViewportSize({ width: 1440, height: 820 }); |
| 206 | await sleep(700); |
| 207 | const r7 = await rail(); |
| 208 | check('and a reset divider follows the window again, rather than staying pinned', |
| 209 | Math.abs(r7.list - r7.sess) <= 24 && r7.list < r6.list - 40, |
| 210 | `${r6.list}px at 1100 → ${r7.list}px vs ${r7.sess}px at 820`); |
| 211 | await p.setViewportSize({ width: 1440, height: 900 }); |
| 212 | await sleep(600); |
| 213 | |
| 214 | // ── 8. The crystal face, in every theme and both skins ─────────────── |
| 215 | const face = () => p.evaluate(() => { |
| 216 | const ai = document.getElementById('panel-ai'); |
| 217 | const m = document.getElementById('chead-mark'); |
| 218 | const cs = getComputedStyle(ai); |
| 219 | return { |
| 220 | cls: ai.classList.contains('crystal-face'), |
| 221 | radius: parseFloat(cs.borderTopLeftRadius), |
| 222 | mark: !!m && getComputedStyle(m).display !== 'none', |
| 223 | markInHead: !!(m && m.closest('.chead')), |
| 224 | // The mark is decoration beside a name that is already there, so it must be |
| 225 | // invisible to a screen reader rather than read out as a second title. |
| 226 | hidden: !!m && m.getAttribute('aria-hidden') === 'true' && m.getAttribute('alt') === '', |
| 227 | nameFirst: (() => { |
| 228 | const head = document.querySelector('.panel.ai .chead'); |
| 229 | if (!head || !m) return null; |
| 230 | const kids = [...head.children]; |
| 231 | return kids.indexOf(m) < kids.indexOf(head.querySelector('.ctitle')); |
| 232 | })(), |
| 233 | }; |
| 234 | }); |
| 235 | async function selectDiamond(name) { |
| 236 | await p.evaluate((n) => { |
| 237 | const box = [...document.querySelectorAll('.diamond-box')] |
| 238 | .find(b => (b.querySelector('.session-box-name') || {}).textContent === n); |
| 239 | if (box) box.click(); |
| 240 | }, name); |
| 241 | await sleep(900); |
| 242 | } |
| 243 | async function selectChat() { |
| 244 | await p.evaluate(() => { |
| 245 | const box = document.querySelector('.session-box:not(.diamond-box)'); |
| 246 | if (box) box.click(); |
| 247 | }); |
| 248 | await sleep(700); |
| 249 | } |
| 250 | |
| 251 | await selectChat(); |
| 252 | const chatFace = await face(); |
| 253 | check('a chat wears no mark', chatFace.mark === false && chatFace.cls === false); |
| 254 | check('and keeps the rounded corners everything else in the app has', |
| 255 | chatFace.radius > 4, `${chatFace.radius}px`); |
| 256 | |
| 257 | for (const skin of ['sharp', 'warm']) { |
| 258 | await p.evaluate(k => window.DaimondSkin.set(k), skin); |
| 259 | await sleep(500); |
| 260 | for (const theme of ['dark', 'light', 'lollypop']) { |
| 261 | await p.evaluate(t => window.DaimondTheme.set(t), theme); |
| 262 | await sleep(400); |
| 263 | await selectDiamond('Two'); |
| 264 | const f = await face(); |
| 265 | check(`the crystal squares its corners (${skin}/${theme})`, |
| 266 | f.cls === true && f.radius === 0, `radius ${f.radius}px`); |
| 267 | check(`and shows the mark beside the crystal name (${skin}/${theme})`, |
| 268 | f.mark && f.markInHead && f.nameFirst === true, |
| 269 | `mark=${f.mark} inHead=${f.markInHead} beforeName=${f.nameFirst}`); |
| 270 | if (theme === 'dark') await shot(s, `railface-crystal-${skin}`); |
| 271 | await selectChat(); |
| 272 | const c = await face(); |
| 273 | check(`and the chat gets its corners back (${skin}/${theme})`, |
| 274 | c.cls === false && c.radius > 4, `radius ${c.radius}px`); |
| 275 | } |
| 276 | await p.evaluate(() => window.DaimondTheme.set('dark')); |
| 277 | } |
| 278 | await p.evaluate(() => window.DaimondSkin.set('sharp')); |
| 279 | await sleep(400); |
| 280 | await selectDiamond('Two'); |
| 281 | const acc = await face(); |
| 282 | check('the mark is decoration, not a second heading, to a screen reader', acc.hidden === true); |
| 283 | |
| 284 | // ── 9. The phone drawer is not divided ─────────────────────────────── |
| 285 | await p.setViewportSize({ width: 390, height: 844 }); |
| 286 | await sleep(700); |
| 287 | const phone = await rail(); |
| 288 | check('on a phone the drawer scrolls as one column, with no divider in it', |
| 289 | phone.shown === false, `handle shown=${phone.shown}`); |
| 290 | const inline = await p.$eval('#diamond-list', e => e.style.height); |
| 291 | check('and no height is imposed on either list there', inline === '', JSON.stringify(inline)); |
| 292 | await shot(s, 'railface-phone'); |
| 293 | |
| 294 | // ── 10. A phone must not eat an old share on its way past ──────────── |
| 295 | // The layout is one of the keys pairing copies between devices, and the phone |
| 296 | // draws no divider -- so there is no room to convert a share against and nothing |
| 297 | // to write down. It is carried instead of dropped, and the desktop spends it. |
| 298 | await p.evaluate(() => { |
| 299 | const j = JSON.parse(localStorage.getItem('daimond-layout') || '{}'); |
| 300 | delete j.railH; |
| 301 | j.railSplit = 0.34; |
| 302 | localStorage.setItem('daimond-layout', JSON.stringify(j)); |
| 303 | }); |
| 304 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 305 | await signInAs(s, 'railface'); |
| 306 | await sleep(1800); |
| 307 | const onPhone = await rail(); |
| 308 | check('a phone-only session leaves an old share where it found it', |
| 309 | onPhone.share === 0.34 && (onPhone.saved === null || onPhone.saved === undefined), |
| 310 | `railSplit ${onPhone.share}, railH ${JSON.stringify(onPhone.saved)}`); |
| 311 | // RELOADED ON THE PHONE, and the order matters. The share is still in memory |
| 312 | // after the step above, so a desktop viewport set first would cut a divider from |
| 313 | // there whether or not anything survived the write -- and the check would pass on |
| 314 | // code that had just eaten it. Reloading before the window widens makes the disk |
| 315 | // the only source there is. |
| 316 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 317 | await signInAs(s, 'railface'); |
| 318 | await sleep(1800); |
| 319 | await p.setViewportSize({ width: 1440, height: 1000 }); |
| 320 | await sleep(1400); |
| 321 | await closeAdmin(); |
| 322 | const offPhone = await rail(); |
| 323 | check('and the next desktop spends it, as a height, the share gone', |
| 324 | typeof offPhone.saved === 'number' && offPhone.share === undefined |
| 325 | && Math.abs(share(offPhone) - 0.34) <= 0.03, |
| 326 | `railH ${offPhone.saved}, share ${share(offPhone).toFixed(3)}`); |
| 327 | |
| 328 | // 402 as well as 502: the unlock path asks the account service what this account |
| 329 | // is entitled to, and with no gateway running that question is answered by |
| 330 | // nothing at all. |
| 331 | const errs = errors(s).filter(e => !/favicon|404|401|402|502|Bad Gateway|net::ERR/.test(e)); |
| 332 | console.log('\nconsole errors:', errs.slice(0, 5)); |
| 333 | check('nothing throws while all this happens', errs.length === 0, errs[0] || ''); |
| 334 | |
| 335 | await s.close(); |
| 336 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 337 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 338 | process.exit(bad.length ? 1 : 0); |