oxedyne/daimond/dev/verify_raildialogs.mjs
22.5 KiB, 1 run
created by r2519314175:621, 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_raildialogs.mjs — photograph and MEASURE every dialog the rail opens. |
| 2 | // |
| 3 | // The user's complaint, verbatim: *"I saw obvious button and chip misalignment |
| 4 | // and spacing problems in dialogs in the rail, I can't remember which ones, so |
| 5 | // examine all visually and quality control."* A defect nobody can name cannot |
| 6 | // be asserted on, so it has to be swept for. |
| 7 | // |
| 8 | // `dev/sweep.mjs` sweeps panels and three pop-ups. It does not open a single |
| 9 | // dialog, because a dialog needs something to be about — a Diamond, a chat, a |
| 10 | // mailbox — and the sweep runs on an empty account. So this seeds a rail and |
| 11 | // then walks every dialog it can reach, at both skins, both themes and three |
| 12 | // widths. |
| 13 | // |
| 14 | // What it MEASURES, chosen for the defect the user described rather than for |
| 15 | // completeness: |
| 16 | // |
| 17 | // [escapes] an element whose box leaves the card it is in |
| 18 | // [clipped] an element whose content is wider or taller than its own box, |
| 19 | // with nothing on screen admitting it — a sideways cut that ends |
| 20 | // in an ellipsis is the app saying so, and is not counted |
| 21 | // [wrapped] a flex ROW whose children landed on more than one line — |
| 22 | // which is what "the refresh button dropped below the title" |
| 23 | // looks like from the outside, and what geometry alone can see |
| 24 | // [offcentre] children of a centred row whose INK centres differ by more |
| 25 | // than 1.5px — the misalignment, measured rather than eyeballed |
| 26 | // [overlap] two controls whose boxes intersect |
| 27 | // |
| 28 | // It asserts nothing about beauty. It fails only on [escapes], [clipped] and |
| 29 | // [overlap], which are always wrong; [wrapped] and [offcentre] are reported to |
| 30 | // be LOOKED at, because a row that wraps on a phone is often correct. |
| 31 | // |
| 32 | // node dev/verify_raildialogs.mjs |
| 33 | // node dev/verify_raildialogs.mjs --out ~/.cache/daimond/raildlg |
| 34 | // node dev/verify_raildialogs.mjs --only 'Tile dialog (Diamond)' |
| 35 | // |
| 36 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway; nothing spends. |
| 37 | import fs from 'node:fs'; |
| 38 | import path from 'node:path'; |
| 39 | import os from 'node:os'; |
| 40 | import { open, scratch } from './harness.mjs'; |
| 41 | |
| 42 | const argv = process.argv.slice(2); |
| 43 | const val = (n, d) => { const i = argv.indexOf(n); return (i >= 0 && argv[i + 1]) ? argv[i + 1] : d; }; |
| 44 | const OUT = val('--out', path.join(os.homedir(), '.cache/daimond/raildlg')); |
| 45 | const ONLY = val('--only', ''); |
| 46 | fs.mkdirSync(OUT, { recursive: true }); |
| 47 | |
| 48 | let hard = 0, soft = 0, shots = 0; |
| 49 | const findings = []; |
| 50 | |
| 51 | const CELLS = [ |
| 52 | { skin: 'sharp', theme: 'dark', w: 1500, h: 950 }, |
| 53 | { skin: 'warm', theme: 'light', w: 1500, h: 950 }, |
| 54 | // 1400, not 900: below 1280 the rail FOLDS ITSELF AWAY (daimond.js:3112), so |
| 55 | // at 900 the dialogs it opens are genuinely unreachable and a sweep there |
| 56 | // photographs nothing. 1400 is the narrowest at which the rail is still a rail. |
| 57 | { skin: 'sharp', theme: 'light', w: 1400, h: 820 }, |
| 58 | { skin: 'warm', theme: 'dark', w: 380, h: 780 }, |
| 59 | ]; |
| 60 | |
| 61 | // ── The measuring pass, run inside the page ──────────────────────────── |
| 62 | // |
| 63 | // One evaluate, not one per element: a layout read per element over a whole |
| 64 | // dialog is hundreds of round trips and the page relayouts between them. |
| 65 | const MEASURE = function (rootSel) { |
| 66 | const root = document.querySelector(rootSel); |
| 67 | if (!root) return { missing: true, defects: [] }; |
| 68 | const out = []; |
| 69 | const vis = (el) => { |
| 70 | if (el.getClientRects().length === 0) return false; |
| 71 | const cs = getComputedStyle(el); |
| 72 | if (cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false; |
| 73 | if (el.classList.contains('vh')) return false; |
| 74 | return true; |
| 75 | }; |
| 76 | const name = (el) => { |
| 77 | let s = el.tagName.toLowerCase(); |
| 78 | if (el.id) s += '#' + el.id; |
| 79 | else if (typeof el.className === 'string' && el.className.trim()) { |
| 80 | s += '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.'); |
| 81 | } |
| 82 | const t = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 24); |
| 83 | return t ? `${s} "${t}"` : s; |
| 84 | }; |
| 85 | const at = (r) => ({ x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }); |
| 86 | const all = [...root.querySelectorAll('*')].filter(vis); |
| 87 | const rr = root.getBoundingClientRect(); |
| 88 | // A container that SCROLLS is meant to hold things outside its visible box — |
| 89 | // that is what scrolling is — so an escape is measured only on the axes the |
| 90 | // root does not scroll. Without this the Appearance menu, a tall scroller on |
| 91 | // a phone, reported every control below its fold as escaping. |
| 92 | const rcs = getComputedStyle(root); |
| 93 | const scrollY = /auto|scroll/.test(rcs.overflowY) || /auto|scroll/.test(rcs.overflow); |
| 94 | const scrollX = /auto|scroll/.test(rcs.overflowX) || /auto|scroll/.test(rcs.overflow); |
| 95 | // Once per chain: a path inside an svg inside a button that escapes is ONE |
| 96 | // defect written up three times, which is how eight findings become twenty. |
| 97 | const escaped = []; |
| 98 | |
| 99 | for (const el of all) { |
| 100 | const r = el.getBoundingClientRect(); |
| 101 | // [escapes] — beyond the card, by more than a rounding error. Half a pixel |
| 102 | // is subpixel layout; two is somebody's box being too big. |
| 103 | const overX = scrollX ? -1 : Math.max(rr.left - r.left, r.right - rr.right); |
| 104 | const overY = scrollY ? -1 : Math.max(rr.top - r.top, r.bottom - rr.bottom); |
| 105 | const over = Math.max(overX, overY); |
| 106 | if (over > 2 && !escaped.some((a) => a.contains(el))) { |
| 107 | escaped.push(el); |
| 108 | out.push({ kind: 'escapes', by: Math.round(over), el: name(el), rect: at(r), weight: 3 }); |
| 109 | } |
| 110 | // [clipped] — its own content does not fit, and it is not a scroller. |
| 111 | // |
| 112 | // SIDEWAYS, AN ELLIPSIS IS NOT A DEFECT. A one-line box with |
| 113 | // `text-overflow: ellipsis` is the app admitting the cut, and the reader |
| 114 | // sees the "…" and knows to open the thing — the trigger gist in the |
| 115 | // Diamond tile dialog is drawn that way on purpose, and this flagged it |
| 116 | // as always-wrong at the one skin where the words happen to be too long. |
| 117 | // dev/verify_sweep_desktop.mjs has always drawn the line here ("an |
| 118 | // admitted cut"), and two sweeps disagreeing about what clipping means |
| 119 | // is how one of them gets ignored. |
| 120 | // |
| 121 | // Downwards it still counts: `text-overflow` says nothing about a box |
| 122 | // too short for its own lines, so text running out of the bottom of a |
| 123 | // card leaves no sign at all. That is the case this was written for and |
| 124 | // it is untouched. The probe at the foot of this file plants both. |
| 125 | const cs = getComputedStyle(el); |
| 126 | const scrolls = /auto|scroll/.test(cs.overflow + cs.overflowX + cs.overflowY); |
| 127 | if (!scrolls) { |
| 128 | const dx = cs.textOverflow === 'ellipsis' ? 0 : el.scrollWidth - el.clientWidth; |
| 129 | const dy = el.scrollHeight - el.clientHeight; |
| 130 | if (el.clientWidth > 0 && (dx > 2 || dy > 2)) { |
| 131 | out.push({ kind: 'clipped', by: Math.max(dx, dy), el: name(el), rect: at(r), weight: 3 }); |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // [wrapped] and [offcentre] — properties of a ROW, so they are measured on |
| 137 | // the containers rather than on the elements. |
| 138 | for (const el of all) { |
| 139 | const cs = getComputedStyle(el); |
| 140 | if (cs.display !== 'flex' && cs.display !== 'inline-flex') continue; |
| 141 | if (cs.flexDirection !== 'row') continue; |
| 142 | const kids = [...el.children].filter(vis); |
| 143 | if (kids.length < 2) continue; |
| 144 | const boxes = kids.map((k) => k.getBoundingClientRect()); |
| 145 | // The CENTRES, against the tallest child. A 30px button beside a 19px |
| 146 | // label sits on one line and its box top is 5px higher; bucketing by top |
| 147 | // called that a wrap, which is a false positive on every mixed row in the |
| 148 | // app. A row has genuinely wrapped only when a child's centre is most of a |
| 149 | // child-height away from another's. |
| 150 | const mids = boxes.map((b) => b.top + b.height / 2); |
| 151 | const tall = Math.max(...boxes.map((b) => b.height), 1); |
| 152 | const spread = Math.max(...mids) - Math.min(...mids); |
| 153 | if (spread > tall * 0.6) { |
| 154 | out.push({ kind: 'wrapped', by: +spread.toFixed(1), el: name(el), |
| 155 | rect: at(el.getBoundingClientRect()), weight: 1, note: kids.map(name).join(' | ') }); |
| 156 | continue; // a wrapped row's centres are not comparable |
| 157 | } |
| 158 | if (cs.alignItems !== 'center') continue; |
| 159 | if (spread > 1.5) { |
| 160 | out.push({ kind: 'offcentre', by: +spread.toFixed(2), el: name(el), |
| 161 | rect: at(el.getBoundingClientRect()), weight: 2, note: kids.map(name).join(' | ') }); |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // [overlap] — two controls sharing pixels. Only controls: a label lying over |
| 166 | // its own field's background is how a field is drawn. |
| 167 | const ctl = all.filter((e) => e.matches('button, a[href], input, select, textarea, [role="button"]')); |
| 168 | for (let i = 0; i < ctl.length; i++) { |
| 169 | for (let j = i + 1; j < ctl.length; j++) { |
| 170 | if (ctl[i].contains(ctl[j]) || ctl[j].contains(ctl[i])) continue; |
| 171 | const a = ctl[i].getBoundingClientRect(), b = ctl[j].getBoundingClientRect(); |
| 172 | const ox = Math.min(a.right, b.right) - Math.max(a.left, b.left); |
| 173 | const oy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); |
| 174 | if (ox > 2 && oy > 2) { |
| 175 | out.push({ kind: 'overlap', by: Math.round(Math.min(ox, oy)), el: name(ctl[i]), |
| 176 | other: name(ctl[j]), rect: at(a), weight: 3 }); |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | out.sort((p, q) => (q.weight - p.weight) || ((q.by || 0) - (p.by || 0))); |
| 181 | return { missing: false, defects: out }; |
| 182 | }; |
| 183 | |
| 184 | // ── Getting each dialog on screen ────────────────────────────────────── |
| 185 | /// Make sure the rail is actually on screen, opening the drawer if it is not. |
| 186 | /// Returns whether it managed it, so a skip says "no rail" rather than "no |
| 187 | /// button". |
| 188 | async function railOpen(page) { |
| 189 | const there = () => page.evaluate(() => { |
| 190 | const r = document.getElementById('panel-rail'); |
| 191 | return !!r && r.getClientRects().length > 0 && r.getBoundingClientRect().width > 40; |
| 192 | }); |
| 193 | if (await there()) return true; |
| 194 | // Two ways, because the rail is hidden two different ways: below 1280px it |
| 195 | // is a panel the dock has closed, and on a phone it is a drawer behind the |
| 196 | // hamburger. Asking the panel registry first is the one that works at 900. |
| 197 | await page.evaluate(() => { try { DaimondPanels.show('rail'); } catch (e) { /* not up */ } }); |
| 198 | await page.waitForTimeout(400); |
| 199 | if (await there()) return true; |
| 200 | await page.evaluate(() => { const b = document.getElementById('drawer-btn'); if (b) b.click(); }); |
| 201 | await page.waitForTimeout(450); |
| 202 | return there(); |
| 203 | } |
| 204 | |
| 205 | const press = async (page, sel) => { |
| 206 | await page.waitForSelector(sel, { timeout: 10000 }); |
| 207 | await page.evaluate((s) => { const e = document.querySelector(s); if (e) e.click(); }, sel); |
| 208 | await page.waitForTimeout(320); |
| 209 | }; |
| 210 | |
| 211 | /// Press the control inside `rootSel` whose words are exactly `text`. Exact, |
| 212 | /// because Playwright's `:has-text` is a case-insensitive substring and would |
| 213 | /// press "Change name…" when asked for "Change passphrase…". |
| 214 | const pressLabel = async (page, rootSel, text) => { |
| 215 | await page.waitForSelector(rootSel, { timeout: 10000 }); |
| 216 | const hit = await page.evaluate(({ rootSel, text }) => { |
| 217 | const root = document.querySelector(rootSel); |
| 218 | if (!root) return false; |
| 219 | const b = [...root.querySelectorAll('button')].find((x) => (x.textContent || '').trim() === text); |
| 220 | if (!b) return false; |
| 221 | b.click(); |
| 222 | return true; |
| 223 | }, { rootSel, text }); |
| 224 | if (!hit) throw new Error(`no control labelled "${text}"`); |
| 225 | await page.waitForTimeout(400); |
| 226 | }; |
| 227 | |
| 228 | const DIALOGS = [ |
| 229 | { |
| 230 | name: 'New Diamond', sel: '.modal.dlg .dlg-card', |
| 231 | reach: async (p) => { await press(p, '#new-diamond-btn'); }, |
| 232 | }, |
| 233 | { |
| 234 | name: 'Tile dialog (Diamond)', sel: '.tile-dlg-card', |
| 235 | reach: async (p) => { await press(p, '#diamond-list .tile-cog'); }, |
| 236 | }, |
| 237 | { |
| 238 | name: 'Tile dialog (chat)', sel: '.tile-dlg-card', |
| 239 | reach: async (p) => { await press(p, '#session-list .tile-cog'); }, |
| 240 | }, |
| 241 | // The two confirms that used to be here — "Delete a Diamond" and "Delete a |
| 242 | // chat" — are gone: since the trash, deleting is reversible and asks nothing. |
| 243 | // The QUESTION moved rather than disappeared, so the coverage moves with it. |
| 244 | // Both of these are reached by deleting something first, which is now the |
| 245 | // step that opens no dialog at all. |
| 246 | { |
| 247 | name: 'Delete permanently (confirm)', sel: '.modal.dlg .dlg-card', |
| 248 | reach: async (p) => { |
| 249 | await press(p, '#diamond-list .tile-cog'); |
| 250 | await press(p, '.tile-dlg-delete'); |
| 251 | await p.waitForTimeout(1200); |
| 252 | await p.evaluate(() => { try { DaimondPanels.show('trash'); } catch (e) { /* not up */ } }); |
| 253 | await p.waitForTimeout(700); |
| 254 | await press(p, '#trash-list .trash-purge'); |
| 255 | }, |
| 256 | }, |
| 257 | { |
| 258 | name: 'Empty trash (confirm)', sel: '.modal.dlg .dlg-card', |
| 259 | reach: async (p) => { |
| 260 | await press(p, '#session-list .tile-cog'); |
| 261 | await press(p, '.tile-dlg-delete'); |
| 262 | await p.waitForTimeout(1200); |
| 263 | await p.evaluate(() => { try { DaimondPanels.show('trash'); } catch (e) { /* not up */ } }); |
| 264 | await p.waitForTimeout(700); |
| 265 | await press(p, '#trash-empty'); |
| 266 | }, |
| 267 | }, |
| 268 | { |
| 269 | name: 'Rename a Diamond', sel: '.modal.dlg .dlg-card', |
| 270 | reach: async (p) => { |
| 271 | await p.evaluate(() => { |
| 272 | const n = document.querySelector('#diamond-list .session-box-name'); |
| 273 | n.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); |
| 274 | }); |
| 275 | await p.waitForTimeout(400); |
| 276 | }, |
| 277 | }, |
| 278 | { |
| 279 | name: 'Fold this chat into…', sel: '.fold-menu', |
| 280 | reach: async (p) => { await press(p, '#session-list .tile-fold'); }, |
| 281 | }, |
| 282 | { |
| 283 | name: 'Admin home', sel: '#admin-home', |
| 284 | reach: async (p) => { await press(p, '#user-row'); }, |
| 285 | }, |
| 286 | { |
| 287 | name: 'Models', sel: '#admin-models', |
| 288 | reach: async (p) => { await press(p, '#astat-model'); }, |
| 289 | }, |
| 290 | { |
| 291 | name: 'Credits', sel: '#admin-credits', |
| 292 | reach: async (p) => { await press(p, '#astat-account'); }, |
| 293 | }, |
| 294 | { |
| 295 | name: 'Change name', sel: '.modal.dlg .dlg-card', |
| 296 | reach: async (p) => { |
| 297 | await press(p, '#user-row'); |
| 298 | await pressLabel(p, '#admin-home', 'Change name…'); |
| 299 | }, |
| 300 | }, |
| 301 | { |
| 302 | name: 'Forget this identity (confirm)', sel: '.modal.dlg .dlg-card', |
| 303 | reach: async (p) => { |
| 304 | await press(p, '#user-row'); |
| 305 | await pressLabel(p, '#admin-home', 'Forget this identity…'); |
| 306 | }, |
| 307 | }, |
| 308 | { |
| 309 | name: 'Add a mailbox', sel: '#admin-form', |
| 310 | reach: async (p) => { |
| 311 | await p.evaluate(() => window.DaimondPanels && DaimondPanels.show('mail')); |
| 312 | await p.waitForTimeout(320); |
| 313 | await press(p, '#panel-mail [data-act="mail-add"]'); |
| 314 | await p.waitForSelector('#admin-form .dlg-input', { timeout: 8000 }); |
| 315 | }, |
| 316 | }, |
| 317 | { |
| 318 | name: 'Link another device', sel: '.pair-scrim', |
| 319 | reach: async (p) => { |
| 320 | await p.evaluate(() => window.DaimondPairing && DaimondPairing.showLink()); |
| 321 | await p.waitForSelector('.pair-scrim', { timeout: 8000 }); |
| 322 | }, |
| 323 | }, |
| 324 | { |
| 325 | name: 'Appearance menu', sel: '#settings-menu', |
| 326 | reach: async (p) => { await press(p, '#settings-menu-btn'); }, |
| 327 | }, |
| 328 | { |
| 329 | name: 'Panel gallery', sel: '#panel-gallery', |
| 330 | reach: async (p) => { |
| 331 | await p.evaluate(() => { |
| 332 | ['doc', 'msg', 'compose'].forEach((x) => { try { DaimondPanels.markUsed(x); } catch (e) {} }); |
| 333 | try { DaimondPanels.reflow(); } catch (e) {} |
| 334 | }); |
| 335 | await p.waitForTimeout(400); |
| 336 | await press(p, '#panel-more'); |
| 337 | }, |
| 338 | }, |
| 339 | ]; |
| 340 | |
| 341 | /// Screenshot, and PROVE it landed. The suite's own `shot()` swallows a failed |
| 342 | /// capture, and capture on this box has silently failed for an hour at a time |
| 343 | /// under load — so a clean run is not by itself evidence of a picture. |
| 344 | async function snap(page, file, sel) { |
| 345 | const p = path.join(OUT, file + '.png'); |
| 346 | try { |
| 347 | const el = sel ? await page.$(sel) : null; |
| 348 | if (el) await el.screenshot({ path: p, timeout: 8000 }); |
| 349 | else await page.screenshot({ path: p, timeout: 8000 }); |
| 350 | } catch (e) { return null; } |
| 351 | if (!fs.existsSync(p) || fs.statSync(p).size < 400) return null; |
| 352 | shots++; |
| 353 | return p; |
| 354 | } |
| 355 | |
| 356 | const s = await open({ name: 'raildlg', profile: scratch('pw', 'raildlg-' + process.pid) }); |
| 357 | const { page } = s; |
| 358 | try { |
| 359 | // A rail with something in it. Every dialog below is about an object, and an |
| 360 | // empty account has none — the whole reason dev/sweep.mjs never opened one. |
| 361 | await page.evaluate(() => { const b = document.getElementById('admin-close'); if (b) b.click(); }); |
| 362 | await page.waitForTimeout(250); |
| 363 | await press(page, '#new-diamond-btn'); |
| 364 | await page.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 365 | await page.evaluate(() => { |
| 366 | const card = [...document.querySelectorAll('.dlg-card')].find((c) => c.getClientRects().length); |
| 367 | const i = card.querySelector('input.dlg-input'); |
| 368 | i.value = 'A Diamond with a fairly long name'; |
| 369 | i.dispatchEvent(new Event('input', { bubbles: true })); |
| 370 | card.querySelector('.dlg-ok').click(); |
| 371 | }); |
| 372 | await page.waitForTimeout(1600); |
| 373 | await press(page, '#new-session-btn'); |
| 374 | await page.evaluate(() => { const b = [...document.querySelectorAll('.tile-start')].pop(); if (b) b.click(); }); |
| 375 | await page.waitForTimeout(1500); |
| 376 | // One turn, so the chat has something in it. The Fold picker refuses an empty |
| 377 | // chat with a notice, so without this the picker is never photographed and |
| 378 | // the skip reads as "unreachable" rather than "there was nothing to fold". |
| 379 | if (await page.isVisible('#chat-input')) { |
| 380 | await page.fill('#chat-input', '@text a first answer'); |
| 381 | await page.click('#chat-send'); |
| 382 | await page.waitForTimeout(2500); |
| 383 | } |
| 384 | |
| 385 | const seeded = await page.evaluate(() => ({ |
| 386 | d: document.querySelectorAll('#diamond-list .session-box').length, |
| 387 | c: document.querySelectorAll('#session-list .session-box').length, |
| 388 | // An ACTIVE chat, not a pending one: Fold is only on an active tile, and |
| 389 | // a pending one would make that dialog silently unreachable. |
| 390 | fold: document.querySelectorAll('#session-list .tile-fold').length, |
| 391 | })); |
| 392 | console.log(`seeded: ${seeded.d} Diamond(s), ${seeded.c} chat(s), ${seeded.fold} foldable`); |
| 393 | if (!seeded.fold) console.log('note the chat did not start — the Fold picker will be skipped'); |
| 394 | if (!seeded.d || !seeded.c) { |
| 395 | console.log('FAIL nothing to open a dialog about — refusing to sweep an empty rail.'); |
| 396 | await s.close(); |
| 397 | process.exit(1); |
| 398 | } |
| 399 | |
| 400 | for (const cell of CELLS) { |
| 401 | const tag = `${cell.skin}-${cell.theme}-${cell.w}`; |
| 402 | console.log(`\n── ${tag}`); |
| 403 | await page.setViewportSize({ width: cell.w, height: cell.h }); |
| 404 | await page.evaluate((c) => { |
| 405 | document.documentElement.setAttribute('data-skin', c.skin); |
| 406 | document.documentElement.setAttribute('data-theme', c.theme); |
| 407 | document.documentElement.setAttribute('data-tone', c.theme === 'light' ? 'light' : 'dark'); |
| 408 | document.documentElement.setAttribute('data-ink', c.theme === 'light' ? 'dark' : 'light'); |
| 409 | }, cell); |
| 410 | await page.waitForTimeout(250); |
| 411 | |
| 412 | // The rail folds itself away below 1280px, and on a phone it is a |
| 413 | // drawer. Photographing without opening it photographs the chat and |
| 414 | // concludes the work is missing — so it is opened whenever it is not |
| 415 | // actually on screen, whatever the width. |
| 416 | await railOpen(page); |
| 417 | |
| 418 | for (const d of DIALOGS) { |
| 419 | if (ONLY && d.name !== ONLY) continue; |
| 420 | // Quieten first, so the previous dialog cannot be measured as this one. |
| 421 | for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(90); } |
| 422 | await railOpen(page); |
| 423 | let up = false; |
| 424 | try { await d.reach(page); up = true; } catch (e) { /* reported below */ } |
| 425 | await page.waitForTimeout(280); |
| 426 | const there = up && await page.evaluate((sel) => { |
| 427 | const e = document.querySelector(sel); |
| 428 | return !!e && e.getClientRects().length > 0 && e.getBoundingClientRect().height > 4; |
| 429 | }, d.sel); |
| 430 | if (!there) { console.log(` skip ${d.name} — did not open here`); continue; } |
| 431 | |
| 432 | const file = `${tag}--${d.name.replace(/\W+/g, '-').toLowerCase()}`; |
| 433 | const shot = await snap(page, file, d.sel); |
| 434 | const m = await page.evaluate(MEASURE, d.sel); |
| 435 | const worst = m.defects.length ? `${m.defects.length}: ${m.defects[0].kind} ${m.defects[0].el}` : ''; |
| 436 | console.log(` ${m.defects.length ? 'LOOK' : ' ok '} ${d.name}${worst ? ' ' + worst : ''}` |
| 437 | + (shot ? '' : ' [NO SHOT ON DISK]')); |
| 438 | for (const f of m.defects) { |
| 439 | findings.push({ cell: tag, dialog: d.name, shot, ...f }); |
| 440 | if (f.kind === 'escapes' || f.kind === 'clipped' || f.kind === 'overlap') hard++; else soft++; |
| 441 | } |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | // ── The clipped rule, shown catching and shown letting go ─────────── |
| 446 | // |
| 447 | // A sweep that reports nothing is not evidence that there was nothing to |
| 448 | // report, and the exemption above is the kind of edit that quietly turns a |
| 449 | // check off. So both halves are planted in the live page and MEASURE — the |
| 450 | // same function, not a copy of its rule — is run over them: a cut with an |
| 451 | // ellipsis must be let through, a cut without one must be caught, and text |
| 452 | // running out of the bottom of a box must be caught whatever its |
| 453 | // `text-overflow` says, because that is the one nothing on screen admits to. |
| 454 | { |
| 455 | await page.evaluate(() => { |
| 456 | const box = document.createElement('div'); |
| 457 | box.id = 'raildlg-probe'; |
| 458 | box.style.cssText = 'position:fixed; left:20px; top:20px; width:300px; ' |
| 459 | + 'z-index:99999; background:#fff;'; |
| 460 | box.innerHTML = ` |
| 461 | <div id="probe-ellipsis" style="width:60px; overflow:hidden; white-space:nowrap; |
| 462 | text-overflow:ellipsis;">a line far longer than sixty pixels of room</div> |
| 463 | <div id="probe-clip" style="width:60px; overflow:hidden; white-space:nowrap; |
| 464 | text-overflow:clip;">a line far longer than sixty pixels of room</div> |
| 465 | <div id="probe-short" style="width:200px; height:16px; overflow:hidden; |
| 466 | text-overflow:ellipsis;">three lines of words in a box with room for one of |
| 467 | them, and nothing on screen to say the other two are there</div>`; |
| 468 | document.body.appendChild(box); |
| 469 | }); |
| 470 | const m = await page.evaluate(MEASURE, '#raildlg-probe'); |
| 471 | const flagged = (id) => m.defects.some((f) => f.kind === 'clipped' && f.el.indexOf('#' + id) !== -1); |
| 472 | const proofs = [ |
| 473 | ['a cut with an ellipsis is let through', !flagged('probe-ellipsis')], |
| 474 | ['a cut with no ellipsis is caught', flagged('probe-clip')], |
| 475 | ['lines with no room and no sign of it are caught', flagged('probe-short')], |
| 476 | ]; |
| 477 | for (const [what, good] of proofs) { |
| 478 | console.log(` ${good ? 'ok ' : 'FAIL'} [proof] ${what}`); |
| 479 | if (!good) hard++; |
| 480 | } |
| 481 | await page.evaluate(() => { |
| 482 | const e = document.getElementById('raildlg-probe'); if (e) e.remove(); |
| 483 | }); |
| 484 | } |
| 485 | } finally { |
| 486 | await s.close(); |
| 487 | } |
| 488 | |
| 489 | findings.sort((a, b) => (b.weight - a.weight) || ((b.by || 0) - (a.by || 0))); |
| 490 | console.log(`\n${shots} shots in ${OUT}`); |
| 491 | if (!findings.length) console.log('nothing measured — the images are still the other half. Read them.'); |
| 492 | else { |
| 493 | console.log(`\n${findings.length} finding(s), worst first:\n`); |
| 494 | for (const f of findings.slice(0, 40)) { |
| 495 | console.log(` [${f.kind}] by ${f.by} ${f.cell} ${f.dialog}\n ${f.el}` |
| 496 | + (f.other ? ` ↔ ${f.other}` : '') + (f.note ? `\n children: ${f.note}` : '') |
| 497 | + ` @${f.rect.x},${f.rect.y} ${f.rect.w}x${f.rect.h}`); |
| 498 | } |
| 499 | if (findings.length > 40) console.log(` … and ${findings.length - 40} more, in the json.`); |
| 500 | } |
| 501 | fs.writeFileSync(path.join(OUT, 'raildialogs.json'), JSON.stringify(findings, null, '\t')); |
| 502 | console.log(`\n${hard} always-wrong, ${soft} to look at. ${path.join(OUT, 'raildialogs.json')}`); |
| 503 | process.exit(hard === 0 ? 0 : 1); |