oxedyne/daimond/dev/verify_stage.mjs
26.9 KiB, 1 run
created by r2519314175:691, 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_stage.mjs — the stage seats as many panels as it has room for, and a |
| 2 | // document sits beside its own preview. |
| 3 | // |
| 4 | // Two changes are under test here and they are two halves of one ask: *"currently |
| 5 | // we can have 1-2 panels in the centre area, we need to allow 3 so i can have a pdf |
| 6 | // view and a text file view visible at once … up to 4 side by side panels."* |
| 7 | // |
| 8 | // A third seat alone would not have given him that. The Doc panel was a SINGLETON |
| 9 | // holding one of three renderings — a text view, a PDF and (since the live typeset |
| 10 | // pages were built) a third — with the others `display: none`. So a PDF and a text |
| 11 | // file could not both be on screen at four seats any more than at two: they were |
| 12 | // the same panel. The preview is therefore its own panel, and the file being edited |
| 13 | // is its own panel, and the seating engine can carry both. |
| 14 | // |
| 15 | // WHAT IS ASSERTED IS THE INVARIANT RATHER THAN THE CASE, and the invariant is the |
| 16 | // dock's own, because it is the defect that recurs on any tiling engine: |
| 17 | // |
| 18 | // 1. A SEAT THAT TAKES WIDTH HAS SOMETHING VISIBLE IN IT. Measured with |
| 19 | // `getBoundingClientRect()` and `getClientRects()` and never the engine's own |
| 20 | // bookkeeping: the dock's bug was a column the engine had finished with that |
| 21 | // the browser was still drawing, and everything that read the engine's state |
| 22 | // called the broken layout healthy. |
| 23 | // |
| 24 | // 2. THE SEATS FIT. Their widths and the dividers between them add up to the |
| 25 | // stage and no more, at every width, so nothing is drawn half off the screen. |
| 26 | // |
| 27 | // 3. NARROWING CLOSES THE OVERFLOW; WIDENING DOES NOT RE-ADMIT IT. The asymmetry |
| 28 | // is the design: a window dragged narrow has to give something up, but an app |
| 29 | // that re-opens panels you closed is worse than one that makes you click. |
| 30 | // |
| 31 | // 4. A BOUNDARY THE USER MOVED IS WHERE THEY LEFT IT — after a reload, and after |
| 32 | // a round trip through another tiling — and a drag moves ONE boundary. |
| 33 | // |
| 34 | // 5. AND THE THING HE ASKED FOR. Not "three panels" and not a count of anything: |
| 35 | // the seat showing `notes.md` is beside the seat showing `paper.pdf`, both are |
| 36 | // on screen, and the line-number toggle still belongs to the Markdown file. |
| 37 | // |
| 38 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a |
| 39 | // deliberately damaged copy of a source file to the real page (through |
| 40 | // `page.route`, so the browser loads it as it loads any other script) and the run is |
| 41 | // expected to FAIL. A break that does not apply cleanly aborts rather than passing |
| 42 | // quietly: a check proved against code that was never broken is not proved at all. |
| 43 | // |
| 44 | // node dev/verify_stage.mjs --break stagecap # four seats, the dividers and the |
| 45 | // # author's own case: all gone at a |
| 46 | // # cap of 2 |
| 47 | // node dev/verify_stage.mjs --break nofit # narrowing overflows, and nothing |
| 48 | // # is ever shed |
| 49 | // node dev/verify_stage.mjs --break readmit # narrowing is right; widening puts |
| 50 | // # back what the user shut |
| 51 | // node dev/verify_stage.mjs --break seatshare # a moved boundary is ignored |
| 52 | // node dev/verify_stage.mjs --break seatsave # it holds, and not across a reload |
| 53 | // node dev/verify_stage.mjs --break seatall # the drag moves the right seat by the |
| 54 | // # right amount and robs the wrong one |
| 55 | // node dev/verify_stage.mjs --break onepanel # the preview is the Doc panel again, |
| 56 | // # so the PDF takes the document's place |
| 57 | // node dev/verify_stage.mjs --break docstate # a preview clobbers the document's |
| 58 | // # state and the toggle stands down |
| 59 | // node dev/verify_stage.mjs --break phoneseat # the seating tiles a phone |
| 60 | // node dev/verify_stage.mjs # and then, clean |
| 61 | // |
| 62 | // eval "$(bash dev/world.sh 4 --up)" |
| 63 | // node dev/verify_stage.mjs |
| 64 | // |
| 65 | // Needs dev/serve.mjs only. No gateway, no mock traffic. |
| 66 | import fs from 'node:fs'; |
| 67 | import path from 'node:path'; |
| 68 | import { fileURLToPath } from 'node:url'; |
| 69 | import { open, shot, scratch } from './harness.mjs'; |
| 70 | |
| 71 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 72 | const WWW = path.join(HERE, '..', 'www'); |
| 73 | |
| 74 | const BREAK = (() => { |
| 75 | const i = process.argv.indexOf('--break'); |
| 76 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 77 | })(); |
| 78 | |
| 79 | const PROFILE = scratch('pw', 'stage' + (BREAK ? '-' + BREAK : '')); |
| 80 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 81 | |
| 82 | const ok = [], bad = []; |
| 83 | const check = (name, pass, detail) => { |
| 84 | (pass ? ok : bad).push(name); |
| 85 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 86 | }; |
| 87 | |
| 88 | // ── The breaks ─────────────────────────────────────────────────────── |
| 89 | // Each is a real edit to a real file, served in place of it. `find` must appear |
| 90 | // exactly once: a break that silently matched nothing would leave the run green |
| 91 | // against working code and prove the opposite of what it claims. |
| 92 | const BREAKS = { |
| 93 | // The cap is the constant it used to be. Everything else — the derivation, the |
| 94 | // dividers, the shares — still works; there are simply never more than two. |
| 95 | stagecap: [{ |
| 96 | file: 'js/daimond.js', |
| 97 | find: '\t\tvar STAGE_CAP = 4;', |
| 98 | with: '\t\tvar STAGE_CAP = 2;', |
| 99 | }], |
| 100 | // Nothing is ever closed for want of room. The seats keep their widths and |
| 101 | // overflow the stage, which is what a tiling engine does when it stops asking |
| 102 | // whether what it is drawing fits. |
| 103 | nofit: [{ |
| 104 | file: 'js/daimond.js', |
| 105 | find: '\t\t\tvar max = stageMax(), closed = false;\n\t\t\twhile (stage.length > max) {', |
| 106 | with: '\t\t\tvar max = stageMax(), closed = false;\n\t\t\twhile (false) {', |
| 107 | }], |
| 108 | // The tempting shape, and the one a reasonable person writes: the stage |
| 109 | // "restores what fits" as it widens. Narrowing still looks exactly right — the |
| 110 | // overflow is closed, properly, back to a chip — and the panels the user shut |
| 111 | // come back on their own the moment the window is dragged out again. |
| 112 | readmit: [{ |
| 113 | file: 'js/daimond.js', |
| 114 | find: '\t\t\t\t\tif (fitStage()) apply(); else applySeats();', |
| 115 | with: '\t\t\t\t\tif (fitStage()) { apply(); return; }\n' |
| 116 | + '\t\t\t\t\tvar room = false;\n' |
| 117 | + '\t\t\t\t\tPANELS.forEach(function (q) {\n' |
| 118 | + '\t\t\t\t\t\tif (q.zone !== \'stage\' || stage.indexOf(q.id) !== -1) return;\n' |
| 119 | + '\t\t\t\t\t\tif (!usedPanels()[q.id] || stage.length >= stageMax()) return;\n' |
| 120 | + '\t\t\t\t\t\topen[q.id] = true; stage.push(q.id); room = true;\n' |
| 121 | + '\t\t\t\t\t});\n' |
| 122 | + '\t\t\t\t\tif (room) apply(); else applySeats();', |
| 123 | }], |
| 124 | // A moved boundary is never honoured: every arrangement is an even share. |
| 125 | seatshare: [{ |
| 126 | file: 'js/daimond.js', |
| 127 | find: '\t\t\tvar shares = sharesOf(seats, stage) || stage.map(function () { return 1 / n; });', |
| 128 | with: '\t\t\tvar shares = null || stage.map(function () { return 1 / n; });', |
| 129 | }], |
| 130 | // It is honoured, and never written down. The drag holds until the page is |
| 131 | // reloaded, which is the failure a check that only drags cannot see. |
| 132 | seatsave: [{ |
| 133 | file: 'js/daimond.js', |
| 134 | find: '\t\t\t\t\twidths: widths, split: split, railH: railPin(), seats: seats,', |
| 135 | with: '\t\t\t\t\twidths: widths, split: split, railH: railPin(),', |
| 136 | }], |
| 137 | // The seat under the hand gains exactly what it should, and what it gains is |
| 138 | // taken from the far end of the stage instead of from its neighbour. Sharpened |
| 139 | // deliberately: a break that also got the first number wrong would have failed |
| 140 | // both checks and proved neither, since "40px" and "and from nobody else" are |
| 141 | // different claims. |
| 142 | seatall: [{ |
| 143 | file: 'js/daimond.js', |
| 144 | find: '\t\t\t\tpx[i] += dx; px[i + 1] -= dx;', |
| 145 | with: '\t\t\t\tpx[i] += dx; px[px.length - 1] -= dx;', |
| 146 | }], |
| 147 | // The preview goes back to being a rendering of the Doc panel. The seating |
| 148 | // engine is untouched and four seats still work — which is the point: seats |
| 149 | // alone never gave him a PDF and a text file at once. |
| 150 | onepanel: [{ |
| 151 | file: 'js/daimond.js', |
| 152 | find: "\t\t\tDaimondPanels.markUsed('preview');\n\t\t\tDaimondPanels.show('preview');", |
| 153 | with: "\t\t\tDaimondPanels.markUsed('doc');\n\t\t\tDaimondPanels.show('doc');", |
| 154 | }], |
| 155 | // The preview writes its file into the document's own state, the way it did |
| 156 | // when they were one panel. Both are on screen and the toggle in the Doc |
| 157 | // panel's header has stood down over a file that has lines. |
| 158 | docstate: [{ |
| 159 | file: 'js/daimond.js', |
| 160 | find: '\t\t\tpvFile = path; pvStore = !!store;', |
| 161 | with: '\t\t\tpvFile = path; pvStore = !!store;\n\t\t\tcurFile = path; curContent = null; syncLineNo();', |
| 162 | }], |
| 163 | // The seat arithmetic runs on a phone instead of standing back for the sheet, |
| 164 | // and it cuts the one panel on screen down to a desktop seat's width on a |
| 165 | // 390px screen. Two edits, because the guard that catches a stage with no room |
| 166 | // to divide would otherwise cover for the missing one. |
| 167 | phoneseat: [ |
| 168 | { |
| 169 | file: 'js/daimond.js', |
| 170 | find: '\t\t\tif (isMobile()) {\n\t\t\t\tels.forEach', |
| 171 | with: '\t\t\tif (false) {\n\t\t\t\tels.forEach', |
| 172 | }, |
| 173 | { |
| 174 | file: 'js/daimond.js', |
| 175 | find: '\t\t\tif (room < MIN_W.stage * n) { even(); return; }', |
| 176 | with: '\t\t\tif (false) { even(); return; }', |
| 177 | }, |
| 178 | ], |
| 179 | }; |
| 180 | |
| 181 | if (BREAK && !BREAKS[BREAK]) { |
| 182 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 183 | process.exit(2); |
| 184 | } |
| 185 | |
| 186 | /// The damaged sources, keyed by file, or a hard stop. |
| 187 | /// |
| 188 | /// EVERY EDIT TO ONE FILE IS APPLIED TO ONE COPY. Registering a `page.route` per |
| 189 | /// edit does not work and does not say so: two routes on the same URL leave the |
| 190 | /// last one serving, so a two-edit break silently shipped only its second edit. |
| 191 | /// That is how `--break readmit` came out GREEN — a break that proves nothing is |
| 192 | /// worse than no break at all, because it reads as proof. |
| 193 | function damagedFiles() { |
| 194 | const out = {}; |
| 195 | for (const spec of (BREAKS[BREAK] || [])) { |
| 196 | let src = out[spec.file]; |
| 197 | if (src === undefined) src = fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 198 | const n = src.split(spec.find).length - 1; |
| 199 | if (n !== 1) { |
| 200 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, ` |
| 201 | + 'so nothing was broken and the run below would prove nothing.'); |
| 202 | process.exit(2); |
| 203 | } |
| 204 | out[spec.file] = src.replace(spec.find, spec.with); |
| 205 | } |
| 206 | return out; |
| 207 | } |
| 208 | |
| 209 | async function routes(page) { |
| 210 | if (!BREAK) return; |
| 211 | const files = damagedFiles(); |
| 212 | for (const file of Object.keys(files)) { |
| 213 | const body = files[file]; |
| 214 | await page.route('**/' + file, r => r.fulfill({ |
| 215 | status: 200, contentType: 'application/javascript', body, |
| 216 | })); |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | const s = await open({ name: 'stage', profile: PROFILE, connect: false, route: routes }); |
| 221 | const p = s.page; |
| 222 | await p.waitForTimeout(1200); |
| 223 | |
| 224 | // ── What the stage is actually doing ───────────────────────────────── |
| 225 | // |
| 226 | // Measured, never asked. `getClientRects()` for "is it drawn at all" and |
| 227 | // `getBoundingClientRect()` for "how much room is it taking", because the whole |
| 228 | // class of bug here is a seat the engine has finished with that the browser is |
| 229 | // still painting — and every reading of the engine's own state would call it |
| 230 | // healthy. `DaimondPanels.isOpen` appears in exactly one check below, and there it |
| 231 | // is the thing being tested rather than the instrument. |
| 232 | const geom = () => p.evaluate(() => { |
| 233 | const st = document.getElementById('stage'); |
| 234 | const box = st.getBoundingClientRect(); |
| 235 | const kids = [...st.children].map((k) => { |
| 236 | const r = k.getBoundingClientRect(); |
| 237 | return { |
| 238 | panel: k.dataset.panel || null, |
| 239 | handle: k.classList.contains('phandle'), |
| 240 | drawn: k.getClientRects().length > 0 && r.width > 0, |
| 241 | w: Math.round(r.width), |
| 242 | left: Math.round(r.left), |
| 243 | right: Math.round(r.right), |
| 244 | // What is INSIDE it that the browser is drawing. A panel whose every |
| 245 | // child is display:none is a seat with nothing in it however wide the |
| 246 | // panel's own box happens to be. |
| 247 | shows: [...k.querySelectorAll(':scope > *')] |
| 248 | .filter((c) => c.getClientRects().length > 0).length, |
| 249 | }; |
| 250 | }); |
| 251 | return { |
| 252 | width: Math.round(box.width), |
| 253 | left: Math.round(box.left), |
| 254 | right: Math.round(box.right), |
| 255 | seats: kids.filter((k) => k.drawn && !k.handle), |
| 256 | hands: kids.filter((k) => k.drawn && k.handle), |
| 257 | all: kids, |
| 258 | }; |
| 259 | }); |
| 260 | |
| 261 | /// The complaint about a stage, or null when it is sound. |
| 262 | /// |
| 263 | /// Three things at once, because they fail in different ways: a seat drawing |
| 264 | /// nothing, a seat narrower than a seat is allowed to be, and seats that together |
| 265 | /// take more room than there is. |
| 266 | const FLOOR = 380; |
| 267 | const HANDLE = 10; |
| 268 | /// The width below which a panel is not a panel at all, whatever the stage is |
| 269 | /// trying to fit. Measured, in `dev/measure_seat.mjs`: at 180px the Web panel's |
| 270 | /// close button sits OUTSIDE the panel it closes, and at 205px it is back inside. |
| 271 | /// This is the line the squeeze may never cross, where 380 is the line it may not |
| 272 | /// cross when there is room to stay the right side of it. |
| 273 | const NEVER = 205; |
| 274 | const faults = (g) => { |
| 275 | const out = []; |
| 276 | const n = g.seats.length; |
| 277 | // Whether the stage could actually afford everyone a readable seat. Two seats |
| 278 | // are the app's premise rather than a width — you should never have to leave |
| 279 | // the conversation to do a thing — so below the room for two they are squeezed |
| 280 | // evenly instead of one being shut. The floor is asserted where it is |
| 281 | // affordable, and an equal share where it is not; both are real claims, and |
| 282 | // asserting the floor everywhere would have been asserting the wrong one. |
| 283 | const affords = g.width >= n * FLOOR + (n - 1) * HANDLE; |
| 284 | g.seats.forEach((k) => { |
| 285 | if (k.shows === 0) out.push(`${k.panel} is ${k.w}px wide with nothing in it`); |
| 286 | if (n > 1 && affords && k.w < FLOOR - 1) { |
| 287 | out.push(`${k.panel} is ${k.w}px in a stage with room for ${FLOOR}px each`); |
| 288 | } |
| 289 | if (k.w < NEVER) out.push(`${k.panel} is ${k.w}px, under the ${NEVER}px a panel needs to be one`); |
| 290 | if (k.left < g.left - 1 || k.right > g.right + 1) { |
| 291 | out.push(`${k.panel} sits ${k.left}..${k.right} outside the stage's ${g.left}..${g.right}`); |
| 292 | } |
| 293 | }); |
| 294 | if (n > 1 && !affords) { |
| 295 | const lo = Math.min(...g.seats.map((k) => k.w)), hi = Math.max(...g.seats.map((k) => k.w)); |
| 296 | if (hi - lo > 4) out.push(`squeezed unevenly: ${g.seats.map((k) => k.w).join(' + ')}`); |
| 297 | } |
| 298 | const spent = [...g.seats, ...g.hands].reduce((a, k) => a + k.w, 0); |
| 299 | if (spent > g.width + 2) out.push(`${spent}px of seats in a ${g.width}px stage`); |
| 300 | return out.length ? out.join('; ') : null; |
| 301 | }; |
| 302 | |
| 303 | /// Ask for a set of stage panels, in order. |
| 304 | const wants = async (ids) => { |
| 305 | await p.evaluate((list) => { list.forEach((x) => DaimondPanels.show(x)); }, ids); |
| 306 | await p.waitForTimeout(600); |
| 307 | }; |
| 308 | |
| 309 | const at = async (w, h) => { |
| 310 | await p.setViewportSize({ width: w, height: h || 950 }); |
| 311 | await p.waitForTimeout(700); |
| 312 | }; |
| 313 | |
| 314 | // ── 1. Four seats where there is room for four ─────────────────────── |
| 315 | await at(2400); |
| 316 | await wants(['web', 'doc', 'msg']); |
| 317 | { |
| 318 | const g = await geom(); |
| 319 | check('a wide stage seats four panels side by side', |
| 320 | g.seats.length === 4, |
| 321 | g.seats.map((k) => `${k.panel}:${k.w}`).join(' ')); |
| 322 | check('and every seat is a seat: something is drawn in it, at a width it can be read at', |
| 323 | faults(g) === null, faults(g) || g.seats.map((k) => `${k.panel}:${k.w}`).join(' ')); |
| 324 | // A divider between each adjacent pair and nowhere else. This is the dock's |
| 325 | // "a divider exists exactly where there is a boundary to move", turned sideways. |
| 326 | check('with a divider between each adjacent pair, and none left over', |
| 327 | g.hands.length === g.seats.length - 1, |
| 328 | `${g.hands.length} divider(s) for ${g.seats.length} seats`); |
| 329 | // Interleaved, not clustered: p h p h p h p. |
| 330 | const order = g.all.filter((k) => k.drawn).map((k) => (k.handle ? 'h' : 'p')).join(''); |
| 331 | check('and they sit BETWEEN the seats rather than beside them', |
| 332 | order === 'phphphp', order); |
| 333 | } |
| 334 | await shot(s, 'stage-four' + (BREAK ? '-' + BREAK : '')); |
| 335 | |
| 336 | // ── 2. Narrowing closes the overflow rather than overflowing ───────── |
| 337 | // |
| 338 | // The ladder matters: the fault is not "it broke at one width" but "at some width |
| 339 | // it drew outside itself", so every rung is asked the same three questions. |
| 340 | { |
| 341 | let worst = null; |
| 342 | for (const w of [2400, 2000, 1800, 1600, 1400, 1200, 1000, 860]) { |
| 343 | await at(w); |
| 344 | const g = await geom(); |
| 345 | const f = faults(g); |
| 346 | if (f && !worst) worst = `at ${w}px: ${f}`; |
| 347 | } |
| 348 | check('NARROWING NEVER OVERFLOWS THE STAGE — it closes what will not fit', |
| 349 | worst === null, worst || 'sound at every width from 2400 down to 860'); |
| 350 | } |
| 351 | |
| 352 | // ── 3. And widening does not put them back ─────────────────────────── |
| 353 | { |
| 354 | await at(1400); |
| 355 | const narrow = await geom(); |
| 356 | await at(2400); |
| 357 | const wide = await geom(); |
| 358 | check('a narrowed stage really did shed seats', |
| 359 | narrow.seats.length < 4, `${narrow.seats.length} seat(s) at 1400px`); |
| 360 | check('WIDENING DOES NOT RE-ADMIT WHAT THE NARROWING CLOSED', |
| 361 | wide.seats.length === narrow.seats.length, |
| 362 | `${narrow.seats.length} seat(s) narrow, ${wide.seats.length} wide`); |
| 363 | // The one place the engine's own bookkeeping is read, because "it went back to |
| 364 | // being a chip" is a claim about that bookkeeping and not about pixels. |
| 365 | const shut = await p.evaluate(() => DaimondPanels.model().panels |
| 366 | .filter((x) => x.zone === 'stage' && !x.open).map((x) => x.id)); |
| 367 | check('and what it closed came back as a chip rather than vanishing', |
| 368 | shut.length > 0, shut.join(' ') || 'nothing is closed'); |
| 369 | } |
| 370 | |
| 371 | // ── 4. A boundary the user moved ───────────────────────────────────── |
| 372 | // |
| 373 | // Dispatched pointer events rather than `page.mouse`, for the reason |
| 374 | // `verify_docking.mjs` sets out at length: headless Chromium drops the moves after |
| 375 | // a mousedown on one of these handles often enough that a real-mouse drag is not a |
| 376 | // usable oracle. Capture is the one thing a dispatched event cannot have, so it is |
| 377 | // stubbed for the length of the drag; the arithmetic under test is untouched. |
| 378 | const dragSeat = (i, dx) => p.evaluate(({ i, by }) => { |
| 379 | const h = document.getElementById(i ? 'handle-stage-' + i : 'handle-stage'); |
| 380 | if (!h) return false; |
| 381 | const r = h.getBoundingClientRect(); |
| 382 | const x = Math.round(r.left + r.width / 2), y = Math.round(r.top + r.height / 2); |
| 383 | const cap = Element.prototype.setPointerCapture, rel = Element.prototype.releasePointerCapture; |
| 384 | Element.prototype.setPointerCapture = function () {}; |
| 385 | Element.prototype.releasePointerCapture = function () {}; |
| 386 | const fire = (type, cx) => h.dispatchEvent(new PointerEvent(type, { |
| 387 | bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, clientX: cx, clientY: y, |
| 388 | })); |
| 389 | fire('pointerdown', x); |
| 390 | for (let k = 1; k <= 10; k++) fire('pointermove', x + Math.round(by * k / 10)); |
| 391 | fire('pointerup', x + by); |
| 392 | Element.prototype.setPointerCapture = cap; |
| 393 | Element.prototype.releasePointerCapture = rel; |
| 394 | return true; |
| 395 | }, { i, by: dx }); |
| 396 | |
| 397 | const widthsOf = async () => (await geom()).seats.map((k) => k.w); |
| 398 | |
| 399 | await at(2400); |
| 400 | await wants(['web', 'doc', 'msg']); |
| 401 | { |
| 402 | const before = await widthsOf(); |
| 403 | // 40px, and the size is chosen rather than round: the seat on the right is |
| 404 | // 427px and may not go under the 380px floor, so a 120px drag would move 47px |
| 405 | // and the check would be asserting the clamp instead of the drag. |
| 406 | await dragSeat(1, 40); // the boundary between the 2nd and 3rd seats |
| 407 | await p.waitForTimeout(400); |
| 408 | const after = await widthsOf(); |
| 409 | check('a divider dragged 40px gives its left-hand seat 40px', |
| 410 | Math.abs((after[1] - before[1]) - 40) <= 6, |
| 411 | `${before.join(' + ')} then ${after.join(' + ')}`); |
| 412 | check('and takes it from the seat on its right, and from nobody else', |
| 413 | Math.abs((after[2] - before[2]) + 40) <= 6 |
| 414 | && Math.abs(after[0] - before[0]) <= 2 && Math.abs(after[3] - before[3]) <= 2, |
| 415 | `${before.join(' + ')} then ${after.join(' + ')}`); |
| 416 | |
| 417 | // A round trip through another tiling: a seat leaves, and the boundary the user |
| 418 | // moved is waiting when it comes back. This is the dock's own test of its stacks, |
| 419 | // and it is why the shares are keyed by OCCUPANCY rather than by seat number. |
| 420 | const tuned = await widthsOf(); |
| 421 | await p.evaluate(() => DaimondPanels.hide('msg')); |
| 422 | await p.waitForTimeout(500); |
| 423 | await p.evaluate(() => DaimondPanels.show('msg')); |
| 424 | await p.waitForTimeout(600); |
| 425 | const back = await widthsOf(); |
| 426 | check('a boundary survives a seat leaving and coming back', |
| 427 | back.length === tuned.length && back.every((w, i) => Math.abs(w - tuned[i]) <= 3), |
| 428 | `${tuned.join(' + ')} then ${back.join(' + ')}`); |
| 429 | |
| 430 | await shot(s, 'stage-dragged' + (BREAK ? '-' + BREAK : '')); |
| 431 | |
| 432 | // And across a reload, which is the half a drag-only check cannot see. |
| 433 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 434 | await p.waitForTimeout(500); |
| 435 | const { signInAs } = await import('./harness.mjs'); |
| 436 | await signInAs(s, 'stage'); |
| 437 | await p.waitForTimeout(1200); |
| 438 | const reloaded = await widthsOf(); |
| 439 | check('AND IT IS STILL THERE AFTER A RELOAD', |
| 440 | reloaded.length === tuned.length && reloaded.every((w, i) => Math.abs(w - tuned[i]) <= 6), |
| 441 | `${tuned.join(' + ')} then ${reloaded.join(' + ')}`); |
| 442 | } |
| 443 | |
| 444 | // ── 5. The thing he actually asked for ─────────────────────────────── |
| 445 | // |
| 446 | // A PDF and a text file on screen at the same time. Asserted by MEANING and never |
| 447 | // by a count: the seat holding notes.md is beside the seat holding paper.pdf, both |
| 448 | // are drawing, and neither has taken the other's place. |
| 449 | await at(2400); |
| 450 | { |
| 451 | await p.evaluate(async () => { |
| 452 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 453 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 454 | await app.run_tool('file_write', JSON.stringify({ |
| 455 | path: 'notes.md', |
| 456 | content: '# Notes\n\nThe document, in the panel that edits it.\n', |
| 457 | })); |
| 458 | // A small but genuinely openable PDF, so the browser's own viewer draws it. |
| 459 | const pdf = '%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n' |
| 460 | + '2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' |
| 461 | + '3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n' |
| 462 | + 'trailer<</Root 1 0 R>>\n%%EOF\n'; |
| 463 | await app.write_bytes('paper.pdf', new Uint8Array([...pdf].map((c) => c.charCodeAt(0)))); |
| 464 | }); |
| 465 | // Opened the way the user opens them: through the one door that decides which |
| 466 | // panel a file belongs in. |
| 467 | await p.evaluate(() => window.DaimondDoc.show('notes.md')); |
| 468 | await p.waitForTimeout(1000); |
| 469 | await p.evaluate(() => window.DaimondDoc.show('paper.pdf')); |
| 470 | await p.waitForTimeout(1600); |
| 471 | |
| 472 | const both = await p.evaluate(() => { |
| 473 | const rect = (el) => { |
| 474 | if (!el) return null; |
| 475 | const r = el.getBoundingClientRect(); |
| 476 | return { w: Math.round(r.width), left: Math.round(r.left), right: Math.round(r.right), |
| 477 | drawn: el.getClientRects().length > 0 && r.width > 0 }; |
| 478 | }; |
| 479 | const doc = document.getElementById('panel-doc'); |
| 480 | const pv = document.getElementById('panel-preview'); |
| 481 | const body = document.querySelector('#doc-view .files-view-body'); |
| 482 | const emb = document.querySelector('#pv-view .fileview embed'); |
| 483 | return { |
| 484 | doc: rect(doc), |
| 485 | pv: rect(pv), |
| 486 | docText: body ? body.textContent.slice(0, 40) : null, |
| 487 | docName: (document.getElementById('doc-name') || {}).textContent || '', |
| 488 | pvName: (document.getElementById('pv-name') || {}).textContent || '', |
| 489 | pdfDrawn: emb ? emb.getClientRects().length > 0 && emb.getBoundingClientRect().width > 0 : false, |
| 490 | // The toggle belongs to the file in the editor, and a preview opened |
| 491 | // beside it must not have taken it away. |
| 492 | lineno: (document.getElementById('doc-lineno') || {}).style.display, |
| 493 | }; |
| 494 | }); |
| 495 | check('THE SEAT SHOWING notes.md IS BESIDE THE SEAT SHOWING paper.pdf, AND BOTH ARE ON SCREEN', |
| 496 | !!(both.doc && both.pv && both.doc.drawn && both.pv.drawn) |
| 497 | && /Notes/.test(both.docText || '') |
| 498 | && both.pvName === 'paper.pdf' && both.docName === 'notes.md', |
| 499 | JSON.stringify({ doc: both.doc && both.doc.w, pv: both.pv && both.pv.w, |
| 500 | docName: both.docName, pvName: both.pvName })); |
| 501 | check('and the PDF is really drawn in its own seat, not merely present in the DOM', |
| 502 | both.pdfDrawn && both.pv && both.pv.w >= FLOOR - 1, |
| 503 | `${both.pv ? both.pv.w : 0}px, embed drawn: ${both.pdfDrawn}`); |
| 504 | check('and they are two seats rather than one panel taking turns', |
| 505 | !!(both.doc && both.pv) && (both.doc.right <= both.pv.left + 1 |
| 506 | || both.pv.right <= both.doc.left + 1), |
| 507 | both.doc && both.pv ? `doc ${both.doc.left}..${both.doc.right}, ` |
| 508 | + `preview ${both.pv.left}..${both.pv.right}` : 'a panel is missing'); |
| 509 | check('and the line-number toggle still belongs to the Markdown file beside it', |
| 510 | both.lineno !== 'none', `display: ${JSON.stringify(both.lineno)}`); |
| 511 | const g = await geom(); |
| 512 | check('and the stage is still sound with them both in it', |
| 513 | faults(g) === null, faults(g) || g.seats.map((k) => `${k.panel}:${k.w}`).join(' ')); |
| 514 | } |
| 515 | await shot(s, 'stage-doc-and-preview' + (BREAK ? '-' + BREAK : '')); |
| 516 | |
| 517 | // ── 6. A phone draws one panel, whatever the stage is holding ──────── |
| 518 | // |
| 519 | // Below 760px `#stage` is `display: contents` and the phone shell decides what is |
| 520 | // on screen: the chat is the floor and a guest rises over it as a sheet. So what |
| 521 | // is asserted here is that the SEATING DOES NOT REACH IN — one panel drawn, no |
| 522 | // dividers, and the panel taking the width of the screen rather than the width of |
| 523 | // a desktop seat. |
| 524 | { |
| 525 | await at(390, 780); |
| 526 | const g = await p.evaluate(() => { |
| 527 | const drawn = [...document.querySelectorAll('.panel[data-zone="stage"]')] |
| 528 | .filter((k) => k.getClientRects().length > 0) |
| 529 | .map((k) => ({ panel: k.dataset.panel, w: Math.round(k.getBoundingClientRect().width), |
| 530 | inline: k.style.width })); |
| 531 | const hands = [...document.querySelectorAll('.phandle')] |
| 532 | .filter((k) => k.getClientRects().length > 0).length; |
| 533 | return { drawn, hands, screen: window.innerWidth }; |
| 534 | }); |
| 535 | check('A PHONE DRAWS ONE STAGE PANEL, and the sheet carries the rest', |
| 536 | g.drawn.length === 1 && g.hands === 0, |
| 537 | `${g.drawn.length} panel(s): ${g.drawn.map((k) => k.panel).join(' ')}, ${g.hands} divider(s)`); |
| 538 | // No inline width, rather than a width equal to the screen: the phone's own |
| 539 | // stylesheet decides how wide a panel is there, and what is being asserted is |
| 540 | // that the seating left nothing behind for it to fight. A 427px desktop seat |
| 541 | // still written on the one panel a 390px screen has is the engine reaching into |
| 542 | // a layout that is not its own. |
| 543 | check('and the seating leaves no desktop width behind on it', |
| 544 | g.drawn.length === 1 && !g.drawn[0].inline, |
| 545 | g.drawn.length ? `${g.drawn[0].w}px of ${g.screen}px` |
| 546 | + (g.drawn[0].inline ? `, inline width ${g.drawn[0].inline}` : ', no inline width') |
| 547 | : 'nothing drawn'); |
| 548 | } |
| 549 | await at(1500); |
| 550 | |
| 551 | // The gateway is not part of this world: nothing here is bought, sent or synced, |
| 552 | // so its refusals are noise from a service the stage knows nothing about. |
| 553 | const errs = s.errs.filter((e) => !/\b(401|402|502)\b|Bad Gateway|Unauthorized|Payment Required|net::ERR/.test(e)); |
| 554 | check('no unexpected console errors', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 555 | |
| 556 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 557 | if (bad.length) console.log(bad.map((b) => ' - ' + b).join('\n')); |
| 558 | await s.close(); |
| 559 | process.exit(bad.length ? 1 : 0); |