Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_workspace.mjs

28.6 KiB, 1 run

created by r2519314175:825, 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_workspace.mjs — the controls that arrange Daimond.
2//
3// What is checked here is not the pixels but the guarantees the design rests on,
4// because those are the things a later change can quietly break:
5//
6// * a chip never moves when a neighbour opens (the reason chips are toggles),
7// * the dock's capacity comes from the chosen grid, not a constant,
8// * a grid too small to hold what is open CLOSES the surplus rather than
9// losing it, so it comes back as a chip,
10// * pinning is the only thing that decides the row, and nothing reorders,
11// * the row may be incomplete, but the gallery and the palette may not,
12// * text scaling moves the TYPE and leaves the frame where it is,
13// * an arrangement is restored only where one was deliberately saved.
14//
15// Run with dev/serve.mjs up. No gateway needed.
16import { open, signInAs, errors } from './harness.mjs';
17
18const s = await open({ name: 'workspace', connect: false });
19const p = s.page;
20await p.waitForTimeout(2500);
21
22const ok = [], bad = [];
23const check = (name, pass, detail) => {
24 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
25 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
26};
27
28// A Diamond to hang an arrangement on later.
29const diamondId = await p.evaluate(async () => {
30 const mod = await import('../pkg/oxedyne_daimond.js');
31 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
32 return await app.create_diamond('Ship the launch');
33});
34await p.reload({ waitUntil: 'domcontentloaded' });
35await signInAs(s, 'workspace');
36await p.waitForTimeout(3000);
37
38// The Agents panel hides until the first agent runs; reveal it so the fleet is
39// whole and the counts below mean what they say.
40await p.evaluate(() => {
41 localStorage.setItem('daimond-agents-revealed', '1');
42 document.body.classList.remove('agents-hidden');
43 window.DaimondPanels.reflow();
44});
45await p.waitForTimeout(300);
46
47const chips = () => p.$$eval('#panel-tags .ptag[data-panel]', els => els.map(e => ({
48 id: e.dataset.panel,
49 on: e.classList.contains('on'),
50 disabled: e.disabled,
51 zone: (e.className.match(/ptag-(rail|stage|dock)/) || [])[1] || null,
52 x: Math.round(e.getBoundingClientRect().left),
53})));
54
55/// Which panels the DOCUMENT says belong to the dock.
56///
57/// The number of them is not a constant this file may hold: it was four, the
58/// Terminal panel made it five, and a check carrying the old number fails for a
59/// panel that is working. `data-zone` in the markup is where that fact lives.
60const dockZone = () => p.$$eval('.panel[data-zone="dock"]', els => els.map(e => e.dataset.panel));
61
62/// How many panels the dock actually DRAWS.
63///
64/// Not how many nodes sit in its columns: closing a panel hides its element and
65/// leaves it where it was seated (apply() says so in as many words), so counting
66/// nodes counts a panel that has just been shed and reads a working shrink as an
67/// overfull dock. The computed style is what says a seat was given up.
68const seatedNow = () => p.$$eval('#dock .pcol > .panel',
69 els => els.filter(e => getComputedStyle(e).display !== 'none').length);
70
71// ── The row shows every panel, not only the closed ones ─────────────────
72{
73 const c = await chips();
74 const openCount = c.filter(x => x.on).length;
75 check('the row carries chips for open panels too', openCount > 0, `${openCount} of ${c.length} shown as open`);
76 check('an open panel is marked, not hidden',
77 c.some(x => x.id === 'ai' && x.on), 'the chat is open and chipped');
78}
79
80// ── A chip does not move when its neighbour opens ───────────────────────
81// This is the whole reason the row was inverted. When only closed panels had
82// chips, opening one shifted every chip to its right and the target a user was
83// aiming at slid out from under the cursor.
84{
85 const before = await chips();
86 await p.evaluate(() => window.DaimondPanels.show('tools'));
87 await p.waitForTimeout(350);
88 const after = await chips();
89 const moved = before.filter(b => {
90 const a = after.find(x => x.id === b.id);
91 return a && a.x !== b.x && b.id !== 'tools';
92 });
93 check('opening a panel moves no other chip', moved.length === 0,
94 moved.length ? moved.map(m => m.id).join(', ') + ' shifted' : 'every chip held its place');
95 check('the opened panel is now marked open',
96 after.find(x => x.id === 'tools')?.on === true);
97 await p.evaluate(() => window.DaimondPanels.hide('tools'));
98 await p.waitForTimeout(300);
99}
100
101// ── Zones are legible by position, in screen order ──────────────────────
102{
103 const c = await chips();
104 const firstOf = z => c.findIndex(x => x.zone === z);
105 const rail = firstOf('rail'), stage = firstOf('stage'), dock = firstOf('dock');
106 check('chips are grouped rail, then stage, then dock — the order the zones sit in',
107 rail < stage && stage < dock, `rail@${rail} stage@${stage} dock@${dock}`);
108 const divs = await p.$$eval('#panel-tags .ptag-div', e => e.length);
109 check('the groups are separated', divs >= 2, `${divs} dividers`);
110}
111
112// ── The dock's capacity is the grid, not a constant ─────────────────────
113{
114 const cap = async g => p.evaluate(grid => {
115 window.DaimondPanels.setGrid(grid);
116 return window.DaimondPanels.model().dockMax;
117 }, g);
118 check('a 2 by 2 dock holds four', await cap('2x2') === 4);
119 check('a 2 by 3 dock holds six', await cap('2x3') === 6);
120 check('a 3 by 2 dock holds six', await cap('3x2') === 6);
121 check('one column holds four', await cap('1') === 4);
122}
123
124// ── Columns are real, and balanced ──────────────────────────────────────
125{
126 await p.evaluate(() => {
127 window.DaimondPanels.setGrid('2x3');
128 ['agents', 'mail', 'work', 'spend'].forEach(id => window.DaimondPanels.show(id));
129 });
130 await p.waitForTimeout(500);
131 const cols = await p.$$eval('#dock .pcol', els =>
132 els.map(e => e.children.length).filter(n => n > 0));
133 check('four dock panels tile across two columns', cols.length === 2, `columns: ${cols.join(' + ')}`);
134 check('the columns are balanced rather than filled in turn',
135 Math.abs(cols[0] - cols[1]) <= 1, `${cols.join(' vs ')}`);
136}
137
138// ── A smaller grid CLOSES the surplus; it does not lose it ──────────────
139// A panel that simply vanished would be a panel the user cannot get back
140// without knowing it was ever there. Closed, it returns to the row as a chip.
141{
142 await p.evaluate(() => window.DaimondPanels.setGrid('2x2'));
143 await p.waitForTimeout(400);
144 const seated = await seatedNow();
145 check('a 2 by 2 dock seats no more than four', seated <= 4, `${seated} seated`);
146
147 await p.evaluate(() => window.DaimondPanels.setGrid('1'));
148 await p.waitForTimeout(400);
149 // Which panels belong to the dock is DECLARED in the markup, so the
150 // expectation is read from there rather than written down here. It used to
151 // say four; the Terminal panel made it five and the check went red for a
152 // panel that was working perfectly. Reading the document also cross-checks
153 // two sources against each other -- `data-zone` in the markup against the
154 // `ptag-dock` class the chip row draws from the panel registry -- so a panel
155 // declared in one zone and registered in another is caught here too.
156 const dockPanels = await dockZone();
157 const dockChips = (await chips()).filter(x => x.zone === 'dock').map(x => x.id);
158 const chipless = dockPanels.filter(id => !dockChips.includes(id));
159 check('every dock panel still has a chip after the grid shrank',
160 chipless.length === 0 && dockChips.length === dockPanels.length,
161 `${dockChips.length} chips for ${dockPanels.length} dock panels`
162 + (chipless.length ? `; no chip for ${chipless.join(', ')}` : ''));
163 const drawn = await seatedNow();
164 const capNow = await p.evaluate(() => window.DaimondPanels.model().dockMax);
165 check('the dock never seats more than the grid allows', drawn <= capNow,
166 `${drawn} seated, ${capNow} allowed`);
167}
168
169// ── A fifth dock panel, so the shedding arm can be driven ──────────────
170// The smallest grid seats four, and the product ships four dock panels, so
171// nothing is ever surplus and `setGrid`'s shedding arm — and the "this dock is
172// full" chip below it — run in no test at all.
173//
174// That gap was invisible for a day because the Terminal happened to be a fifth
175// dock panel, and it closed again the moment the Terminal moved to the stage.
176// Depending on a particular product panel being docked is what made the check
177// fragile, so it does not depend on one: a panel is DECLARED, the way any panel
178// is declared, and the engine is left to discover it through the same `scan()`
179// that finds the real ones. It is added before the document's own scripts run
180// (a MutationObserver at document-start, because the registry is read during
181// boot and a DOMContentLoaded listener is a race against it), and the page is
182// reloaded so boot sees it.
183//
184// HOW MANY dock panels the product ships is the product's business, so it is
185// measured here rather than written down. It said `=== 5` and phase H's Pending
186// panel made it six, which failed a check for a panel that was working
187// perfectly — the same shape as the sentence above about the Terminal, one
188// release later. What this section needs is not a number but ONE MORE THAN
189// THERE WERE.
190{
191 const before = await dockZone();
192 await p.addInitScript(() => {
193 const add = () => {
194 if (document.getElementById('panel-scratch')) return true;
195 const dock = document.getElementById('dock');
196 if (!dock) return false; // not parsed yet; the observer waits
197 const a = document.createElement('aside');
198 a.className = 'panel scratch';
199 a.id = 'panel-scratch';
200 a.dataset.panel = 'scratch';
201 a.dataset.zone = 'dock';
202 a.dataset.label = 'Scratch';
203 a.innerHTML = '<div class="railhead"><span role="heading" aria-level="2">Scratch</span></div>';
204 dock.parentNode.insertBefore(a, dock.nextSibling);
205 return true;
206 };
207 if (!add()) {
208 // `document`, not `document.documentElement`: at document-start the root
209 // element does not exist yet, and observing null throws before anything
210 // else in this script has been registered.
211 const mo = new MutationObserver(() => { if (add()) mo.disconnect(); });
212 mo.observe(document, { childList: true, subtree: true });
213 document.addEventListener('DOMContentLoaded', add);
214 }
215 });
216 await p.reload({ waitUntil: 'domcontentloaded' });
217 await signInAs(s, 'workspace');
218 await p.waitForTimeout(2500);
219 const dockPanels = await dockZone();
220 check('one more dock panel is registered from the markup, as any panel is',
221 dockPanels.includes('scratch') && dockPanels.length === before.length + 1,
222 `${before.length} -> ${dockPanels.length}: ${dockPanels.join(', ')}`);
223 const smallest = await p.evaluate(() => {
224 const g = window.DaimondPanels.grids();
225 return Math.min(...Object.keys(g).filter(k => g[k]).map(k => g[k].cols * g[k].rows));
226 });
227 check('and the smallest grid seats fewer than there now are, so a surplus exists',
228 smallest < dockPanels.length, `${smallest} seats for ${dockPanels.length} panels`);
229}
230
231// ── The surplus is CLOSED, not lost ─────────────────────────────────────
232// A grid too small to hold what is open leaves a panel with nowhere to sit, and
233// what has to be true of it is that it was CLOSED, and is therefore still on the
234// row to be clicked back. A panel that simply vanished would be one the user
235// cannot get back without knowing it was ever there.
236{
237 const dockPanels = await dockZone();
238 // The BIGGEST grid, and its real capacity, both read from the app rather
239 // than written here.
240 //
241 // This asserted that a `2x3` seats every dock panel, which was true when
242 // there were five. There are six now, plus the one this file registers, and
243 // no grid in the app seats seven — `2x3` and `3x2` are both six — so the
244 // check could only fail, and it was failing for a premise that had quietly
245 // stopped being true rather than for anything the dock does wrong.
246 //
247 // What is actually worth asserting is what this section's own heading says:
248 // the grid fills to capacity, and the surplus is CLOSED rather than lost. So
249 // it fills to capacity here, and the check below it does the surplus.
250 const big = await p.evaluate(() => {
251 const g = window.DaimondPanels.grids();
252 let best = null;
253 for (const k of Object.keys(g)) {
254 if (!g[k]) continue; // `auto` has no fixed size
255 const seats = g[k].cols * g[k].rows;
256 if (!best || seats > best.seats) best = { key: k, seats: seats };
257 }
258 return best;
259 });
260 check('the app offers a grid with a known capacity to fill', !!big, JSON.stringify(big));
261 await p.evaluate((arg) => {
262 window.DaimondPanels.setGrid(arg.key);
263 arg.ids.forEach(id => window.DaimondPanels.show(id));
264 }, { key: big.key, ids: dockPanels });
265 await p.waitForTimeout(600);
266 const openFull = await p.evaluate((ids) =>
267 ids.filter(id => window.DaimondPanels.isOpen(id)), dockPanels);
268 const want = Math.min(big.seats, dockPanels.length);
269 check(`the largest grid (${big.key}) seats every panel it has room for`,
270 openFull.length === want,
271 `${openFull.length} of ${dockPanels.length} open, ${big.seats} seats`);
272
273 await p.evaluate(() => window.DaimondPanels.setGrid('1'));
274 await p.waitForTimeout(600);
275 const shrunk = await p.evaluate((ids) => ({
276 open: ids.filter(id => window.DaimondPanels.isOpen(id)),
277 cap: window.DaimondPanels.model().dockMax,
278 }), dockPanels);
279 shrunk.seated = await seatedNow();
280 const shed = openFull.filter(id => !shrunk.open.includes(id));
281 check('a grid too small to hold them closes the surplus',
282 shrunk.seated <= shrunk.cap && shed.length === openFull.length - shrunk.cap,
283 `${shrunk.seated} seated of ${shrunk.cap}; closed: ${shed.join(', ') || 'none'}`);
284 const onRow = (await chips()).map(x => x.id);
285 check('and what was closed is back on the row, where it can be clicked again',
286 shed.length > 0 && shed.every(id => onRow.includes(id)),
287 `closed ${shed.join(', ') || 'nothing'}; row has ${onRow.join(', ')}`);
288
289 await p.evaluate((ids) => {
290 ids.forEach(id => window.DaimondPanels.hide(id));
291 window.DaimondPanels.setGrid('auto');
292 }, dockPanels);
293 await p.waitForTimeout(400);
294}
295
296// ── A full dock says so on the chip, before it is clicked ───────────────
297{
298 await p.evaluate(() => {
299 window.DaimondPanels.setGrid('2x2');
300 ['agents', 'mail', 'work', 'spend'].forEach(id => window.DaimondPanels.show(id));
301 });
302 await p.waitForTimeout(400);
303 const full = await p.evaluate(() => {
304 // Close one and fill with the other three, then ask about the odd one out.
305 window.DaimondPanels.setGrid('1');
306 return window.DaimondPanels.model().panels.filter(x => x.full).map(x => x.id);
307 });
308 await p.waitForTimeout(300);
309 const c = await chips();
310 const disabled = c.filter(x => x.disabled).map(x => x.id);
311 // Two checks, not one. This used to read `full.length === 0 || disabled…`,
312 // which passes without asserting anything at all on a dock that is never
313 // full — and with four dock panels and a four-seat floor, that was every run.
314 check('a dock with no seat left names the chip it cannot honour',
315 full.length > 0, `full: [${full}]`);
316 check('and that chip is disabled rather than silently inert',
317 full.length > 0 && full.every(id => disabled.includes(id)),
318 `full: [${full}] disabled: [${disabled}]`);
319 await p.evaluate(() => window.DaimondPanels.setGrid('auto'));
320 await p.waitForTimeout(300);
321}
322
323// ── Pinning decides the row, and only pinning ───────────────────────────
324{
325 const before = (await chips()).length;
326 await p.evaluate(() => window.DaimondPanels.setPinned('tools', false));
327 await p.waitForTimeout(350);
328 const after = await chips();
329 check('unpinning takes a chip off the row', after.length === before - 1,
330 `${before} then ${after.length}`);
331 check('the unpinned panel is gone from the row',
332 !after.some(x => x.id === 'tools'));
333 const more = await p.$$eval('#panel-more', e => e.map(x => x.textContent));
334 check('an overflow chip appears, carrying the count', more.length === 1, more[0]);
335
336 // Unpinning ONE must not read as unpinning all: the implicit "all of them"
337 // has to become a real list without collapsing.
338 check('every other panel keeps its pin',
339 after.length === before - 1, `${after.length} chips remain`);
340}
341
342// ── The row may be incomplete; the gallery may not ──────────────────────
343{
344 await p.click('#panel-more');
345 await p.waitForTimeout(400);
346 const rows = await p.$$eval('#panel-gallery .gal-row .nm', els => els.map(e => e.textContent));
347 const model = await p.evaluate(() => window.DaimondPanels.model().panels.length);
348 check('the gallery lists every panel, pinned or not', rows.length === model,
349 `${rows.length} rows for ${model} panels`);
350 check('including the one that is not on the row', rows.includes('Tools'));
351
352 await p.fill('#panel-gallery .gal-search', 'spend');
353 await p.waitForTimeout(300);
354 const hits = await p.$$eval('#panel-gallery .gal-row .nm', els => els.map(e => e.textContent));
355 check('the gallery searches', hits.length === 1 && hits[0] === 'Spending', hits.join(', '));
356
357 await p.keyboard.press('Escape');
358 await p.waitForTimeout(250);
359 check('escape closes the gallery', await p.$eval('#panel-gallery', e => e.hidden));
360}
361
362// ── A panel with nothing to hold waits before joining the row ──────────
363// Message and Compose mean nothing without an account for mail to arrive at or
364// be sent from, and a Doc panel means nothing before something is compiled into
365// it. Standing in the row from the first run they read as broken features
366// rather than as features not yet reached.
367{
368 const row = await chips();
369 const onRow = row.map(x => x.id);
370 check('Message stays off the row while no mail account exists',
371 !onRow.includes('msg'), onRow.join(', '));
372 check('and so does Compose, which needs somewhere to send from',
373 !onRow.includes('compose'));
374 check('and Doc, until something has been compiled into it',
375 !onRow.includes('doc'));
376
377 // But all three are still enumerable, which is the rule the row bends to.
378 await p.evaluate(() => window.DaimondRelease && 0);
379 const model = await p.evaluate(() => window.DaimondPanels.model().panels.map(x => x.id));
380 check('all three are still in the model, for the gallery and the palette',
381 ['msg', 'compose', 'doc'].every(id => model.includes(id)), model.join(', '));
382
383 // Reaching for one is the event it was waiting for.
384 await p.evaluate(() => window.DaimondPanels.activate('doc'));
385 await p.waitForTimeout(400);
386 const after2 = (await chips()).map(x => x.id);
387 check('reaching for Doc puts it on the row for good', after2.includes('doc'), after2.join(', '));
388}
389
390// ── A panel that has not revealed itself is still enumerable ───────────
391// This was proved with the Agents panel, which stayed off the chip row until a
392// Diamond dispatched its first agent. It no longer does: a panel that comes and
393// goes reads as a panel that was taken away, which is how the user read it, so
394// Agents is now reachable at all times and says that nothing is running. The
395// property is unchanged and is proved directly above with the Doc panel, which
396// is what still waits to be earned ("reaching for Doc puts it on the row for
397// good"). What went with the block was its fixture, not its subject.
398{
399 // `show`, not `activate`: activate TOGGLES a panel that has already revealed
400 // itself, so calling it on one that happens to be open closes it -- which is
401 // what the first draft of this check did to itself.
402 await p.evaluate(() => window.DaimondPanels.hide('agents'));
403 await p.waitForTimeout(300);
404 await p.evaluate(() => window.DaimondPanels.show('agents'));
405 await p.waitForTimeout(400);
406 check('the Agents panel opens whether or not an agent has ever run',
407 await p.evaluate(() => window.DaimondPanels.isOpen('agents')));
408 const row = (await chips()).map(x => x.id);
409 check('and it is on the chip row from the start', row.includes('agents'), row.join(', '));
410}
411
412// ── Pins survive a reload, and so does the grid ─────────────────────────
413{
414 await p.evaluate(() => window.DaimondPanels.setGrid('2x3'));
415 await p.waitForTimeout(300);
416 await p.reload({ waitUntil: 'domcontentloaded' });
417 await signInAs(s, 'workspace');
418 await p.waitForTimeout(2500);
419 const grid = await p.evaluate(() => window.DaimondPanels.grid());
420 check('the chosen tiling outlives a reload', grid === '2x3', grid);
421 const c = await chips();
422 check('so does the pin list', !c.some(x => x.id === 'tools'),
423 `${c.length} chips, tools absent`);
424}
425
426// ── The palette reaches what the row does not ───────────────────────────
427{
428 await p.keyboard.press('Control+k');
429 await p.waitForTimeout(400);
430 check('ctrl-k opens the palette', !(await p.$eval('#palette', e => e.hidden)));
431 await p.fill('#pal-input', 'tools');
432 await p.waitForTimeout(300);
433 const items = await p.$$eval('#pal-list .pal-item .nm', els => els.map(e => e.textContent));
434 check('an unpinned panel is still reachable by typing', items.includes('Tools'), items.join(', '));
435 await p.keyboard.press('Enter');
436 await p.waitForTimeout(500);
437 check('and opening it from the palette works',
438 await p.evaluate(() => window.DaimondPanels.isOpen('tools')));
439 check('the palette closes itself once it has acted',
440 await p.$eval('#palette', e => e.hidden));
441
442 // Settings are reachable the same way, which is what makes the palette
443 // complete rather than merely a panel switcher.
444 await p.keyboard.press('Control+k');
445 await p.waitForTimeout(300);
446 await p.fill('#pal-input', 'lolly');
447 await p.waitForTimeout(250);
448 await p.keyboard.press('Enter');
449 await p.waitForTimeout(400);
450 check('a theme can be set from the palette',
451 await p.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'lollypop');
452 await p.evaluate(() => window.DaimondTheme.set('dark'));
453}
454
455// ── Text scaling moves the type and leaves the frame ────────────────────
456{
457 const measure = () => p.evaluate(() => {
458 const cs = getComputedStyle(document.body);
459 const head = document.querySelector('.topbar, .header, header');
460 return {
461 font: parseFloat(cs.fontSize),
462 headerH: head ? Math.round(head.getBoundingClientRect().height) : null,
463 railW: Math.round(document.getElementById('panel-rail').getBoundingClientRect().width),
464 };
465 });
466 const base = await measure();
467 await p.evaluate(() => window.DaimondWorkspace.setScale(1.3));
468 await p.waitForTimeout(500);
469 const big = await measure();
470
471 check('the type grows', big.font > base.font, `${base.font}px then ${big.font}px`);
472 check('by the amount asked for',
473 Math.abs(big.font - base.font * 1.3) < 0.6, `${big.font} vs ${(base.font * 1.3).toFixed(1)}`);
474 check('the rail does not', big.railW === base.railW, `${base.railW}px then ${big.railW}px`);
475
476 await p.evaluate(() => window.DaimondWorkspace.setScale(1));
477 await p.waitForTimeout(400);
478 const back = await measure();
479 check('and it goes back exactly', back.font === base.font, `${back.font}px`);
480}
481
482// ── The size reaches the framed guide, which is its own document ────────
483// The guide is a separate document with its own stylesheet, so a size set on
484// the app does not reach it by inheritance. It has to be mirrored, exactly as
485// the theme already was -- and this is the check that would have caught the
486// guide falling back to a default when the tokens were first introduced.
487{
488 await p.click('#guide-btn').catch(() => {});
489 await p.waitForTimeout(2500);
490 // Read through Playwright's frame API, not contentDocument: the Web panel
491 // withholds `allow-same-origin`, so the document is opaque to the page.
492 const guideFrame = () => p.frames().find(f => /\/guide\//.test(f.url()));
493 const guideFont = async () => {
494 const f = guideFrame();
495 if (!f) return null;
496 try { return await f.evaluate(() => parseFloat(getComputedStyle(document.body).fontSize)); }
497 catch (e) { return null; }
498 };
499 const a = await guideFont();
500 if (a === null) {
501 check('the framed guide could be measured', false, 'no same-origin frame to read');
502 } else {
503 await p.evaluate(() => window.DaimondWorkspace.setScale(1.3));
504 await p.waitForTimeout(700);
505 const b = await guideFont();
506 check('the guide carries the reader\'s size too', b > a, `${a}px then ${b}px`);
507 await p.evaluate(() => window.DaimondWorkspace.setScale(1));
508
509 // The guide could never see the app's theme through the sandbox, and had
510 // been quietly defaulting to dark whatever the app was set to.
511 await p.evaluate(() => window.DaimondTheme.set('light'));
512 await p.waitForTimeout(600);
513 const th = await guideFrame().evaluate(() => document.documentElement.getAttribute('data-theme'));
514 check('and the theme, which the sandbox had always blocked', th === 'light', String(th));
515 await p.evaluate(() => window.DaimondTheme.set('dark'));
516 }
517}
518
519// ── An arrangement is restored only where one was saved ─────────────────
520{
521 await p.waitForTimeout(300);
522 // Nothing saved yet: selecting the Diamond must not disturb the panels.
523 const before = await p.evaluate(() => window.DaimondPanels.model().panels
524 .filter(x => x.open).map(x => x.id).sort().join(','));
525 await p.evaluate(async (id) => {
526 const row = Array.from(document.querySelectorAll('#diamond-list .diamond-box'))
527 .find(e => e.dataset.id === id);
528 if (row) row.click();
529 await new Promise(r => setTimeout(r, 800));
530 }, diamondId);
531 await p.waitForTimeout(600);
532 const after = await p.evaluate(() => window.DaimondPanels.model().panels
533 .filter(x => x.open).map(x => x.id).sort().join(','));
534 check('a Diamond with no saved arrangement changes nothing', before === after,
535 `${before} then ${after}`);
536
537 // Save one, disturb the layout, come back.
538 await p.evaluate((id) => {
539 window.DaimondPanels.show('spend');
540 window.DaimondPanels.saveArrangement(id);
541 }, diamondId);
542 await p.waitForTimeout(400);
543 check('the arrangement is recorded',
544 await p.evaluate((id) => window.DaimondPanels.hasArrangement(id), diamondId));
545
546 await p.evaluate(() => window.DaimondPanels.hide('spend'));
547 await p.waitForTimeout(300);
548 check('and the layout was genuinely disturbed',
549 !(await p.evaluate(() => window.DaimondPanels.isOpen('spend'))));
550
551 await p.evaluate((id) => window.DaimondPanels.restoreArrangement(id), diamondId);
552 await p.waitForTimeout(500);
553 check('returning to the Diamond puts the panels back',
554 await p.evaluate(() => window.DaimondPanels.isOpen('spend')));
555
556 await p.evaluate((id) => window.DaimondPanels.forgetArrangement(id), diamondId);
557 check('and it can be forgotten',
558 !(await p.evaluate((id) => window.DaimondPanels.hasArrangement(id), diamondId)));
559}
560
561// ── The row fits the window, and takes nothing off screen with it ──────
562// This one is worth keeping. The header used to run off the right of a narrow
563// window, carrying the README, guide and appearance buttons out of reach, and
564// NOTHING caught it: the body does not scroll when it happens, so the usual
565// horizontal-overflow check reads clean. The row now sheds its tail into the
566// menu instead, and what is asserted is that the controls stay reachable.
567{
568 for (const w of [1440, 1100, 950, 850]) {
569 await p.setViewportSize({ width: w, height: 850 });
570 await p.evaluate(() => window.DaimondPanels.reflow());
571 await p.waitForTimeout(400);
572 const r = await p.evaluate(() => {
573 const acts = document.querySelector('.top-actions').getBoundingClientRect();
574 const tags = document.getElementById('panel-tags');
575 const btn = document.getElementById('settings-menu-btn').getBoundingClientRect();
576 return {
577 actsRight: Math.round(acts.right),
578 btnRight: Math.round(btn.right),
579 rowOverflows: tags.scrollWidth > tags.clientWidth + 1,
580 onRow: document.querySelectorAll('#panel-tags .ptag[data-panel]').length,
581 };
582 });
583 check(`at ${w}px the header stays inside the window`,
584 r.actsRight <= w + 1, `actions end at ${r.actsRight}`);
585 check(`at ${w}px the appearance button is reachable`,
586 r.btnRight <= w + 1 && r.btnRight > 0, `button ends at ${r.btnRight}`);
587 check(`at ${w}px the row does not overflow its own box`,
588 !r.rowOverflows, `${r.onRow} chips on the row`);
589 }
590 await p.setViewportSize({ width: 1500, height: 950 });
591 await p.evaluate(() => window.DaimondPanels.reflow());
592 await p.waitForTimeout(400);
593}
594
595// ── Nothing threw ───────────────────────────────────────────────────────
596{
597 const errs = errors(s).filter(e => !/502|Bad Gateway|Failed to load resource/.test(e));
598 check('no unexpected console errors', errs.length === 0, errs.join(' | ') || 'clean');
599}
600
601await s.close();
602console.log(`\n${ok.length} passed, ${bad.length} failed`);
603if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); }