Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_tiledlg.mjs

30.9 KiB, 1 run

created by r2519314175:741, 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_tiledlg.mjs — the cog, the dialog, and Delete at its foot.
2//
3// Phase C put a cog in the top right of every Diamond and chat tile and moved
4// Delete into the dialog it opens. Four properties follow, and each is written
5// as a SEARCH of the page rather than as "press this, expect that" — a check
6// that names one button passes on a page where that button is the only thing
7// left.
8//
9// 1. Every tile carries a cog. A Diamond tile carries no closer cross — the
10// DIALOG has one, in its title row, which is the way out of a window of
11// settings. notes4 put a cross back on a CHAT tile specifically ("chats
12// are ephemeral but not disposable"), so that half of the claim now
13// holds only for a Diamond; a chat tile's own cross, and the confirm
14// behind it, is verify_chattiles's subject, not this file's.
15// 2. Delete is reachable from the dialog, it is the ONLY way to remove a
16// DIAMOND by hand, and it asks before it acts.
17// 3. The dialog carries the settings the tile does not show — the pause
18// control and the two colours — and carries no level control, because
19// Simple and Max are global (notes3). Simple really hides what Max shows,
20// and that choice, being the view's, survives a reload.
21// 4. Answering "no" to the confirm leaves the tile exactly where it was.
22//
23// Every check is gated on the thing it needs existing: a page with no tiles
24// satisfies "no cross on any tile" for free, so the tile count is asserted
25// first and the run refuses to be quietly vacuous.
26//
27// 5. A Diamond tile's meter row draws the CONTEXT METER, first, and it is the
28// chat tile's own -- the same last-prompt-over-window reading, from the
29// same function. The row was built to carry it and never did: its guard
30// asked about two counters a daimon's turn does not write.
31//
32// node dev/verify_tiledlg.mjs
33//
34// Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs, which
35// section 7 steers a daimon against. No gateway; nothing spends real money.
36import fs from 'node:fs';
37import path from 'node:path';
38import os from 'node:os';
39import { open, scratch, connectMock, steerDiamond } from './harness.mjs';
40
41const OUT = path.join(os.homedir(), '.cache/daimond/tiledlg-shots');
42fs.mkdirSync(OUT, { recursive: true });
43
44let failures = 0;
45const check = (cond, msg, detail) => {
46 console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : ''));
47 if (!cond) failures++;
48};
49
50/// A screenshot that is PROVEN to exist. `shot()` in the harness swallows a
51/// failed capture, and under load on this box capture has silently failed for
52/// an hour at a time, so a clean run is not evidence that anything was taken.
53async function snap(page, name, sel) {
54 const p = path.join(OUT, name + '.png');
55 try {
56 const target = sel ? await page.$(sel) : page;
57 if (!target) { console.log(` note no ${sel} to photograph`); return null; }
58 await target.screenshot({ path: p, timeout: 8000 });
59 } catch (e) { console.log(` note screenshot ${name} failed: ${String(e).split('\n')[0]}`); return null; }
60 if (!fs.existsSync(p) || fs.statSync(p).size < 500) { console.log(` note screenshot ${name} is not on disk`); return null; }
61 return p;
62}
63
64/// Make a Diamond through the dialog a person uses.
65async function makeDiamond(page, name) {
66 await page.evaluate(() => document.getElementById('new-diamond-btn').click());
67 await page.waitForSelector('.dlg-card', { timeout: 8000 });
68 await page.evaluate((nm) => {
69 const card = [...document.querySelectorAll('.dlg-card')].find((c) => c.getClientRects().length);
70 const inp = card.querySelector('input.dlg-input');
71 inp.value = nm;
72 inp.dispatchEvent(new Event('input', { bubbles: true }));
73 card.querySelector('.dlg-ok').click();
74 }, name);
75 await page.waitForTimeout(1500);
76}
77
78/// Make a chat tile (pending is enough: it is a tile with a cog).
79async function makeChat(page) {
80 await page.evaluate(() => document.getElementById('new-session-btn').click());
81 await page.waitForTimeout(700);
82}
83
84/// Open the cog dialog of the first tile in `listSel`.
85async function openCog(page, listSel) {
86 const hit = await page.evaluate((sel) => {
87 const box = document.querySelector(sel + ' .session-box');
88 if (!box) return 'no tile';
89 const cog = box.querySelector('.tile-cog');
90 if (!cog) return 'no cog';
91 cog.click();
92 return 'ok';
93 }, listSel);
94 if (hit !== 'ok') throw new Error(hit);
95 await page.waitForSelector('.tile-dlg-card', { timeout: 8000 });
96 await page.waitForTimeout(200);
97}
98
99const s = await open({ name: 'tiledlg', profile: scratch('pw', 'tiledlg-' + process.pid) });
100const { page } = s;
101try {
102 await page.evaluate(() => { const b = document.getElementById('admin-close'); if (b) b.click(); });
103 await page.waitForTimeout(200);
104 await makeDiamond(page, 'Alpha');
105 await makeChat(page);
106
107 // ── 0. The gate. Without tiles, every property below is vacuous. ──
108 const counts = await page.evaluate(() => ({
109 diamonds: document.querySelectorAll('#diamond-list .session-box').length,
110 chats: document.querySelectorAll('#session-list .session-box').length,
111 }));
112 check(counts.diamonds >= 1 && counts.chats >= 1,
113 'the rail holds a Diamond and a chat to judge',
114 `${counts.diamonds} Diamond(s), ${counts.chats} chat(s)`);
115 if (!(counts.diamonds >= 1 && counts.chats >= 1)) {
116 console.log('\nnothing to test against — refusing to report a vacuous pass.');
117 await s.close();
118 process.exit(1);
119 }
120 await snap(page, 'rail-with-cogs', '#panel-rail');
121
122 // ── 1. A cog on every tile. A cross on a chat tile, none on a Diamond's ──
123 const corner = await page.evaluate(() => {
124 const boxes = [...document.querySelectorAll('.session-box')];
125 const diamond = [...document.querySelectorAll('#diamond-list .session-box')];
126 const chat = [...document.querySelectorAll('#session-list .session-box')];
127 return {
128 total: boxes.length,
129 withCog: boxes.filter((b) => b.querySelector('.tile-cog')).length,
130 diamondTotal: diamond.length,
131 diamondCrosses: diamond.filter((b) => b.querySelector('.tile-x')).length,
132 chatTotal: chat.length,
133 chatCrosses: chat.filter((b) => b.querySelector('.tile-x')).length,
134 };
135 });
136 check(corner.withCog === corner.total, 'every tile carries a cog',
137 `${corner.withCog} of ${corner.total}`);
138 check(corner.diamondTotal > 0 && corner.diamondCrosses === 0,
139 'a Diamond tile carries no closer cross — the dialog is the way out',
140 `${corner.diamondCrosses} of ${corner.diamondTotal}`);
141 // notes4: brought back for a chat only, because a chat is ephemeral but not
142 // disposable. The confirm behind it is verify_chattiles's job, not this
143 // file's — here it is only asked whether the button exists.
144 check(corner.chatTotal > 0 && corner.chatCrosses === corner.chatTotal,
145 'and a chat tile carries one, restored by notes4',
146 `${corner.chatCrosses} of ${corner.chatTotal}`);
147
148 // ── 2. The dialog, and Delete at its foot ──
149 await openCog(page, '#diamond-list');
150 await snap(page, 'diamond-dialog', '.tile-dlg-card');
151 const dlg = await page.evaluate(() => {
152 const card = document.querySelector('.tile-dlg-card');
153 if (!card) return null;
154 const btns = [...card.querySelectorAll('button')];
155 const del = card.querySelector('.tile-dlg-delete');
156 const foot = card.querySelector('.tile-dlg-foot');
157 const closer = card.querySelector('.tile-dlg-title .tile-dlg-done');
158 return {
159 hasPause: !!card.querySelector('.pptw'),
160 // By `[data-level]`, which is what the deleted per-tile control was
161 // marked with. `.tile-dlg-level` on its own is now worn by the colour
162 // reset and by Fold context in a chat's dialog, so a bare-class check
163 // would report a control that is not the one being asked about.
164 levels: [...card.querySelectorAll('[data-level]')].map(b => b.dataset.level),
165 // The way out, which is a cross in the title row and no longer a Done
166 // at the foot. It keeps `tile-dlg-done` deliberately: that class has
167 // never meant "the button that says Done", it means "the control that
168 // finishes with this dialog", and half the suite reaches for it.
169 closerInTitle: !!closer,
170 closerNotInFoot: !!(closer && foot && !foot.contains(closer)),
171 closerNamed: !!(closer && (closer.getAttribute('aria-label') || '').trim()),
172 // The colours, by the field each writes rather than by how many
173 // pickers happen to be there.
174 swatches: [...card.querySelectorAll('.tile-dlg-swatch')].map(i => ({
175 id: i.id, type: i.type,
176 // The label earns its click: `htmlFor` pointing at this input is
177 // what makes the word a way into the control it names.
178 labelled: !!card.querySelector(`label.tile-dlg-label[for="${i.id}"]`),
179 })),
180 hasClear: !!card.querySelector('.tile-dlg-clear'),
181 hasDelete: !!del,
182 deleteInFoot: !!(del && foot && foot.contains(del)),
183 // The foot is the last block of the card: "at the bottom of the dialog".
184 footLast: !!(foot && card.lastElementChild === foot),
185 labels: btns.map((b) => (b.textContent || '').trim()),
186 };
187 });
188 check(!!dlg, 'the cog opens a dialog');
189 // THE DIALOG ALWAYS CARRIES IT, even for a Diamond whose tile draws none.
190 // Taking the light off an unautomated tile is a statement about clutter; the
191 // object is still in the pause tree, still held by the global control, and
192 // this dialog is where it can be let go from. A version of this change that
193 // dropped the node here too left such a Diamond with no release valve at all.
194 check(dlg && dlg.hasPause,
195 'the dialog carries the pause control — it is the release valve for a tile with no light',
196 JSON.stringify(dlg && dlg.hasPause));
197 // The level control is GONE, and this is the check that says so. It used to
198 // assert which levels were offered; the property it was protecting — that a
199 // tile can always be got back into step with the global view — is now kept
200 // by there being nothing here that can take it out of step in the first
201 // place, so that is what is asserted instead.
202 check(dlg && (dlg.levels || []).length === 0,
203 'the dialog offers no level control — Simple and Max are global, so a tile cannot detach itself',
204 dlg && ((dlg.levels || []).join(',') || 'none'));
205 // The way out. Named as well as drawn: a lone × with no accessible name is a
206 // button a screen reader announces as "×".
207 check(dlg && dlg.closerInTitle && dlg.closerNotInFoot,
208 'the way out is a cross in the title row, not a Done at the foot',
209 dlg && JSON.stringify({ inTitle: dlg.closerInTitle, notInFoot: dlg.closerNotInFoot }));
210 check(dlg && dlg.closerNamed, 'and it says what it is to a reader who cannot see it');
211 // The colours, by the field each picker writes. Two pickers is not the
212 // property — a background and a text colour is.
213 check(dlg && (dlg.swatches || []).some(i => /-bg-/.test(i.id))
214 && (dlg.swatches || []).some(i => /-fg-/.test(i.id)),
215 'the dialog offers a background colour and a text colour',
216 dlg && JSON.stringify((dlg.swatches || []).map(i => i.id)));
217 check(dlg && (dlg.swatches || []).length > 0
218 && (dlg.swatches || []).every(i => i.type === 'color' && i.labelled),
219 'each is a real colour input with a label bound to it, so the word focuses the control it names',
220 dlg && JSON.stringify(dlg.swatches));
221 check(dlg && dlg.hasClear, 'and there is a way back to the theme’s own colours');
222 check(dlg && dlg.hasDelete && dlg.deleteInFoot && dlg.footLast,
223 'Delete is at the foot of the dialog', dlg && JSON.stringify(dlg.labels));
224
225 // ── The colours, driven rather than described ──
226 //
227 // A picker that paints the tile and writes nothing is the failure worth
228 // catching: `daimond-tile-prefs` is what the Graph reads to paint the same
229 // Diamond, so a colour that reached the rail and not the store is a colour
230 // the picture never hears about.
231 {
232 const tileId = await page.evaluate(() =>
233 document.querySelector('#diamond-list .session-box').dataset.id);
234 const painted = await page.evaluate(() => {
235 const card = document.querySelector('.tile-dlg-card');
236 const put = (which, hex) => {
237 const inp = [...card.querySelectorAll('.tile-dlg-swatch')]
238 .find(i => new RegExp('-' + which + '-').test(i.id));
239 if (!inp) return null;
240 inp.value = hex;
241 inp.dispatchEvent(new Event('input', { bubbles: true }));
242 return hex;
243 };
244 const bg = put('bg', '#123456');
245 const fg = put('fg', '#abcdef');
246 const box = document.querySelector('#diamond-list .session-box');
247 const name = box.querySelector('.session-box-name');
248 return { bg, fg, back: box.style.backgroundColor,
249 ink: name ? name.style.color : '' };
250 });
251 const rec = await page.evaluate((i) =>
252 JSON.parse(localStorage.getItem('daimond-tile-prefs') || '{}')[i] || {}, tileId);
253 check(rec.bg === '#123456' && rec.fg === '#abcdef',
254 'the pickers write both colours into daimond-tile-prefs, which is where the Graph reads them',
255 JSON.stringify(rec));
256 check(/18,\s*52,\s*86/.test(painted.back || ''),
257 'and the tile takes the background at once, not at the next reload', painted.back);
258 // The ink is the other half: the stylesheet colours the name, the meta and
259 // the chips individually, so a tile that took a background and kept its old
260 // text is the failure this half exists for.
261 check(/171,\s*205,\s*239/.test(painted.ink || ''),
262 'and the words inside it take the text colour with it', painted.ink);
263
264 await page.evaluate(() => document.querySelector('.tile-dlg-card .tile-dlg-clear').click());
265 await page.waitForTimeout(250);
266 const cleared = await page.evaluate((i) => ({
267 rec: JSON.parse(localStorage.getItem('daimond-tile-prefs') || '{}')[i] || {},
268 back: document.querySelector('#diamond-list .session-box').style.backgroundColor,
269 }), tileId);
270 // A stored empty string is not the same as an absent field: "the theme's
271 // own" IS the absence, and a record that keeps the key around invites a
272 // later reader to treat it as a value.
273 check(!('bg' in cleared.rec) && !('fg' in cleared.rec),
274 'the reset takes both fields OUT of the record rather than storing an empty one',
275 JSON.stringify(cleared.rec));
276 check(!cleared.back, 'so the tile is drawn in the theme’s colours again',
277 JSON.stringify(cleared.back));
278 }
279
280 // AND THE TILE IS THE OTHER HALF OF THE RULE: no light until the Diamond has
281 // something that spends unbidden, then one. Measured on the tile rather than
282 // in the dialog, because that is where the user's instruction applies.
283 {
284 // Closed by the control that means "finished with this dialog", which is
285 // the cross. Reaching for the last `.dlg-ok` in the card would now press
286 // DELETE: the foot holds nothing else since Done left it.
287 await page.evaluate(() => {
288 const c = document.querySelector('.tile-dlg-card .tile-dlg-done');
289 if (c) c.click(); else document.querySelector('.tile-dlg').remove();
290 });
291 await page.waitForTimeout(400);
292 check(await page.evaluate(() => !document.querySelector('.tile-dlg-card')),
293 'the closer cross really closes the dialog');
294 // THE FIRST TILE, not any tile in the list. `#diamond-list .session-box
295 // .pptw` matches a light inside ANY box, and the Optimiser ships with a
296 // timer and therefore a light — so this said "true" for a tile that has
297 // none. Scope it to the box being talked about.
298 const bare = await page.evaluate(() => {
299 const b = document.querySelector('#diamond-list .session-box');
300 return !!(b && b.querySelector('.pptw'));
301 });
302 check(!bare, 'a Diamond with no actions carries no light on its TILE', String(bare));
303
304 await page.evaluate(async () => {
305 const id = document.querySelector('#diamond-list .session-box').dataset.id;
306 const ta = DaimondTriggers.blank('activity');
307 ta.id = 'activity-1';
308 ta.instruction = 'Say one useful thing.';
309 await DaimondCore.triggerSet(id, ta);
310 });
311 await page.waitForTimeout(600);
312 const armed = await page.evaluate(() => {
313 const b = document.querySelector('#diamond-list .session-box');
314 return {
315 has: !!b.querySelector('.pptw'),
316 order: [...b.querySelector('.session-box-header').children]
317 .map((el) => el.className.split(' ')[0]).join(','),
318 };
319 });
320 check(armed.has, 'and one WITH an action grows one', JSON.stringify(armed));
321 check(/^session-box-name,pptw,tile-cog/.test(armed.order),
322 'to the right of the name, hard against the cog', armed.order);
323 // And no chat tile has one at all — the user's ruling, and the same rule:
324 // a chat spends when you type in it and at no other time.
325 const chat = await page.evaluate(() =>
326 !!document.querySelector('#session-list .session-box .pptw'));
327 check(!chat, 'while an ordinary chat carries none at all', String(chat));
328
329 await openCog(page, '#diamond-list');
330 }
331
332 // ── 3. Delete asks nothing, because it takes nothing away ──
333 //
334 // This used to assert a confirm and a "no" that left the tile standing. Since
335 // the trash, Delete is reversible and asks nothing — the question moved to
336 // "Delete permanently" and "Empty trash", where it is true (see
337 // dev/verify_trash.mjs). What is asserted here is the pair that makes the
338 // silence safe: the tile goes, AND the Diamond is in the trash.
339 const before = await page.evaluate(() =>
340 [...document.querySelectorAll('#diamond-list .session-box-name')].map((n) => n.textContent.trim()));
341 await page.evaluate(() => document.querySelector('.tile-dlg-delete').click());
342 await page.waitForTimeout(1800);
343 const asked = await page.evaluate(() => {
344 const dlgs = [...document.querySelectorAll('.modal.dlg .dlg-card')]
345 .filter((c) => c.getClientRects().length && !c.classList.contains('tile-dlg-card'));
346 return dlgs.length ? (dlgs[0].querySelector('.dlg-msg') || {}).textContent || '(no message)' : null;
347 });
348 check(asked === null, 'Delete asks nothing — it is reversible',
349 asked ? `a dialog opened: ${asked.slice(0, 60)}…` : 'no dialog');
350 const after = await page.evaluate(() =>
351 [...document.querySelectorAll('#diamond-list .session-box-name')].map((n) => n.textContent.trim()));
352 check(!after.includes('Alpha') && before.includes('Alpha'),
353 'and the Diamond leaves the rail', `${JSON.stringify(before)} → ${JSON.stringify(after)}`);
354
355 // ── 4. Where it went ──
356 const inTrash = await page.evaluate(async () => {
357 try { return (await window.DaimondCore.trashList()).map((x) => x.name); }
358 catch (e) { return []; }
359 });
360 check(inTrash.includes('Alpha'), 'IT IS IN THE TRASH, which is why Delete no longer has to ask',
361 JSON.stringify(inTrash));
362 await page.evaluate(() => window.DaimondPanels.show('trash'));
363 await page.waitForTimeout(700);
364 await snap(page, 'delete-trash', '#panel-trash');
365
366 // ── 5. Simple hides, Max shows, and the choice survives a reload ──
367 //
368 // Driven through `DaimondView`, which is the whole of the control now: the
369 // dialog no longer offers a level, so the only way to change what a tile
370 // draws is the one control in the appearance menu. What is measured is still
371 // the TILE, because the tile is where a user would notice the rule failing.
372 // The view's own segmented control is verify_view's subject.
373 //
374 // The Diamond is TAGGED first, and that is not decoration. Simple also drops
375 // the whole meta row when nothing in it is a tag — so on an untagged tile the
376 // timestamp is invisible whether or not the rule that hides it exists, and
377 // the check passed against CSS with that rule deleted. Proved by deleting it:
378 // the run stayed green. A tag keeps the row on screen, so what is being
379 // measured is the rule and not the row.
380 await makeDiamond(page, 'Beta');
381 await page.evaluate(() => {
382 const box = document.querySelector('#diamond-list .session-box');
383 const meta = box.querySelector('.session-box-meta');
384 const chip = document.createElement('span');
385 chip.className = 'tag-chip tag-sm';
386 chip.textContent = 'kept';
387 meta.appendChild(chip);
388 });
389 await page.waitForTimeout(200);
390 const shownAt = () => page.evaluate(() => {
391 const box = document.querySelector('#diamond-list .session-box');
392 if (!box) return null;
393 // PRESENT and VISIBLE are different questions. A check that only asks
394 // "is it on screen?" passes on a page where the element was never built,
395 // which is the vacuous pass this whole file exists to avoid.
396 const st = (sel) => {
397 const e = box.querySelector(sel);
398 return { present: !!e, visible: !!e && e.getClientRects().length > 0 };
399 };
400 return { detail: box.dataset.detail, time: st('.session-box-time'),
401 meter: st('.diamond-meter'), meta: st('.session-box-meta') };
402 });
403 const simple = await shownAt();
404 check(simple && simple.detail === 'simple', 'a new tile starts Simple', simple && simple.detail);
405 // WHAT A DIAMOND TILE HOLDS CHANGED ON 2026-08-21, and this block asserted the
406 // shape before it. Two things moved and one went:
407 //
408 // * the crystal version left the tile altogether, on the owner's instruction
409 // -- `.session-box-ctx` is not drawn on a Diamond at all now, so asking
410 // whether Simple hides it is asking about something that does not exist;
411 // * the relative time moved out of the meta row into `.diamond-meter`, below
412 // the tags, where it sits beside the spend it belongs with;
413 // * the meta row is no longer appended at all when it would be empty.
414 //
415 // The old assertion also required the META ROW to be VISIBLE in Simple, and
416 // that was wrong before any of tonight's work: `app.css` has hidden it in
417 // Simple -- unless it carries a pending badge -- since the two views were
418 // written. It failed at bd5a2c3 for exactly that reason, so it is not a
419 // regression being papered over here; it is a check that never matched the
420 // stylesheet and was never run often enough to say so.
421 check(simple && simple.time.present && simple.meter.present,
422 'the tile really has a timestamp and a meter row to hide',
423 JSON.stringify(simple));
424 check(simple && !simple.time.visible && !simple.meter.visible,
425 'Simple hides the meter row, and the timestamp with it', JSON.stringify(simple));
426 await snap(page, 'tile-simple', '#diamond-list .session-box');
427
428 await page.evaluate(() => window.DaimondView.set('max'));
429 await page.waitForTimeout(300);
430 const max = await shownAt();
431 check(max && max.detail === 'max' && max.meter.visible && max.time.visible,
432 'Max shows what Simple hid', JSON.stringify(max));
433 await snap(page, 'tile-max', '#diamond-list .session-box');
434
435 await page.reload({ waitUntil: 'domcontentloaded' });
436 await page.waitForTimeout(1200);
437 // Back through the gate.
438 await page.waitForSelector('#id-primary', { timeout: 15000 }).catch(() => {});
439 if (await page.$('#id-pass')) {
440 await page.fill('#id-pass', 'testpass1234');
441 await page.evaluate(() => document.getElementById('id-primary').click());
442 await page.waitForSelector('#identity-modal', { state: 'hidden', timeout: 15000 }).catch(() => {});
443 }
444 await page.waitForTimeout(2500);
445 const afterReload = await shownAt();
446 check(afterReload && afterReload.detail === 'max',
447 'the choice survives a reload', afterReload && JSON.stringify(afterReload));
448 // And it survived as the VIEW, which is the only place it is now written
449 // down. A tile that came back Max because something had stamped `max` into
450 // its own record would look identical here and be the bug notes3 removed.
451 const kept = await page.evaluate(() => ({
452 view: localStorage.getItem('daimond-view'),
453 prefs: localStorage.getItem('daimond-tile-prefs') || '{}',
454 }));
455 check(kept.view === 'max' && !/"detail"/.test(kept.prefs),
456 'and it survived as the view, with nothing written into the tile’s own record',
457 JSON.stringify(kept));
458
459 // ── 5b. And back again. A level you can raise and not lower is the one-way
460 // door verify_reversible hunts; it cannot see this one, because its
461 // signature does not read `aria-pressed`, so it is asserted here.
462 await page.evaluate(() => window.DaimondView.set('simple'));
463 await page.waitForTimeout(300);
464 const backToSimple = await shownAt();
465 check(backToSimple && backToSimple.detail === 'simple'
466 && !backToSimple.meter.visible && !backToSimple.time.visible,
467 'Max can be turned back to Simple', backToSimple && JSON.stringify(backToSimple));
468
469 // ── 6. Nothing on a tile DESTROYS anything ──
470 //
471 // Searched, not enumerated: every control inside a tile is pressed on a
472 // fresh page, and the rail is counted afterwards. The rule used to be "a
473 // tile that vanished without a confirm is a second delete path"; since the
474 // trash it is the stronger and simpler "whatever a click removes must be
475 // recoverable". A control that took a tile off the rail and put nothing in
476 // the trash destroyed something, whatever it is called.
477 const trashBefore = await page.evaluate(async () => {
478 try { return (await window.DaimondCore.trashList()).length; } catch (e) { return 0; }
479 });
480 const railBefore = await page.evaluate(() =>
481 document.querySelectorAll('.session-box').length);
482 const controls = await page.evaluate(() => [...document.querySelectorAll('.session-box')]
483 .flatMap((b, bi) => [...b.querySelectorAll('button, input, select')]
484 .map((c, ci) => ({ bi, ci, what: c.className || c.tagName }))));
485 let vanished = [];
486 for (const c of controls) {
487 const gone = await page.evaluate(({ bi, ci }) => {
488 const boxes = [...document.querySelectorAll('.session-box')];
489 const box = boxes[bi];
490 if (!box) return null;
491 const ctl = [...box.querySelectorAll('button, input, select')][ci];
492 if (!ctl) return null;
493 const was = boxes.length;
494 ctl.click();
495 return { was };
496 }, c);
497 if (!gone) continue;
498 await page.waitForTimeout(250);
499 const now = await page.evaluate(() => ({
500 tiles: document.querySelectorAll('.session-box').length,
501 modal: !!document.querySelector('.modal.dlg'),
502 }));
503 if (now.tiles < gone.was && !now.modal) vanished.push(c.what);
504 // Put anything that opened back down.
505 await page.keyboard.press('Escape');
506 await page.waitForTimeout(150);
507 }
508 const trashAfter = await page.evaluate(async () => {
509 try { return (await window.DaimondCore.trashList()).length; } catch (e) { return -1; }
510 });
511 check(vanished.length === trashAfter - trashBefore,
512 'no control on a tile DESTROYS anything — everything a click removed is in the trash',
513 `${controls.length} control(s) pressed; ${vanished.length} tile(s) vanished `
514 + `(${vanished.join(', ') || 'none'}); trash went ${trashBefore} → ${trashAfter}`);
515
516 // ── 7. A Diamond tile draws the CONTEXT METER its meter row was built for ──
517 //
518 // It never did. The row asked `promptTokens + completionTokens > 0` of the
519 // daimon's conversation, and NOTHING ON THAT PATH WRITES EITHER: a daimon's
520 // turn goes through `doSteer`, which sets `lastPrompt` from the engine and
521 // bills the turn to the Diamond through `meterDiamondTurn`, while those two
522 // cumulative counters are `runTurn`'s and `sendUserMessage` diverts a daimon
523 // away from `runTurn` before it can reach them. So the guard was false after
524 // every daimon turn ever run and the footer read `22h ago $3.40` with a real
525 // reading available and unasked for.
526 //
527 // The property is that the meter measures THE SAME QUANTITY AS A CHAT'S: the
528 // last prompt the engine sent, over the window its model publishes. So the
529 // numerator is asserted against `lastPrompt` and the denominator against the
530 // pricing table, rather than against the percentage the tile happens to draw
531 // -- a check that only asked "is there a bar" would pass on a bar drawn from
532 // anything at all.
533 //
534 // The model is switched to one the pricing table publishes a window for,
535 // because a model with none has no denominator and `tileCtxBar` correctly
536 // draws nothing. `glm-5p2` is served by dev/mockllm.mjs AND is in the table,
537 // which is what makes it the one model that can be both run and measured here.
538 await connectMock(s, { model: 'accounts/fireworks/models/glm-5p2' });
539 await page.waitForTimeout(600);
540 await makeDiamond(page, 'Gamma');
541 await page.evaluate(() => {
542 const box = [...document.querySelectorAll('#diamond-list .session-box')]
543 .find((b) => (b.textContent || '').includes('Gamma'));
544 if (box) box.click();
545 });
546 await page.waitForTimeout(700);
547 const gammaId = await page.evaluate(() => (window.DaimondDiamond.current() || {}).id);
548 // `@usage` makes the mock report the provider figures a working conversation
549 // really carries. Nothing here writes the reading being measured: the meter's
550 // numerator is the engine's own `last_prompt_tokens`, read back below from the
551 // conversation record rather than from this constant.
552 const WANT_PROMPT = 231000;
553 await steerDiamond(s, `@usage ${WANT_PROMPT} 900 3.40`);
554 await page.waitForTimeout(6000);
555 await page.evaluate(() => window.DaimondView.set('max'));
556 await page.waitForTimeout(500);
557
558 const meter = await page.evaluate((id) => {
559 const conv = window.DaimondDiamond.conversation(id);
560 const box = document.querySelector(`#diamond-list .session-box[data-id="${id}"]`);
561 const row = box && box.querySelector('.diamond-meter');
562 const ctx = box && box.querySelector('.tile-ctx');
563 return {
564 lastPrompt: conv ? conv.lastPrompt : null,
565 cumulative: conv ? ((conv.promptTokens || 0) + (conv.completionTokens || 0)) : null,
566 window: conv ? window.DaimondPricing.contextWindow(conv.model, conv.provider || '') : null,
567 rowFirst: row && row.firstElementChild ? row.firstElementChild.className : null,
568 pct: ctx ? (ctx.querySelector('.tile-ctx-pct') || {}).textContent : null,
569 fold: !!(ctx && ctx.querySelector('.tile-ctx-fold')),
570 title: ctx ? ctx.getAttribute('title') : null,
571 // The chat tile's own bar, by class, so the two cannot be two things.
572 sameClass: !!(ctx && ctx.querySelector('.tile-ctx-bar')),
573 text: row ? row.textContent : null,
574 };
575 }, gammaId);
576
577 // The gate. Without a real turn and a published window every claim below is
578 // vacuous, and the old code's silence would look like a pass.
579 check(meter.lastPrompt === WANT_PROMPT && meter.window > 0,
580 'the daimon really ran a turn and its model publishes a window',
581 `lastPrompt=${meter.lastPrompt}, window=${meter.window}`);
582 check(meter.cumulative === 0,
583 'a daimon turn still writes NEITHER cumulative counter — the old guard\'s question',
584 `promptTokens+completionTokens=${meter.cumulative}`);
585 check(!!meter.pct, 'the Diamond tile draws a context meter at all', meter.text);
586 check(meter.rowFirst === 'tile-ctx',
587 'the meter is FIRST on the row, before the age and the spend', meter.rowFirst);
588 check(meter.sameClass && meter.fold,
589 'it is the chat tile\'s own bar, fold mark and all',
590 JSON.stringify({ bar: meter.sameClass, fold: meter.fold }));
591 const wantPct = meter.window
592 ? Math.min(100, Math.round(meter.lastPrompt / meter.window * 100)) + '%' : null;
593 check(meter.pct === wantPct,
594 'the reading is the last prompt over the published window, as on a chat',
595 `tile says ${meter.pct}, ${meter.lastPrompt}/${meter.window} is ${wantPct}`);
596 await snap(page, 'diamond-tile-meter', `#diamond-list .session-box[data-id="${gammaId}"]`);
597
598 // 502 is the dev server proxying to a gateway that is not running, which is
599 // the ordinary state of a browser-only run and not a fault of the page.
600 const errs = s.errs.filter((e) => !/favicon/i.test(e) && !/502 \(Bad Gateway\)/.test(e));
601 check(errs.length === 0, 'no console errors', errs.slice(0, 3).join(' | ') || 'none');
602} finally {
603 await s.close();
604}
605
606console.log(failures === 0
607 ? `\ntiledlg: the cog is the corner and Delete is the foot. Shots in ${OUT}`
608 : `\ntiledlg: ${failures} failure(s). Shots in ${OUT}`);
609process.exit(failures === 0 ? 0 : 1);