oxedyne/daimond/dev/verify_docking.mjs
23.6 KiB, 1 run
created by r2519314175:373, 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_docking.mjs — the dock survives a change of tiling and a change back. |
| 2 | // |
| 3 | // The dock is one width (`widths.dock`) times however many columns the grid |
| 4 | // asks for, and those columns then divide that width between themselves. So |
| 5 | // the two numbers have to agree: if the engine sizes the dock for ONE column |
| 6 | // while TWO are still drawing, every panel comes back at half width. |
| 7 | // |
| 8 | // That is what was reported from the live app: Auto → 2 by 2 → Auto left the |
| 9 | // dock a single narrow column, the panels crushed into the left half of it and |
| 10 | // dead space beside them. The cause was that closing a panel does not move its |
| 11 | // element — it only hides it — so the column it was seated in still held a |
| 12 | // node, `.pcol:empty` never fired, and a column nobody could see kept its |
| 13 | // share of the dock. |
| 14 | // |
| 15 | // What is asserted is the invariant rather than the case: A COLUMN THAT TAKES |
| 16 | // WIDTH HAS SOMETHING VISIBLE IN IT, and going back to a tiling returns the |
| 17 | // dock to exactly what that tiling gave before it was left. |
| 18 | // |
| 19 | // The second half of the file is the height the columns divide between the |
| 20 | // panels stacked in them, on the same terms: A DIVIDER EXISTS EXACTLY WHERE |
| 21 | // THERE IS A BOUNDARY TO MOVE, and one the user has moved is still where they |
| 22 | // left it after a reload and after a round trip through another tiling. |
| 23 | // |
| 24 | // node dev/verify_docking.mjs |
| 25 | // |
| 26 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway. |
| 27 | |
| 28 | import { open, signInAs, shot, errors } from './harness.mjs'; |
| 29 | |
| 30 | const s = await open({ name: 'docking', connect: false }); |
| 31 | const p = s.page; |
| 32 | await p.waitForTimeout(1500); |
| 33 | |
| 34 | const ok = [], bad = []; |
| 35 | const check = (name, pass, detail) => { |
| 36 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 37 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 38 | }; |
| 39 | |
| 40 | /// What the dock is holding and what its columns are doing. |
| 41 | /// |
| 42 | /// Measured, not intended: `getClientRects()` and `getBoundingClientRect()`, |
| 43 | /// because the whole defect was a column the engine had finished with that the |
| 44 | /// browser was still drawing. Anything reading the engine's own bookkeeping |
| 45 | /// would have called the broken layout healthy. |
| 46 | const geom = () => p.evaluate(() => { |
| 47 | const d = document.getElementById('dock'); |
| 48 | const cols = [...d.querySelectorAll('.pcol')].map((c) => ({ |
| 49 | id: c.id, |
| 50 | w: Math.round(c.getBoundingClientRect().width), |
| 51 | shows: [...c.children].filter((k) => k.getClientRects().length).map((k) => k.dataset.panel), |
| 52 | })); |
| 53 | return { |
| 54 | grid: DaimondPanels.grid(), |
| 55 | width: Math.round(d.getBoundingClientRect().width), |
| 56 | drawn: cols.filter((c) => c.w > 0), |
| 57 | open: DaimondPanels.panels().filter((x) => x.zone === 'dock') |
| 58 | .filter((x) => DaimondPanels.isOpen(x.id)).map((x) => x.id), |
| 59 | }; |
| 60 | }); |
| 61 | |
| 62 | /// The complaint, or null when every column that takes width earns it. |
| 63 | const dead = (g) => { |
| 64 | const empty = g.drawn.filter((c) => c.shows.length === 0); |
| 65 | if (empty.length) { |
| 66 | return empty.map((c) => `${c.id} is ${c.w}px wide with nothing in it`).join('; '); |
| 67 | } |
| 68 | return null; |
| 69 | }; |
| 70 | |
| 71 | /// Put the dock in a known state: exactly these panels open, on this grid. |
| 72 | const dock = async (ids, grid) => { |
| 73 | await p.evaluate(({ ids, grid }) => { |
| 74 | DaimondPanels.setGrid(grid); |
| 75 | DaimondPanels.panels().filter((x) => x.zone === 'dock').forEach((x) => { |
| 76 | if (ids.indexOf(x.id) === -1) DaimondPanels.hide(x.id); |
| 77 | }); |
| 78 | ids.forEach((id) => DaimondPanels.show(id)); |
| 79 | }, { ids, grid }); |
| 80 | await p.waitForTimeout(400); |
| 81 | }; |
| 82 | |
| 83 | // ── The baseline: two panels docked, on Auto, having never left it ────── |
| 84 | await dock(['work', 'mail'], 'auto'); |
| 85 | const base = await geom(); |
| 86 | check('Auto seats two dock panels in one column', base.drawn.length === 1, |
| 87 | base.drawn.map((c) => `${c.id} ${c.w}px [${c.shows}]`).join(', ')); |
| 88 | check('and that column is the whole dock', base.drawn[0] && base.drawn[0].w === base.width, |
| 89 | `column ${base.drawn[0] ? base.drawn[0].w : '-'}px of a ${base.width}px dock`); |
| 90 | check('the baseline itself is honest', dead(base) === null, dead(base)); |
| 91 | |
| 92 | // ── The reported round trip, panel for panel ──────────────────────────── |
| 93 | // Auto → a tiling → Auto, with a panel closed while away, which is what leaves |
| 94 | // a column holding a node nobody can see. Every tiling with more than one |
| 95 | // column can do it, so every one of them is walked. |
| 96 | for (const g of ['2x2', '2x3', '3x2']) { |
| 97 | await p.evaluate((grid) => { |
| 98 | DaimondPanels.show('spend'); // three panels, so the tiling spreads them |
| 99 | DaimondPanels.setGrid(grid); |
| 100 | }, g); |
| 101 | await p.waitForTimeout(400); |
| 102 | const spread = await geom(); |
| 103 | check(`${g} spreads the dock over more than one column`, spread.drawn.length > 1, |
| 104 | spread.drawn.map((c) => `${c.id} ${c.w}px [${c.shows}]`).join(', ')); |
| 105 | |
| 106 | await p.evaluate(() => DaimondPanels.hide('spend')); |
| 107 | await p.waitForTimeout(350); |
| 108 | await p.evaluate(() => DaimondPanels.setGrid('auto')); |
| 109 | await p.waitForTimeout(400); |
| 110 | |
| 111 | const back = await geom(); |
| 112 | const complaint = dead(back); |
| 113 | check(`${g} → Auto leaves no column drawing on nothing`, complaint === null, complaint); |
| 114 | check(`${g} → Auto comes back to one column`, back.drawn.length === 1, |
| 115 | back.drawn.map((c) => `${c.id} ${c.w}px [${c.shows}]`).join(', ')); |
| 116 | check(`${g} → Auto comes back at the width Auto had`, |
| 117 | back.drawn[0] && back.drawn[0].w === base.drawn[0].w, |
| 118 | `${base.drawn[0].w}px before, ${back.drawn[0] ? back.drawn[0].w : '-'}px after`); |
| 119 | if (complaint || back.drawn.length !== 1) await shot(s, 'docking-' + g); |
| 120 | } |
| 121 | |
| 122 | // ── And without closing anything, which is how it was reported ────────── |
| 123 | { |
| 124 | await dock(['work', 'spend', 'mail'], 'auto'); |
| 125 | const before = await geom(); |
| 126 | await p.evaluate(() => DaimondPanels.setGrid('2x2')); |
| 127 | await p.waitForTimeout(400); |
| 128 | await p.evaluate(() => DaimondPanels.setGrid('auto')); |
| 129 | await p.waitForTimeout(400); |
| 130 | const after = await geom(); |
| 131 | check('three panels, Auto → 2x2 → Auto, is a round trip', |
| 132 | after.width === before.width && after.drawn.length === before.drawn.length |
| 133 | && dead(after) === null, |
| 134 | `${before.width}px/${before.drawn.length} col then ${after.width}px/${after.drawn.length} col` |
| 135 | + (dead(after) ? ' — ' + dead(after) : '')); |
| 136 | } |
| 137 | |
| 138 | // ── The one-column tiling is not the auto one ─────────────────────────── |
| 139 | // `1` is a chosen tiling and `auto` is a resolved one; they can agree on the |
| 140 | // count and still have to leave the dock in the same state. |
| 141 | { |
| 142 | await dock(['work', 'spend', 'mail'], '2x2'); |
| 143 | await p.evaluate(() => DaimondPanels.hide('spend')); |
| 144 | await p.waitForTimeout(350); |
| 145 | await p.evaluate(() => DaimondPanels.setGrid('1')); |
| 146 | await p.waitForTimeout(400); |
| 147 | const one = await geom(); |
| 148 | check('2x2 → one column leaves no column drawing on nothing', dead(one) === null, dead(one)); |
| 149 | check('and the one column is the whole dock', |
| 150 | one.drawn.length === 1 && one.drawn[0].w === one.width, |
| 151 | `${one.drawn.length} columns, ${one.drawn[0] ? one.drawn[0].w : '-'}px of ${one.width}px`); |
| 152 | } |
| 153 | |
| 154 | // ── A wide window, where Auto is itself two columns ───────────────────── |
| 155 | { |
| 156 | await p.setViewportSize({ width: 2000, height: 950 }); |
| 157 | await dock(['work', 'spend', 'mail'], 'auto'); |
| 158 | const wide = await geom(); |
| 159 | check('above the two-column mark Auto takes two columns', wide.drawn.length === 2, |
| 160 | wide.drawn.map((c) => `${c.id} ${c.w}px`).join(', ')); |
| 161 | await p.evaluate(() => DaimondPanels.setGrid('3x2')); |
| 162 | await p.waitForTimeout(400); |
| 163 | await p.evaluate(() => DaimondPanels.hide('spend')); |
| 164 | await p.waitForTimeout(350); |
| 165 | await p.evaluate(() => DaimondPanels.setGrid('auto')); |
| 166 | await p.waitForTimeout(400); |
| 167 | const back = await geom(); |
| 168 | check('3x2 → Auto on a wide window leaves no dead column', dead(back) === null, dead(back)); |
| 169 | check('and the columns still share the whole dock', |
| 170 | back.drawn.reduce((a, c) => a + c.w, 0) >= back.width - 16, |
| 171 | `${back.drawn.map((c) => c.w).join(' + ')} of ${back.width}px`); |
| 172 | await p.setViewportSize({ width: 1500, height: 950 }); |
| 173 | await p.evaluate(() => DaimondPanels.reflow()); |
| 174 | await p.waitForTimeout(400); |
| 175 | } |
| 176 | |
| 177 | // ── What the saved layout brings back ─────────────────────────────────── |
| 178 | // The DOM is rebuilt from the markup on a reload, so a live-only fault would |
| 179 | // heal itself and the user would never see it twice. This says so explicitly, |
| 180 | // and would catch the day the columns start being written to the layout. |
| 181 | { |
| 182 | await dock(['work', 'spend', 'mail'], '2x2'); |
| 183 | await p.evaluate(() => DaimondPanels.hide('spend')); |
| 184 | await p.waitForTimeout(300); |
| 185 | await p.evaluate(() => DaimondPanels.setGrid('auto')); |
| 186 | await p.waitForTimeout(400); |
| 187 | const live = await geom(); |
| 188 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 189 | await signInAs(s, 'docking'); |
| 190 | await p.waitForTimeout(2500); |
| 191 | const fresh = await geom(); |
| 192 | check('the tiling outlives the reload', fresh.grid === 'auto', fresh.grid); |
| 193 | check('and the dock comes up honest', dead(fresh) === null, dead(fresh)); |
| 194 | check('at the same width it was left at', fresh.width === live.width, |
| 195 | `${live.width}px then ${fresh.width}px`); |
| 196 | if (dead(live)) await shot(s, 'docking-reload'); |
| 197 | } |
| 198 | |
| 199 | // ── The handle moves the edge the pointer is holding ──────────────────── |
| 200 | // `widths.dock` is ONE column's width and the dock is that times the columns it |
| 201 | // draws, so a drag added to it whole moved the edge at two or three times the |
| 202 | // speed of the hand. |
| 203 | // |
| 204 | // Driven by dispatched pointer events rather than `page.mouse`: Chromium |
| 205 | // headless drops the moves after a mousedown on this particular handle often |
| 206 | // enough that a real-mouse drag is not a usable oracle here (the rail's |
| 207 | // identical handle drags fine, so it is the harness, not the app). Pointer |
| 208 | // CAPTURE is the one thing a dispatched event cannot have, so it is stubbed for |
| 209 | // the length of the drag -- the arithmetic under test is untouched. |
| 210 | const dragDock = (dx) => p.evaluate((by) => { |
| 211 | const h = document.getElementById('handle-dock'); |
| 212 | const r = h.getBoundingClientRect(); |
| 213 | const x = Math.round(r.left + r.width / 2), y = Math.round(r.top + r.height / 2); |
| 214 | const cap = Element.prototype.setPointerCapture, rel = Element.prototype.releasePointerCapture; |
| 215 | Element.prototype.setPointerCapture = function () {}; |
| 216 | Element.prototype.releasePointerCapture = function () {}; |
| 217 | const fire = (type, cx) => h.dispatchEvent(new PointerEvent(type, { |
| 218 | bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, clientX: cx, clientY: y, |
| 219 | })); |
| 220 | fire('pointerdown', x); |
| 221 | for (let i = 1; i <= 10; i++) fire('pointermove', x - Math.round(by * i / 10)); |
| 222 | fire('pointerup', x - by); |
| 223 | Element.prototype.setPointerCapture = cap; |
| 224 | Element.prototype.releasePointerCapture = rel; |
| 225 | }, dx); |
| 226 | { |
| 227 | for (const [grid, cols] of [['auto', 1], ['2x2', 2], ['3x2', 3]]) { |
| 228 | await dock(['work', 'spend', 'mail'], grid); |
| 229 | const w0 = (await geom()).width; |
| 230 | await dragDock(100); |
| 231 | await p.waitForTimeout(250); |
| 232 | const w1 = (await geom()).width; |
| 233 | check(`on ${grid} (${cols} column${cols > 1 ? 's' : ''}) a 100px drag widens the dock by 100px`, |
| 234 | Math.abs((w1 - w0) - 100) <= 4, `${w0}px then ${w1}px`); |
| 235 | await p.evaluate(() => DaimondPanels.reflow()); |
| 236 | } |
| 237 | // Put the width back, so nothing downstream inherits a dragged dock. |
| 238 | await p.evaluate(() => { |
| 239 | document.getElementById('handle-dock').dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); |
| 240 | DaimondPanels.setGrid('auto'); |
| 241 | }); |
| 242 | await p.waitForTimeout(300); |
| 243 | } |
| 244 | |
| 245 | // ── Panels stacked in a column divide its height ──────────────────────── |
| 246 | // |
| 247 | // Two panels in one column used to take half of it each and that was that. A |
| 248 | // divider now sits between every adjacent pair, and what it is asked to prove |
| 249 | // is the same kind of invariant as above: A DIVIDER EXISTS EXACTLY WHERE THERE |
| 250 | // IS A BOUNDARY TO MOVE, and a boundary the user has moved is still where they |
| 251 | // left it after a reload and after a round trip through another tiling. |
| 252 | // |
| 253 | // The share is kept against the column's OCCUPANCY -- `work|mail` -- so the |
| 254 | // same two panels re-stacking anywhere find their own tuning, and a third |
| 255 | // joining them is a different stack and therefore an even one. |
| 256 | |
| 257 | /// Exactly this set of dock panels, in this order, on this grid. |
| 258 | /// |
| 259 | /// `dock()` above leaves the ORDER of anything already open alone, which is |
| 260 | /// right for what it tests and no use here: which panel is above which decides |
| 261 | /// what a divider is a boundary between. |
| 262 | const only = async (ids, grid) => { |
| 263 | await p.evaluate(({ ids, grid }) => { |
| 264 | DaimondPanels.setGrid(grid); |
| 265 | DaimondPanels.panels().filter((x) => x.zone === 'dock').forEach((x) => DaimondPanels.hide(x.id)); |
| 266 | ids.forEach((id) => DaimondPanels.show(id)); |
| 267 | }, { ids, grid }); |
| 268 | await p.waitForTimeout(400); |
| 269 | }; |
| 270 | |
| 271 | /// What each drawing column is stacking, and every divider in the dock. |
| 272 | /// |
| 273 | /// `loose` is the orphan count: a handle whose parent is not a column that |
| 274 | /// draws. The surplus-column sweep empties a retired column by moving its |
| 275 | /// children into the dock itself, so a divider left behind by a tiling change |
| 276 | /// would end up loose beside the panels -- a handle for a boundary that is not |
| 277 | /// there any more. |
| 278 | const stackGeom = () => p.evaluate(() => { |
| 279 | const d = document.getElementById('dock'); |
| 280 | const drawn = (k) => k.getClientRects().length > 0; |
| 281 | const cols = [...d.querySelectorAll('.pcol')].filter((c) => c.getBoundingClientRect().width > 0); |
| 282 | return { |
| 283 | cols: cols.map((c) => ({ |
| 284 | id: c.id, |
| 285 | panels: [...c.children].filter((k) => k.classList.contains('panel') && drawn(k)) |
| 286 | .map((k) => ({ id: k.dataset.panel, h: Math.round(k.getBoundingClientRect().height) })), |
| 287 | handles: [...c.children].filter((k) => k.classList.contains('hstack') && drawn(k)).length, |
| 288 | order: [...c.children].filter(drawn) |
| 289 | .map((k) => k.classList.contains('hstack') ? '|' : k.dataset.panel).join(' '), |
| 290 | })), |
| 291 | loose: [...d.querySelectorAll('.hstack')].filter((h) => { |
| 292 | const par = h.parentNode; |
| 293 | return !par || !par.classList.contains('pcol') |
| 294 | || par.getBoundingClientRect().width === 0; |
| 295 | }).length, |
| 296 | total: d.querySelectorAll('.hstack').length, |
| 297 | }; |
| 298 | }); |
| 299 | |
| 300 | /// Drag the i-th divider in the dock by `dy`, downwards positive. |
| 301 | /// |
| 302 | /// Dispatched pointer events for the same reason `dragDock` uses them: headless |
| 303 | /// Chromium drops the moves after a mousedown on a dock handle. Capture is the |
| 304 | /// one thing a dispatched event cannot have, so it is stubbed for the drag. |
| 305 | const dragStack = (i, dy) => p.evaluate(({ i, by }) => { |
| 306 | const h = document.querySelectorAll('#dock .hstack')[i]; |
| 307 | if (!h) return false; |
| 308 | const r = h.getBoundingClientRect(); |
| 309 | const x = Math.round(r.left + r.width / 2), y = Math.round(r.top + r.height / 2); |
| 310 | const cap = Element.prototype.setPointerCapture, rel = Element.prototype.releasePointerCapture; |
| 311 | Element.prototype.setPointerCapture = function () {}; |
| 312 | Element.prototype.releasePointerCapture = function () {}; |
| 313 | const fire = (type, cy) => h.dispatchEvent(new PointerEvent(type, { |
| 314 | bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, clientX: x, clientY: cy, |
| 315 | })); |
| 316 | fire('pointerdown', y); |
| 317 | for (let k = 1; k <= 10; k++) fire('pointermove', y + Math.round(by * k / 10)); |
| 318 | fire('pointerup', y + by); |
| 319 | Element.prototype.setPointerCapture = cap; |
| 320 | Element.prototype.releasePointerCapture = rel; |
| 321 | return true; |
| 322 | }, { i, by: dy }); |
| 323 | |
| 324 | const heights = (g, col) => (g.cols[col || 0] ? g.cols[col || 0].panels.map((x) => x.h) : []); |
| 325 | |
| 326 | { |
| 327 | // A boundary exists only where two panels meet. |
| 328 | await only(['work'], 'auto'); |
| 329 | const lone = await stackGeom(); |
| 330 | check('a column holding one panel has no divider', lone.total === 0, |
| 331 | `${lone.total} in the dock`); |
| 332 | |
| 333 | await only(['work', 'mail'], 'auto'); |
| 334 | const two = await stackGeom(); |
| 335 | check('two panels stacked in a column get one divider between them', |
| 336 | two.cols.length === 1 && two.cols[0].handles === 1 && two.cols[0].order === 'work | mail', |
| 337 | two.cols.map((c) => `${c.id}: ${c.order}`).join(', ')); |
| 338 | const even = heights(two); |
| 339 | check('and they start out sharing the column evenly', |
| 340 | even.length === 2 && Math.abs(even[0] - even[1]) <= 4, even.join(' + ')); |
| 341 | |
| 342 | // The drag moves the boundary the hand is on, and only that one. |
| 343 | await dragStack(0, 80); |
| 344 | await p.waitForTimeout(200); |
| 345 | const moved = heights(await stackGeom()); |
| 346 | check('a divider dragged 80px down gives the upper panel 80px', |
| 347 | Math.abs((moved[0] - even[0]) - 80) <= 6, `${even.join(' + ')} then ${moved.join(' + ')}`); |
| 348 | check('and takes it off the lower one, so the column still fills', |
| 349 | Math.abs((moved[0] + moved[1]) - (even[0] + even[1])) <= 4, |
| 350 | `${even[0] + even[1]}px then ${moved[0] + moved[1]}px`); |
| 351 | await shot(s, 'docking-stack-dragged'); |
| 352 | |
| 353 | // What was tuned is still tuned after the page is built again from markup. |
| 354 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 355 | await signInAs(s, 'docking'); |
| 356 | await p.waitForTimeout(2500); |
| 357 | const after = await stackGeom(); |
| 358 | check('the tuned boundary outlives a reload', |
| 359 | Math.abs(heights(after)[0] - moved[0]) <= 6, |
| 360 | `${moved.join(' + ')} then ${heights(after).join(' + ')}`); |
| 361 | check('and the divider comes back with it', |
| 362 | after.cols.length === 1 && after.cols[0].handles === 1, `${after.total} in the dock`); |
| 363 | |
| 364 | // Auto -> a tiling that separates them -> Auto. The same two panels re-stack, |
| 365 | // so the same share applies; a key tied to the column slot could not do this. |
| 366 | await p.evaluate(() => DaimondPanels.setGrid('2x2')); |
| 367 | await p.waitForTimeout(400); |
| 368 | const split2 = await stackGeom(); |
| 369 | check('a tiling that puts them in a column each has no divider to draw', |
| 370 | split2.total === 0 && split2.cols.length === 2, |
| 371 | `${split2.total} dividers over ${split2.cols.length} columns`); |
| 372 | check('and leaves none loose in the dock', split2.loose === 0, `${split2.loose} loose`); |
| 373 | await p.evaluate(() => DaimondPanels.setGrid('auto')); |
| 374 | await p.waitForTimeout(400); |
| 375 | const round = await stackGeom(); |
| 376 | check('Auto -> 2x2 -> Auto keeps the tuned boundary', |
| 377 | Math.abs(heights(round)[0] - moved[0]) <= 6, |
| 378 | `${moved.join(' + ')} then ${heights(round).join(' + ')}`); |
| 379 | check('and no column is left drawing on nothing', dead(await geom()) === null); |
| 380 | |
| 381 | // A panel joining the stack is a different stack: even, and the old tuning |
| 382 | // is waiting for it to leave again. |
| 383 | await p.evaluate(() => DaimondPanels.show('spend')); |
| 384 | await p.waitForTimeout(400); |
| 385 | const three = await stackGeom(); |
| 386 | check('a third panel joining the column brings a second divider', |
| 387 | three.cols.length === 1 && three.cols[0].handles === 2, three.cols[0].order); |
| 388 | const h3 = heights(three); |
| 389 | check('and the new stack starts even rather than inheriting a share', |
| 390 | h3.length === 3 && Math.max(...h3) - Math.min(...h3) <= 6, h3.join(' + ')); |
| 391 | |
| 392 | // A divider is a boundary between two panels, not a lever on the column: the |
| 393 | // lower one moves and the panel above it does not shift under the hand. |
| 394 | await dragStack(1, 60); |
| 395 | await p.waitForTimeout(200); |
| 396 | const h3b = heights(await stackGeom()); |
| 397 | check('the lower divider of a three-panel column moves only its own pair', |
| 398 | Math.abs(h3b[0] - h3[0]) <= 4 && Math.abs((h3b[1] - h3[1]) - 60) <= 6 |
| 399 | && Math.abs((h3b[2] - h3[2]) + 60) <= 6, `${h3.join(' + ')} then ${h3b.join(' + ')}`); |
| 400 | await p.evaluate(() => { |
| 401 | const h = document.querySelectorAll('#dock .hstack')[1]; |
| 402 | if (h) h.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); |
| 403 | }); |
| 404 | await p.waitForTimeout(200); |
| 405 | |
| 406 | await p.evaluate(() => DaimondPanels.hide('spend')); |
| 407 | await p.waitForTimeout(400); |
| 408 | check('and the pair gets its own tuning back when it leaves', |
| 409 | Math.abs(heights(await stackGeom())[0] - moved[0]) <= 6, |
| 410 | `${moved.join(' + ')} then ${heights(await stackGeom()).join(' + ')}`); |
| 411 | |
| 412 | // Neither panel can be dragged away to nothing. |
| 413 | await dragStack(0, -3000); |
| 414 | await p.waitForTimeout(200); |
| 415 | const crushed = heights(await stackGeom()); |
| 416 | // Both bounds, so the check cannot pass on a divider that never moved: it |
| 417 | // went as far as it could, and stopped at something you can still read. |
| 418 | check('a divider dragged hard up leaves the upper panel usable, and no more', |
| 419 | crushed[0] >= 110 && crushed[0] <= 160, `${crushed.join(' + ')}`); |
| 420 | await dragStack(0, 3000); |
| 421 | await p.waitForTimeout(200); |
| 422 | const crushed2 = heights(await stackGeom()); |
| 423 | check('and dragged hard down leaves the lower one usable, and no more', |
| 424 | crushed2[1] >= 110 && crushed2[1] <= 160, `${crushed2.join(' + ')}`); |
| 425 | |
| 426 | // The affordance the other dividers have. |
| 427 | await p.evaluate(() => { |
| 428 | const h = document.querySelector('#dock .hstack'); |
| 429 | if (h) h.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); |
| 430 | }); |
| 431 | await p.waitForTimeout(250); |
| 432 | const reset = heights(await stackGeom()); |
| 433 | check('double-clicking a divider puts the column back to even', |
| 434 | Math.abs(reset[0] - reset[1]) <= 4, reset.join(' + ')); |
| 435 | |
| 436 | // Two columns, each stacking a pair: two boundaries that know nothing about |
| 437 | // each other, which is the case a single stored ratio could not have held. |
| 438 | await only(['work', 'mail', 'spend', 'agents'], '2x2'); |
| 439 | const quad = await stackGeom(); |
| 440 | check('2x2 with four panels gives each column its own divider', |
| 441 | quad.cols.length === 2 && quad.cols.every((c) => c.handles === 1), |
| 442 | quad.cols.map((c) => `${c.id}: ${c.order}`).join(', ')); |
| 443 | const colB = heights(quad, 1); |
| 444 | await dragStack(0, 70); |
| 445 | await p.waitForTimeout(200); |
| 446 | const quad2 = await stackGeom(); |
| 447 | check('and dragging one leaves the other column alone', |
| 448 | Math.abs(heights(quad2)[0] - heights(quad)[0] - 70) <= 6 |
| 449 | && heights(quad2, 1).every((h, i) => Math.abs(h - colB[i]) <= 4), |
| 450 | `${heights(quad).join(' + ')} | ${colB.join(' + ')} then ` |
| 451 | + `${heights(quad2).join(' + ')} | ${heights(quad2, 1).join(' + ')}`); |
| 452 | await p.evaluate(() => DaimondPanels.setGrid('auto')); |
| 453 | await p.waitForTimeout(400); |
| 454 | const stack4 = await stackGeom(); |
| 455 | check('four panels in one column stack with three dividers', |
| 456 | stack4.cols.length === 1 && stack4.cols[0].handles === 3, stack4.cols[0].order); |
| 457 | check('and the tiling change leaves none loose', |
| 458 | stack4.loose === 0 && stack4.total === 3, `${stack4.total} total, ${stack4.loose} loose`); |
| 459 | |
| 460 | await only(['work', 'mail'], 'auto'); |
| 461 | |
| 462 | // A column too short to honour any share does not offer a handle that |
| 463 | // cannot move -- the rail's divider stands down the same way. |
| 464 | await p.setViewportSize({ width: 1500, height: 300 }); |
| 465 | await p.evaluate(() => DaimondPanels.reflow()); |
| 466 | await p.waitForTimeout(300); |
| 467 | const tiny = await stackGeom(); |
| 468 | const tinyH = await p.evaluate(() => Math.round( |
| 469 | document.getElementById('dock-a').getBoundingClientRect().height)); |
| 470 | check('a column with no room to divide hides its divider', |
| 471 | tinyH < 256 && (tiny.cols.length === 0 || tiny.cols[0].handles === 0), |
| 472 | `${tinyH}px column, ` + tiny.cols.map((c) => `${c.id}: ${c.order}`).join(', ')); |
| 473 | await p.setViewportSize({ width: 1500, height: 950 }); |
| 474 | await p.evaluate(() => DaimondPanels.reflow()); |
| 475 | await p.waitForTimeout(300); |
| 476 | |
| 477 | // On a phone the dock is not a dock: one destination at a time, chosen from |
| 478 | // the bottom bar. There is no boundary to hold, so there is no handle. |
| 479 | await p.setViewportSize({ width: 390, height: 780 }); |
| 480 | await p.evaluate(() => DaimondPanels.reflow()); |
| 481 | await p.waitForTimeout(400); |
| 482 | const phone = await p.evaluate(() => |
| 483 | [...document.querySelectorAll('.hstack')].filter((h) => h.getClientRects().length).length); |
| 484 | check('the phone shell draws no dividers at all', phone === 0, `${phone} on screen`); |
| 485 | await shot(s, 'docking-stack-phone'); |
| 486 | await p.setViewportSize({ width: 1500, height: 950 }); |
| 487 | await p.evaluate(() => DaimondPanels.reflow()); |
| 488 | await p.waitForTimeout(400); |
| 489 | await shot(s, 'docking-stack-desktop'); |
| 490 | } |
| 491 | |
| 492 | // ── Nothing threw ─────────────────────────────────────────────────────── |
| 493 | { |
| 494 | const errs = errors(s).filter((e) => !/502|Bad Gateway|Failed to load resource/.test(e)); |
| 495 | check('no unexpected console errors', errs.length === 0, errs.join(' | ') || 'clean'); |
| 496 | } |
| 497 | |
| 498 | await s.close(); |
| 499 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 500 | if (bad.length) { bad.forEach((b) => console.log(' FAILED: ' + b)); process.exit(1); } |