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. |
| 36 | import fs from 'node:fs'; |
| 37 | import path from 'node:path'; |
| 38 | import os from 'node:os'; |
| 39 | import { open, scratch, connectMock, steerDiamond } from './harness.mjs'; |
| 40 | |
| 41 | const OUT = path.join(os.homedir(), '.cache/daimond/tiledlg-shots'); |
| 42 | fs.mkdirSync(OUT, { recursive: true }); |
| 43 | |
| 44 | let failures = 0; |
| 45 | const 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. |
| 53 | async 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. |
| 65 | async 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). |
| 79 | async 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`. |
| 85 | async 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 | |
| 99 | const s = await open({ name: 'tiledlg', profile: scratch('pw', 'tiledlg-' + process.pid) }); |
| 100 | const { page } = s; |
| 101 | try { |
| 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 | |
| 606 | console.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}`); |
| 609 | process.exit(failures === 0 ? 0 : 1); |