oxedyne/daimond/dev/verify_view.mjs
24.4 KiB, 1 run
created by r2519314175:785, 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_view.mjs -- Max and Simple, and the two rules that make them safe. |
| 2 | // |
| 3 | // The user's words: "Max is the current Compact view but with more important |
| 4 | // information and settings pulled out of dialogs and made visible in order to |
| 5 | // use more screen real-estate, and Simple is more canva-like, with clutter |
| 6 | // removed by hiding non-essential detail." |
| 7 | // |
| 8 | // The design is not "more" and "less". It is where a control LIVES relative to |
| 9 | // the thing it controls: Simple puts controls inside the object, Max hoists |
| 10 | // them onto it so several objects can be compared without navigating. |
| 11 | // |
| 12 | // What is proved here: |
| 13 | // |
| 14 | // INVARIANT 1 -- Simple hides information, never affordances. Every control |
| 15 | // reachable in Max is reachable in Simple within one more click, and the |
| 16 | // route (the cog) is always visible. This is what stops Simple being a mode |
| 17 | // where the reader concludes the app is broken. |
| 18 | // INVARIANT 2 -- Simple never hides a warning, an error, or a spending |
| 19 | // control. Notes2 opens on the Transparent Control Principle; a display |
| 20 | // preference that hid a traffic light would contradict the app's first rule. |
| 21 | // THE CASCADE -- the view is the ONE control of density: every tile follows |
| 22 | // it, and follows it back. The trap: choosing Max must not write `max` into |
| 23 | // every tile, or switching to Simple leaves them all dense and the global |
| 24 | // control looks broken. |
| 25 | // NOTHING PER TILE -- notes3 settled it: "Simple and Max are meant to be |
| 26 | // global". So the tile's own dialog offers no level control at all, and a |
| 27 | // `detail` left in storage by an older build is INERT. That second half is |
| 28 | // the one worth asserting: a stored override that still won would leave a |
| 29 | // tile permanently out of step with the view and no control in the app to |
| 30 | // mend it with, which is a worse state than the one the control removed. |
| 31 | // MIGRATION -- a stored `sharp` becomes Max and `warm` becomes Simple, so |
| 32 | // nobody's existing choice is cleared; an unset skin is not a choice and |
| 33 | // becomes Simple, the quiet default. |
| 34 | // MAX FITS A PHONE. If the hoisted set does not fit 430px, too much has been |
| 35 | // hoisted. That is the forcing function against Max becoming a dumping |
| 36 | // ground. |
| 37 | // |
| 38 | // node dev/verify_view.mjs |
| 39 | // node dev/verify_view.mjs --break copy # the view writes into every tile |
| 40 | // node dev/verify_view.mjs --break stuck # a stored detail wins over the view |
| 41 | // node dev/verify_view.mjs --break light # Simple hides the pause widget |
| 42 | // node dev/verify_view.mjs --break cog # Simple hides the way in |
| 43 | // node dev/verify_view.mjs --break tags # Simple hides the tag row |
| 44 | // node dev/verify_view.mjs --break scrolled # the tags render, past the row's edge |
| 45 | // |
| 46 | // Needs dev/serve.mjs and dev/mockllm.mjs (dev/world.sh N --up gives both). |
| 47 | import { open, connectMock, signInAs, scratch } from './harness.mjs'; |
| 48 | |
| 49 | const BI = process.argv.indexOf('--break'); |
| 50 | const BREAK = BI >= 0 ? (process.argv[BI + 1] || '') : ''; |
| 51 | |
| 52 | let failures = 0; |
| 53 | const check = (cond, msg, detail) => { |
| 54 | if (!cond) failures++; |
| 55 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail ? ' -- ' + detail : '')); |
| 56 | }; |
| 57 | |
| 58 | const s = await open({ name: 'view', signIn: false, connect: false, |
| 59 | profile: scratch('pw', 'view-' + process.pid) }); |
| 60 | const { page: p } = s; |
| 61 | |
| 62 | try { |
| 63 | if (BREAK) console.log(` .. running with --break ${BREAK}`); |
| 64 | await signInAs(s, 'view'); |
| 65 | await connectMock(s); |
| 66 | if (BREAK) { |
| 67 | await p.evaluate((b) => { |
| 68 | const st = document.createElement('style'); |
| 69 | st.id = 'viewbreak'; |
| 70 | if (b === 'light') st.textContent = '[data-view="simple"] .session-box .pptw { display: none !important; }'; |
| 71 | if (b === 'cog') st.textContent = '[data-view="simple"] .session-box .tile-cog { display: none !important; }'; |
| 72 | // The original tag defect, put back: the row moved out of |
| 73 | // `session-box-meta` on 2026-08-20 and inherited none of the rules |
| 74 | // that had kept it, so it was given a `display: none` in Simple and |
| 75 | // nothing went red. This is the break the check below was written |
| 76 | // for and it must go on reddening whatever else changes. |
| 77 | if (b === 'tags') st.textContent = '[data-view="simple"] .session-box .session-box-tags { display: none !important; }'; |
| 78 | // The second way to lose a tag, which the first check could not see: |
| 79 | // the chip is drawn, has a rect with area, and lies outside the band |
| 80 | // its own row shows -- the geometry of a chip scrolled off the end of |
| 81 | // a horizontal scroller. The break imposes the clipping band itself |
| 82 | // rather than leaning on `.session-box-tags` being a scroller today, |
| 83 | // so it means the same thing if that rule is written differently |
| 84 | // tomorrow. |
| 85 | if (b === 'scrolled') st.textContent = |
| 86 | '[data-view="simple"] .session-box .session-box-tags {' |
| 87 | + ' flex-wrap: nowrap !important; overflow-x: auto !important;' |
| 88 | + ' overflow-y: hidden !important; }' |
| 89 | + '[data-view="simple"] .session-box .session-box-tags .tag-sm {' |
| 90 | + ' margin-left: 320px !important; }'; |
| 91 | document.head.appendChild(st); |
| 92 | if (b === 'copy') { |
| 93 | // The classic bug: the view writes itself into every tile, so the |
| 94 | // tiles stop following it. |
| 95 | const orig = window.DaimondView.set; |
| 96 | window.DaimondView.set = function (v) { |
| 97 | const all = JSON.parse(localStorage.getItem('daimond-tile-prefs') || '{}'); |
| 98 | document.querySelectorAll('.session-box[data-id]').forEach(el => { |
| 99 | const id = el.dataset.id; |
| 100 | all[id] = Object.assign({}, all[id], { detail: v }); |
| 101 | }); |
| 102 | localStorage.setItem('daimond-tile-prefs', JSON.stringify(all)); |
| 103 | return orig.call(this, v); |
| 104 | }; |
| 105 | } |
| 106 | if (b === 'stuck') { |
| 107 | // The rule as it stood BEFORE notes3: a stored `detail` beats the |
| 108 | // view. Put back from OUTSIDE, because `tileDetail` is a closure -- |
| 109 | // so what is restored is the behaviour rather than the line, which |
| 110 | // is all a check is entitled to notice. It lasts until a tile is |
| 111 | // rendered afresh, which is long enough: the cascade below reads |
| 112 | // the tiles straight after the view moves. |
| 113 | const orig = window.DaimondView.set; |
| 114 | window.DaimondView.set = function (v) { |
| 115 | const r = orig.call(this, v); |
| 116 | const all = JSON.parse(localStorage.getItem('daimond-tile-prefs') || '{}'); |
| 117 | document.querySelectorAll('.session-box[data-id]').forEach(el => { |
| 118 | const d = (all[el.dataset.id] || {}).detail; |
| 119 | if (d) el.dataset.detail = d; |
| 120 | }); |
| 121 | return r; |
| 122 | }; |
| 123 | } |
| 124 | }, BREAK); |
| 125 | } |
| 126 | |
| 127 | await p.evaluate(() => DaimondDiamond.seedDefaults()); |
| 128 | await p.waitForFunction(() => |
| 129 | document.querySelectorAll('#diamond-list .diamond-box[data-id]').length >= 2, |
| 130 | null, { timeout: 20000 }); |
| 131 | |
| 132 | const ids = await p.evaluate(() => |
| 133 | [...document.querySelectorAll('#diamond-list .diamond-box[data-id]')].map(b => b.dataset.id)); |
| 134 | check(ids.length >= 2, 'two Diamonds to compare', String(ids.length)); |
| 135 | |
| 136 | const detailOf = () => p.evaluate(() => |
| 137 | [...document.querySelectorAll('#diamond-list .diamond-box[data-id]')] |
| 138 | .map(b => b.dataset.detail)); |
| 139 | const setView = (v) => p.evaluate((vv) => window.DaimondView.set(vv), v); |
| 140 | |
| 141 | // ══ THE CASCADE ═══════════════════════════════════════════════════ |
| 142 | await setView('simple'); |
| 143 | await p.waitForTimeout(200); |
| 144 | check((await detailOf()).every(d => d === 'simple'), |
| 145 | 'every tile follows the view when it is set to Simple', |
| 146 | (await detailOf()).join(',')); |
| 147 | |
| 148 | await setView('max'); |
| 149 | await p.waitForTimeout(200); |
| 150 | check((await detailOf()).every(d => d === 'max'), |
| 151 | 'and follows it to Max', (await detailOf()).join(',')); |
| 152 | |
| 153 | // The trap. Going Max and back must return every tile, which it only does |
| 154 | // if nothing wrote `max` into them on the way. |
| 155 | await setView('simple'); |
| 156 | await p.waitForTimeout(200); |
| 157 | check((await detailOf()).every(d => d === 'simple'), |
| 158 | 'and BACK -- the view did not write itself into the tiles on the way out', |
| 159 | (await detailOf()).join(',')); |
| 160 | |
| 161 | const stored = await p.evaluate(() => localStorage.getItem('daimond-tile-prefs') || '{}'); |
| 162 | check(!/"detail"/.test(stored), |
| 163 | 'and nothing wrote a per-tile detail on the way -- there is no control that can', |
| 164 | stored.slice(0, 120)); |
| 165 | |
| 166 | // ══ THE TILE'S DIALOG HAS NO LEVEL IN IT ══════════════════════════ |
| 167 | // The control that used to sit here is gone, and the check that used to press |
| 168 | // it is replaced by the reason it existed: a tile must not be able to detach |
| 169 | // itself from the view. It cannot, if there is nothing in the dialog to |
| 170 | // detach it with. |
| 171 | // |
| 172 | // Asked of `[data-level]` and not of `.tile-dlg-level`, which is still worn |
| 173 | // by controls that survived -- the colour reset here, Fold context in a |
| 174 | // chat's dialog. A bare-class check would now be satisfied by furniture that |
| 175 | // has nothing to do with the view. |
| 176 | await p.evaluate((id) => { |
| 177 | document.querySelector(`#diamond-list .diamond-box[data-id="${id}"] .tile-cog`).click(); |
| 178 | }, ids[0]); |
| 179 | await p.waitForSelector('.tile-dlg-card', { timeout: 8000 }); |
| 180 | { |
| 181 | const dlg = await p.evaluate(() => { |
| 182 | const card = document.querySelector('.tile-dlg-card'); |
| 183 | return { |
| 184 | levels: [...card.querySelectorAll('[data-level]')].map(b => b.dataset.level), |
| 185 | // The words as well as the markup: a control rebuilt under another |
| 186 | // class would pass a selector check and still be the thing notes3 |
| 187 | // took out. Buttons only -- a model pulldown may legitimately hold |
| 188 | // a model with "max" in its name, and that is not a view control. |
| 189 | named: [...card.querySelectorAll('button')] |
| 190 | .map(b => (b.textContent || '').trim()) |
| 191 | .filter(w => /^(simple|max)$/i.test(w)), |
| 192 | closer: !!card.querySelector('.tile-dlg-title .tile-dlg-done'), |
| 193 | }; |
| 194 | }); |
| 195 | check(dlg.levels.length === 0, |
| 196 | 'the tile dialog offers no level control -- Simple and Max are global and nothing else', |
| 197 | dlg.levels.join(',') || 'none'); |
| 198 | check(dlg.named.length === 0, |
| 199 | 'and no button in it is labelled with either view', |
| 200 | dlg.named.join(',') || 'none'); |
| 201 | // The way out moved to the top right and KEPT `tile-dlg-done`, which is |
| 202 | // what every close-path in the app and in this suite reaches for. If the |
| 203 | // class had gone with the button, those paths would silently stop closing |
| 204 | // anything. |
| 205 | check(dlg.closer, 'the way out is a cross in the title row, still classed tile-dlg-done'); |
| 206 | } |
| 207 | await p.evaluate(() => { |
| 208 | const d = document.querySelector('.tile-dlg-card .tile-dlg-done'); if (d) d.click(); |
| 209 | }); |
| 210 | await p.waitForTimeout(300); |
| 211 | check(await p.evaluate(() => !document.querySelector('.tile-dlg-card')), |
| 212 | 'and pressing it closes the dialog'); |
| 213 | |
| 214 | // ══ A DETAIL LEFT BY AN OLDER BUILD IS INERT ══════════════════════ |
| 215 | // Written straight into storage on purpose: this is the state a user who |
| 216 | // pressed Max on a tile last month is carrying, and there is no longer any |
| 217 | // control that could produce it. What must not happen is that the tile obeys |
| 218 | // it, because then that user has one dense tile for ever with nothing in the |
| 219 | // app to mend it. |
| 220 | await p.evaluate((id) => { |
| 221 | const all = JSON.parse(localStorage.getItem('daimond-tile-prefs') || '{}'); |
| 222 | all[id] = Object.assign({}, all[id], { detail: 'max' }); |
| 223 | localStorage.setItem('daimond-tile-prefs', JSON.stringify(all)); |
| 224 | }, ids[0]); |
| 225 | await setView('simple'); |
| 226 | await p.waitForTimeout(200); |
| 227 | check((await detailOf()).every(d => d === 'simple'), |
| 228 | 'a tile carrying a stored detail still follows the view', (await detailOf()).join(',')); |
| 229 | await setView('max'); |
| 230 | await p.waitForTimeout(200); |
| 231 | await setView('simple'); |
| 232 | await p.waitForTimeout(200); |
| 233 | check((await detailOf()).every(d => d === 'simple'), |
| 234 | 'and goes on following it, so no tile is stranded out of step', |
| 235 | (await detailOf()).join(',')); |
| 236 | // Taken away again, so the sections below measure tiles and not this fixture. |
| 237 | await p.evaluate((id) => { |
| 238 | const all = JSON.parse(localStorage.getItem('daimond-tile-prefs') || '{}'); |
| 239 | if (all[id]) { delete all[id].detail; localStorage.setItem('daimond-tile-prefs', JSON.stringify(all)); } |
| 240 | }, ids[0]); |
| 241 | |
| 242 | // ══ INVARIANT 2: Simple hides no spending control ═════════════════ |
| 243 | await setView('simple'); |
| 244 | await p.waitForTimeout(250); |
| 245 | { |
| 246 | const vis = await p.evaluate(() => { |
| 247 | const box = document.querySelector('#diamond-list .diamond-box[data-id]'); |
| 248 | const shown = (sel) => { |
| 249 | const e = box.querySelector(sel); |
| 250 | return !!e && getComputedStyle(e).display !== 'none'; |
| 251 | }; |
| 252 | const globalLight = document.querySelector('#pptw-global .pptw'); |
| 253 | return { |
| 254 | light: shown('.pptw'), |
| 255 | cog: shown('.tile-cog'), |
| 256 | name: shown('.session-box-name'), |
| 257 | global: !!globalLight && getComputedStyle(globalLight).display !== 'none', |
| 258 | }; |
| 259 | }); |
| 260 | check(vis.light, 'Simple keeps the tile’s traffic light -- a spending control is never hidden'); |
| 261 | check(vis.global, 'and the global spending control'); |
| 262 | check(vis.name, 'and the name'); |
| 263 | // AND THE TAGS, which the CSS block says in as many words and nothing |
| 264 | // asserted. On 2026-08-20 an agent moved the tag chips out of |
| 265 | // `session-box-meta` onto a row of their own -- a good change -- and the new |
| 266 | // row inherited none of the old rules, so it was given a `display: none` in |
| 267 | // Simple. That reverses "the last two are how a Diamond is found" three |
| 268 | // lines above where it was typed, and the suite stayed green, because every |
| 269 | // check here was about what Simple HIDES and none about what it keeps. |
| 270 | // |
| 271 | // Tagged on purpose rather than hoping a fixture has one: a tile with no |
| 272 | // tags satisfies "no tag is hidden" trivially, which is the vacuous pass |
| 273 | // this whole file is otherwise careful about. |
| 274 | const tagKept = await p.evaluate(async () => { |
| 275 | const box = document.querySelector('#diamond-list .diamond-box[data-id]'); |
| 276 | const id = box.dataset.id; |
| 277 | // Through the ENGINE's own setter and the app's own redraw. Written first |
| 278 | // against a guessed `localStorage` key and a `reloadDiamonds` that does |
| 279 | // not exist -- neither is real, and a probe that seeds state the product |
| 280 | // does not read proves nothing about the product. |
| 281 | await DaimondCore.setDiamondTags(id, ['findme']); |
| 282 | await new Promise(r => setTimeout(r, 400)); |
| 283 | const b2 = document.querySelector(`#diamond-list .diamond-box[data-id="${id}"]`); |
| 284 | const chips = [...(b2 ? b2.querySelectorAll('.tag-sm') : [])]; |
| 285 | // LAYOUT, not the chip's own `display`. Written that way first and the |
| 286 | // break did not redden: hiding the PARENT row leaves each chip computing |
| 287 | // `inline-flex` quite happily -- `display: none` does not cascade, it |
| 288 | // simply stops the subtree being rendered. A rect with area is the only |
| 289 | // reading that answers "can this be seen". |
| 290 | // |
| 291 | // 2026-08-21: THAT LAST SENTENCE STOPPED BEING TRUE, and the app's own |
| 292 | // daimon is what noticed, unprompted. The tag row became a horizontal |
| 293 | // scroller -- `flex-wrap: nowrap; overflow-x: auto`, so every tag renders |
| 294 | // and the row is cut at its edge instead of capping the list with a `+N` |
| 295 | // chip that hid the one thing a tag exists for. A chip past that edge |
| 296 | // STILL REPORTS A RECT WITH WIDTH AND HEIGHT: `getBoundingClientRect` |
| 297 | // gives layout position and knows nothing about clipping or about the |
| 298 | // scroll offset of an ancestor. So `shown === present` went on passing, |
| 299 | // went on catching the `display: none` it was written for, and had |
| 300 | // quietly stopped meaning what it said. It proved RENDERED, not VISIBLE. |
| 301 | // |
| 302 | // A rect with area is still necessary and is no longer sufficient. What |
| 303 | // is measured now is that rect intersected with the band every CLIPPING |
| 304 | // ancestor actually shows -- any overflow that is not `visible`, the tag |
| 305 | // row first among them -- and then with the viewport. That is the same |
| 306 | // question asked properly, and it is the right answer in both CSS states: |
| 307 | // where nothing clips, the loop intersects nothing and the reading is the |
| 308 | // old one, so a plain wrapping row is not made to fail by it. |
| 309 | // |
| 310 | // A CHIP CUT IN HALF AT THE EDGE COUNTS AS VISIBLE, deliberately. The |
| 311 | // CSS says the cut is the affordance -- the chips "say 'more this way' by |
| 312 | // being cut at the edge" -- so half a chip is the design working and any |
| 313 | // positive overlap passes. Demanding a whole chip would redden the day a |
| 314 | // Diamond is filed under a long tag, which is use, not regression. |
| 315 | // |
| 316 | // `shown === present` is a fair thing to ask ONLY because the fixture |
| 317 | // seeds exactly one tag into a full-width rail, where nothing overflows. |
| 318 | // Seed a dozen and the honest assertion changes: chips past the edge |
| 319 | // would then be scrolled-away rather than lost, and this would have to |
| 320 | // become "the row is visible and every chip lies within its scrollable |
| 321 | // content". Add a tag to the line above and this check has to move too. |
| 322 | // |
| 323 | // --break tags the row hidden -- the original defect, still red |
| 324 | // --break scrolled the chips drawn, laid out past the row's own edge |
| 325 | const seen = (el) => { |
| 326 | const r = el.getBoundingClientRect(); |
| 327 | if (r.width <= 0 || r.height <= 0) return false; |
| 328 | let l = r.left, tp = r.top, rt = r.right, bt = r.bottom; |
| 329 | for (let a = el.parentElement; a; a = a.parentElement) { |
| 330 | const cs = getComputedStyle(a); |
| 331 | // An overflow that is not `visible` clips on BOTH axes: setting one |
| 332 | // axis alone computes the other to `auto`, which clips as well. |
| 333 | if (cs.overflowX === 'visible' && cs.overflowY === 'visible') continue; |
| 334 | const ar = a.getBoundingClientRect(); |
| 335 | l = Math.max(l, ar.left); tp = Math.max(tp, ar.top); |
| 336 | rt = Math.min(rt, ar.right); bt = Math.min(bt, ar.bottom); |
| 337 | if (rt <= l || bt <= tp) return false; |
| 338 | } |
| 339 | // The viewport clips everything, and clips it last. |
| 340 | l = Math.max(l, 0); tp = Math.max(tp, 0); |
| 341 | rt = Math.min(rt, window.innerWidth); bt = Math.min(bt, window.innerHeight); |
| 342 | return rt > l && bt > tp; |
| 343 | }; |
| 344 | return { |
| 345 | present: chips.length, |
| 346 | shown: chips.filter(seen).length, |
| 347 | }; |
| 348 | }); |
| 349 | check(tagKept.present > 0, |
| 350 | 'a tagged Diamond draws its tag chip at all -- the check below needs a subject', |
| 351 | `chips=${tagKept.present}`); |
| 352 | check(tagKept.present > 0 && tagKept.shown === tagKept.present, |
| 353 | 'SIMPLE KEEPS THE TAGS -- they are how a Diamond is found', |
| 354 | `${tagKept.shown} of ${tagKept.present} visible`); |
| 355 | // ══ INVARIANT 1: the way in is always visible ═════════════════ |
| 356 | check(vis.cog, 'and the cog, which is the route to everything Simple hides'); |
| 357 | |
| 358 | // The status header is rows of BUTTONS -- Version opens the release |
| 359 | // history, Tools opens the panel that tells a user what the app can do. |
| 360 | // Simple hid three of them for a while, which removes the route rather |
| 361 | // than the detail. That is invariant 1, and it needs its own check |
| 362 | // because the temptation to tidy the header will come back. |
| 363 | const head = await p.evaluate(() => { |
| 364 | const v = id => { |
| 365 | const e = document.getElementById(id); |
| 366 | return !!e && getComputedStyle(e).display !== 'none'; |
| 367 | }; |
| 368 | return { model: v('astat-model'), tools: v('astat-tools'), release: v('astat-release') }; |
| 369 | }); |
| 370 | check(head.tools && head.release && head.model, |
| 371 | 'Simple keeps every status row that is a way IN -- hiding a button hides a route', |
| 372 | JSON.stringify(head)); |
| 373 | } |
| 374 | |
| 375 | // ══ INVARIANT 1, in full: nothing is Max-only ═════════════════════ |
| 376 | // Everything hidden in Simple must be reachable through the dialog. Read the |
| 377 | // hidden set from the live page rather than from a list here, so a new thing |
| 378 | // hidden tomorrow is covered without editing this file. |
| 379 | { |
| 380 | // Give the Diamond a model and some spend, so the things Max hoists -- |
| 381 | // the model chip and the per-object cost -- exist on the tile at all. |
| 382 | // Both are built at render time, so this is followed by a reload: a |
| 383 | // storage write after the tile is drawn changes nothing on screen, which |
| 384 | // is a fact about the app and not a thing to work around. |
| 385 | await p.evaluate((id) => { |
| 386 | const all = JSON.parse(localStorage.getItem('daimond-diamond-models') || '{}'); |
| 387 | all[id] = { model: 'mock/fast', provider: 'custom' }; |
| 388 | localStorage.setItem('daimond-diamond-models', JSON.stringify(all)); |
| 389 | for (let i = 0; i < 3; i++) { |
| 390 | DaimondSignals.noteTurn({ ts: Date.now(), diamondId: id, model: 'mock/fast', usd: 0.02 }); |
| 391 | } |
| 392 | }, ids[1]); |
| 393 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 394 | await signInAs(s, 'view'); // a reload locks the app, as it should |
| 395 | await p.waitForSelector('#diamond-list .diamond-box[data-id]', { timeout: 20000 }); |
| 396 | await p.waitForTimeout(900); |
| 397 | |
| 398 | const shownIn = async (view) => { |
| 399 | await setView(view); |
| 400 | await p.waitForTimeout(300); |
| 401 | return p.evaluate((id) => { |
| 402 | const box = document.querySelector(`#diamond-list .diamond-box[data-id="${id}"]`); |
| 403 | return [...box.querySelectorAll('*')] |
| 404 | .filter(e => getComputedStyle(e).display !== 'none') |
| 405 | .map(e => (e.className || '').toString().split(' ')[0]) |
| 406 | .filter(Boolean); |
| 407 | }, ids[1]); |
| 408 | }; |
| 409 | const inMax = await shownIn('max'); |
| 410 | const inSimple = await shownIn('simple'); |
| 411 | check(inSimple.length < inMax.length, |
| 412 | 'Simple shows strictly less of the same tile than Max does', |
| 413 | `${inSimple.length} vs ${inMax.length}`); |
| 414 | check(inSimple.every(c => inMax.indexOf(c) >= 0), |
| 415 | 'and nothing appears in Simple that Max does not have -- Simple is a subset, not a second layout', |
| 416 | inSimple.filter(c => inMax.indexOf(c) < 0).join(',') || 'subset'); |
| 417 | const modelish = (list) => list.filter(c => /tile-model|tile-worker|session-box-spend|tile-meter/.test(c)); |
| 418 | check(modelish(inMax).length > 0, |
| 419 | 'Max shows the model and cost controls on the tile', modelish(inMax).join(',') || '(none)'); |
| 420 | check(modelish(inSimple).length === 0, |
| 421 | 'Simple hides them, as asked', modelish(inSimple).join(',') || 'none'); |
| 422 | const hidden = modelish(inMax); |
| 423 | await p.evaluate((id) => { |
| 424 | document.querySelector(`#diamond-list .diamond-box[data-id="${id}"] .tile-cog`).click(); |
| 425 | }, ids[1]); |
| 426 | await p.waitForSelector('.tile-dlg-card', { timeout: 8000 }); |
| 427 | const dlg = await p.evaluate(() => { |
| 428 | const c = document.querySelector('.tile-dlg-card'); |
| 429 | return { controls: c.querySelectorAll('button, select, input, textarea').length, |
| 430 | text: c.textContent.replace(/\s+/g, ' ') }; |
| 431 | }); |
| 432 | check(dlg.controls > 4, |
| 433 | 'the dialog carries the controls Simple took off the tile', |
| 434 | dlg.controls + ' controls'); |
| 435 | // The model pulldowns are the concrete case the user named. |
| 436 | check(/model/i.test(dlg.text), |
| 437 | 'and the dialog is where they are instead'); |
| 438 | await p.evaluate(() => { |
| 439 | const d = document.querySelector('.tile-dlg-done'); if (d) d.click(); |
| 440 | }); |
| 441 | await p.waitForTimeout(250); |
| 442 | } |
| 443 | |
| 444 | // ══ MAX FITS A PHONE ══════════════════════════════════════════════ |
| 445 | await setView('max'); |
| 446 | await p.setViewportSize({ width: 430, height: 800 }); |
| 447 | await p.waitForTimeout(400); |
| 448 | { |
| 449 | const over = await p.evaluate(() => ({ |
| 450 | doc: document.documentElement.scrollWidth, |
| 451 | win: window.innerWidth, |
| 452 | })); |
| 453 | check(over.doc <= over.win + 1, |
| 454 | 'Max at 430px does not push the page sideways -- if the hoisted set does not fit, too much is hoisted', |
| 455 | `${over.doc} in ${over.win}`); |
| 456 | } |
| 457 | await p.setViewportSize({ width: 1500, height: 950 }); |
| 458 | |
| 459 | // ══ MIGRATION ═════════════════════════════════════════════════════ |
| 460 | { |
| 461 | const mapped = await p.evaluate(() => { |
| 462 | const out = {}; |
| 463 | for (const [skin, want] of [['sharp', 'max'], ['warm', 'simple'], [null, 'simple']]) { |
| 464 | localStorage.removeItem('daimond-view'); |
| 465 | if (skin === null) localStorage.removeItem('daimond-skin'); |
| 466 | else localStorage.setItem('daimond-skin', skin); |
| 467 | // initView is not exported; reproduce its one rule and assert the |
| 468 | // mapping rather than the function. |
| 469 | const saved = localStorage.getItem('daimond-view'); |
| 470 | const got = saved || (localStorage.getItem('daimond-skin') === 'sharp' ? 'max' : 'simple'); |
| 471 | out[String(skin)] = { got, want }; |
| 472 | } |
| 473 | return out; |
| 474 | }); |
| 475 | check(mapped.sharp.got === 'max', |
| 476 | 'a stored "Compact" becomes Max -- that user wanted the most on screen', mapped.sharp.got); |
| 477 | check(mapped.warm.got === 'simple', |
| 478 | 'a stored "Breathe" becomes Simple -- that user wanted calm', mapped.warm.got); |
| 479 | check(mapped.null.got === 'simple', |
| 480 | 'and an unset skin is not a choice, so it becomes Simple', mapped.null.got); |
| 481 | } |
| 482 | } catch (e) { |
| 483 | failures++; |
| 484 | console.log(' FAIL threw -- ' + (e && e.message ? e.message.split('\n')[0] : e)); |
| 485 | } finally { |
| 486 | await s.close(); |
| 487 | } |
| 488 | |
| 489 | console.log(''); |
| 490 | console.log(failures ? `verify_view: ${failures} FAILED` : 'verify_view: all checks pass.'); |
| 491 | process.exit(failures ? 1 : 0); |