Oregami
Repositories/oxedyne/daimond

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
28import { open, signInAs, shot, errors } from './harness.mjs';
29
30const s = await open({ name: 'docking', connect: false });
31const p = s.page;
32await p.waitForTimeout(1500);
33
34const ok = [], bad = [];
35const 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.
46const 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.
63const 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.
72const 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 ──────
84await dock(['work', 'mail'], 'auto');
85const base = await geom();
86check('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(', '));
88check('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`);
90check('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.
96for (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.
210const 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.
262const 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.
278const 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.
305const 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
324const 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
498await s.close();
499console.log(`\n${ok.length} passed, ${bad.length} failed`);
500if (bad.length) { bad.forEach((b) => console.log(' FAILED: ' + b)); process.exit(1); }