Oregami
Repositories/oxedyne/daimond

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.
66import fs from 'node:fs';
67import path from 'node:path';
68import { fileURLToPath } from 'node:url';
69import { open, shot, scratch } from './harness.mjs';
70
71const HERE = path.dirname(fileURLToPath(import.meta.url));
72const WWW = path.join(HERE, '..', 'www');
73
74const BREAK = (() => {
75 const i = process.argv.indexOf('--break');
76 return i > 0 ? String(process.argv[i + 1] || '') : '';
77})();
78
79const PROFILE = scratch('pw', 'stage' + (BREAK ? '-' + BREAK : ''));
80fs.rmSync(PROFILE, { recursive: true, force: true });
81
82const ok = [], bad = [];
83const 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.
92const 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
181if (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.
193function 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
209async 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
220const s = await open({ name: 'stage', profile: PROFILE, connect: false, route: routes });
221const p = s.page;
222await 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.
232const 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.
266const FLOOR = 380;
267const 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.
273const NEVER = 205;
274const 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.
304const wants = async (ids) => {
305 await p.evaluate((list) => { list.forEach((x) => DaimondPanels.show(x)); }, ids);
306 await p.waitForTimeout(600);
307};
308
309const 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 ───────────────────────
315await at(2400);
316await 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}
334await 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.
378const 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
397const widthsOf = async () => (await geom()).seats.map((k) => k.w);
398
399await at(2400);
400await 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.
449await 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}
515await 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}
549await 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.
553const errs = s.errs.filter((e) => !/\b(401|402|502)\b|Bad Gateway|Unauthorized|Payment Required|net::ERR/.test(e));
554check('no unexpected console errors', errs.length === 0, errs.slice(0, 3).join(' | '));
555
556console.log(`\n${ok.length} passed, ${bad.length} failed`);
557if (bad.length) console.log(bad.map((b) => ' - ' + b).join('\n'));
558await s.close();
559process.exit(bad.length ? 1 : 0);