oxedyne/daimond/dev/verify_tags.mjs
65.8 KiB, 1 run
created by r2519314175:725, 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 Diamond tags: chips, search, the standing tag pool, the tag editor, |
| 2 | // persistence, and the backup round trip -- against the real wasm, no stubs. |
| 3 | // |
| 4 | // Tags are the user's filing system and nothing else, so the check that |
| 5 | // matters most is the one proving a tag never reaches the model. |
| 6 | // |
| 7 | // The filter used to be drawn only once a tag had been clicked, and the only |
| 8 | // chip to click sat on a Diamond box -- so the space under the search box, which |
| 9 | // is where a filter goes, was empty until you had already found another way in. |
| 10 | // It is a POOL now: every tag in use, each chip cycling off -> wanted -> |
| 11 | // refused -> off where it sits. The checks that used to read a summary read the |
| 12 | // pool instead; the ones that asserted a summary chip's closer now assert the |
| 13 | // stronger thing, that a pool chip's text is the bare tag. |
| 14 | // |
| 15 | // The pool STOOD, and that was a rent it could not pay: thirty tags is three and |
| 16 | // a half rows of chips under the search box on every screen, whether anything is |
| 17 | // being filtered or not, and the rail's two lists live on what is left. It sits |
| 18 | // behind a DISCLOSURE now -- one muted row naming the feature and counting what |
| 19 | // is behind it, in the place and the ink of the "No tags yet" line, so the |
| 20 | // question "is there a filter here?" is answered before it is asked either way. |
| 21 | // The checks that asserted the pool stands by default now assert the disclosure |
| 22 | // stands by default: what is superseded is the pool being permanently up, not |
| 23 | // the pool. Two properties are new and load-bearing -- the choice is kept across |
| 24 | // a reload, and a filter that is ON is never invisible, because closed the chips |
| 25 | // holding it come out and stand on their own. |
| 26 | |
| 27 | import fs from 'node:fs'; |
| 28 | import { open, shot, errors, signInAs, mockLog, clearMockLog, scratch } from './harness.mjs'; |
| 29 | |
| 30 | const s = await open({ name: 'tags', defaults: false }); |
| 31 | const { page } = s; |
| 32 | const out = []; |
| 33 | const say = (ok, what) => { out.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); return ok; }; |
| 34 | let bad = 0; |
| 35 | const check = (ok, what) => { if (!say(ok, what)) bad++; }; |
| 36 | // A failure that is real, understood, and not this change's. Reported loudly, |
| 37 | // but it does not fail the run: see the note at the backup round trip. |
| 38 | const known = []; |
| 39 | const checkKnown = (ok, what, why) => { if (!ok) known.push(`${what}\n ${why}`); }; |
| 40 | |
| 41 | const boxes = () => page.$$eval('.diamond-box .session-box-name', els => els.map(e => e.textContent)); |
| 42 | const railOf = () => page.$$eval('.diamond-box', els => els.map(e => ({ |
| 43 | name: (e.querySelector('.session-box-name') || {}).textContent, |
| 44 | tags: [...e.querySelectorAll('.session-box-tags .tag-chip')].map(c => c.textContent), |
| 45 | more: (e.querySelector('.tag-more') || {}).textContent || null, |
| 46 | }))); |
| 47 | |
| 48 | /// Reach the real wasm directly. The page has already run init(), so a fresh |
| 49 | /// DaimondApp shares its memory and its OPFS -- this is the same store the UI |
| 50 | /// is reading, not a copy. |
| 51 | const wasm = (fn, arg) => page.evaluate(async ({ src, arg }) => { |
| 52 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 53 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 54 | return await (new Function('app', 'arg', `return (${src})(app, arg);`))(app, arg); |
| 55 | }, { src: fn.toString(), arg }); |
| 56 | |
| 57 | /// How the rail's two lists are sharing it. The furniture above them is cut out |
| 58 | /// of the same column, so anything that changes the furniture's height has to |
| 59 | /// make that cut again or the divider walks. |
| 60 | const shareOn = (pg) => pg.evaluate(() => { |
| 61 | const l = document.getElementById('diamond-list').getBoundingClientRect().height; |
| 62 | const c = document.getElementById('session-list').getBoundingClientRect().height; |
| 63 | return { list: Math.round(l), sess: Math.round(c), share: +(l / (l + c)).toFixed(3), |
| 64 | handle: !!document.getElementById('handle-rail-split') }; |
| 65 | }); |
| 66 | |
| 67 | /// Open or close the pool, by the one control a user has for it. A no-op when it |
| 68 | /// is already the way round it was asked for. |
| 69 | const setPoolOn = async (pg, want) => { |
| 70 | const now = await pg.$eval('#diamond-filter .tagf-toggle', |
| 71 | b => b.getAttribute('aria-expanded') === 'true').catch(() => null); |
| 72 | if (now === null || now === want) return now; |
| 73 | await pg.click('#diamond-filter .tagf-toggle', { force: true }); |
| 74 | await pg.waitForTimeout(400); |
| 75 | return want; |
| 76 | }; |
| 77 | |
| 78 | // An empty account is already told "No Diamonds yet." -- a second line about |
| 79 | // tags there would be nagging about a thing there is nothing to do it to. |
| 80 | check(!(await page.isVisible('#diamond-tag-hint')), 'no tag hint on an account with no Diamonds at all'); |
| 81 | |
| 82 | // ── Create three Diamonds ──────────────────────────────────────────── |
| 83 | async function newDiamond(name) { |
| 84 | // NOT `{ force: true }`. A force-click reaches a button a person could not, |
| 85 | // and this suite's subject is what the rail does to a Diamond you create -- |
| 86 | // so "the button was reachable" is part of what it is asserting. The force |
| 87 | // hid the Admin drawer sitting over it for the last two sessions. |
| 88 | await page.click('#new-diamond-btn', { timeout: 8000 }); |
| 89 | await page.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 90 | await page.fill('.dlg-input', name); |
| 91 | await page.click('.dlg-ok', { force: true }); |
| 92 | await page.waitForTimeout(700); |
| 93 | } |
| 94 | for (const n of ['Ship a CSV parser', 'Mum birthday plan', 'Rust compiler notes']) await newDiamond(n); |
| 95 | check((await boxes()).length === 3, 'three Diamonds in the rail'); |
| 96 | |
| 97 | // The contract says every row now carries tags, [] when there are none. |
| 98 | const rawRows = await wasm(async (app) => JSON.parse(await app.list_diamonds())); |
| 99 | check(rawRows.length === 3 && rawRows.every(r => Array.isArray(r.tags) && r.tags.length === 0), |
| 100 | 'list_diamonds rows carry "tags":[] when a Diamond has none'); |
| 101 | |
| 102 | // An untagged Diamond must look exactly as it did before tags existed. |
| 103 | // |
| 104 | // THE WHOLE TILE, not the meta row. This read `.session-box-meta`'s innerHTML |
| 105 | // until 2026-08-21, and on 2026-08-20 the chips moved out of that row onto one of |
| 106 | // their own -- so the check went on passing while asking a question whose answer |
| 107 | // had become "no" by construction. A chip could have been drawn on every untagged |
| 108 | // Diamond in the rail and this would have said nothing. |
| 109 | const untaggedMeta = await page.$$eval('.diamond-box', els => |
| 110 | els.map(e => e.innerHTML || '')); |
| 111 | check(untaggedMeta.every(h => !h.includes('tag-chip')), 'untagged Diamonds render no chips (zero regression)'); |
| 112 | |
| 113 | // ── The tag pool's empty state ─────────────────────────────────── |
| 114 | // Every chip in the rail is drawn from a Diamond, so a store with no tag on |
| 115 | // anything draws none, and a rail that is just a search box reads as filing |
| 116 | // that was taken away rather than filing not yet done. That is how the tag |
| 117 | // loss was read, twice. Say which, where the chips would be. |
| 118 | const hintText = () => page.$eval('#diamond-tag-hint', e => e.textContent).catch(() => ''); |
| 119 | check(await page.isVisible('#diamond-tag-hint'), |
| 120 | 'a store with Diamonds but no tags shows a hint where the chips would be'); |
| 121 | check((await hintText()).includes('No tags yet'), |
| 122 | `the hint says the tags are missing, not the feature: ${JSON.stringify(await hintText())}`); |
| 123 | // It must not sit anywhere a chip's text is read from: the filter chip's text |
| 124 | // IS a tag name, and the list's text is read for "No Diamonds match". |
| 125 | check(await page.$eval('#diamond-tag-hint', |
| 126 | e => !e.closest('#diamond-filter') && !e.closest('#diamond-list')).catch(() => false), |
| 127 | 'the hint sits outside the filter chip and outside the Diamonds list'); |
| 128 | check(!(await page.isVisible('#diamond-filter')), 'and it does not raise an empty filter chip beside it'); |
| 129 | // The route to a first tag, for the reader who wants it, in the order the pool |
| 130 | // really offers them -- a hint promising chips the editor does not offer would |
| 131 | // be the same lie in a smaller font. |
| 132 | // The starters are translated, so they are read from the table rather than |
| 133 | // spelled out here: a test that hardcodes the English four passes in English |
| 134 | // and fails in the other seven for no reason but its own assumption. |
| 135 | const STARTERS = fs.readFileSync(new URL('../www/i18n/en.js', import.meta.url), 'utf8') |
| 136 | .match(/'tag\.starters':\s*'([^']*)'/)[1] |
| 137 | .split(',').map(x => x.trim()).filter(Boolean); |
| 138 | const hintTip = await page.$eval('#diamond-tag-hint', e => e.title).catch(() => ''); |
| 139 | check(hintTip.includes(STARTERS.join(', ')), |
| 140 | `the hint's tooltip names the starter tags in the pool's order: ${JSON.stringify(hintTip)}`); |
| 141 | await shot(s, 'tags-empty-hint'); |
| 142 | |
| 143 | // The order tagging must not disturb. |
| 144 | const orderBefore = await boxes(); |
| 145 | |
| 146 | // ── Tag the first Diamond through the editor ─────────────────────── |
| 147 | async function openTagEditor(name) { |
| 148 | const idx = (await boxes()).indexOf(name); |
| 149 | await page.$$eval('.diamond-box', (els, i) => els[i].click(), idx); |
| 150 | await page.waitForTimeout(500); |
| 151 | for (const b of await page.$$('.crystal-act')) { |
| 152 | if ((await b.textContent()).includes('Tags')) { await b.click({ force: true }); break; } |
| 153 | } |
| 154 | await page.waitForSelector('.tag-editor', { timeout: 5000 }); |
| 155 | } |
| 156 | const editorTags = () => page.$$eval('.tag-row:not(.tag-sug) .tag-chip', |
| 157 | els => els.map(e => e.textContent.replace('×', ''))); |
| 158 | |
| 159 | await openTagEditor('Ship a CSV parser'); |
| 160 | check(await page.isVisible('.tag-editor'), 'tag editor opens from the crystal bar'); |
| 161 | const sugs = await page.$$eval('.tag-sug .tag-chip', els => els.map(e => e.textContent)); |
| 162 | check(JSON.stringify(sugs) === JSON.stringify(STARTERS), |
| 163 | `default suggestions offered: ${JSON.stringify(sugs)}`); |
| 164 | |
| 165 | // One click adds a suggestion. |
| 166 | const sugChips = await page.$$('.tag-sug .tag-chip'); |
| 167 | await sugChips[0].click({ force: true }); |
| 168 | await page.waitForTimeout(600); |
| 169 | // Type a custom one, shouty and padded, to prove the real normalisation lands. |
| 170 | await page.fill('.tag-input', ' RUST '); |
| 171 | await page.keyboard.press('Enter'); |
| 172 | await page.waitForTimeout(600); |
| 173 | const et = await editorTags(); |
| 174 | check(et.includes('person') && et.includes('rust'), `editor shows current tags, normalised: ${JSON.stringify(et)}`); |
| 175 | |
| 176 | // ── The two boxes: a closed chip lands back in the pool ────────── |
| 177 | // Close "rust" above; it must leave the Diamond AND reappear below, where |
| 178 | // one click brings it back -- the pool holds every tag, not a fixed list. |
| 179 | await page.click('.tag-chip.tag-edit .tag-x >> nth=-1', { force: true }); // rust is newest, last |
| 180 | await page.waitForTimeout(600); |
| 181 | const afterClose = await editorTags(); |
| 182 | const pool = await page.$$eval('.tag-sug .tag-chip', els => els.map(e => e.textContent)); |
| 183 | check(!afterClose.includes('rust') && pool.includes('rust'), |
| 184 | `a closed tag returns to the pool: on=${JSON.stringify(afterClose)} pool has rust=${pool.includes('rust')}`); |
| 185 | const rustChip = await page.$$('.tag-sug .tag-chip'); |
| 186 | for (const c of rustChip) { |
| 187 | if ((await c.textContent()) === 'rust') { await c.click({ force: true }); break; } |
| 188 | } |
| 189 | await page.waitForTimeout(600); |
| 190 | check((await editorTags()).includes('rust'), 'one click in the pool restores it'); |
| 191 | |
| 192 | // ── Tagging must not reorder the rail ──────────────────────────── |
| 193 | // The Rust set_tags deliberately leaves `updated` alone: filing a Diamond is not |
| 194 | // touching it, and a rail that reshuffles under a tag edit is a rail you |
| 195 | // cannot tag twice without losing your place. |
| 196 | await page.click('.crystal-act', { force: true }); // ← Back to the crystal |
| 197 | await page.waitForTimeout(500); |
| 198 | const orderAfter = await boxes(); |
| 199 | check(JSON.stringify(orderBefore) === JSON.stringify(orderAfter), |
| 200 | `rail order is stable across a tag edit: ${JSON.stringify(orderAfter)}`); |
| 201 | |
| 202 | // ── The hint goes when there is something to filter with ───────── |
| 203 | const tagged = (await railOf()).find(r => r.name === 'Ship a CSV parser'); |
| 204 | check(tagged && tagged.tags.length === 2 && !(await page.isVisible('#diamond-tag-hint')), |
| 205 | `one tag anywhere replaces the hint with chips: chips=${JSON.stringify(tagged && tagged.tags)}`); |
| 206 | |
| 207 | // ── The standing pool ──────────────────────────────────────────── |
| 208 | // The filter used to be drawn only once something had been clicked, and the |
| 209 | // only thing to click was a chip on a Diamond box. So a reader who went looking |
| 210 | // for the filter under the search box -- which is where a filter goes -- found |
| 211 | // nothing there, and concluded there wasn't one. The pool is the surface now: |
| 212 | // it is up before anything is touched, and it says what the vocabulary is. |
| 213 | /// Every chip in the pool, in the order the pool offers them. |
| 214 | const poolText = () => page.$$eval('#diamond-filter .tagf-pool .tag-chip', |
| 215 | els => els.map(e => e.textContent)); |
| 216 | /// Each pool chip and the state its class says the rail is holding it in. |
| 217 | const poolState = () => page.$$eval('#diamond-filter .tagf-pool .tag-chip', els => els.map(e => ({ |
| 218 | t: e.textContent, |
| 219 | s: e.classList.contains('tag-inc') ? 'inc' : e.classList.contains('tag-no') ? 'exc' : 'off', |
| 220 | }))); |
| 221 | const stateOf = (st, tag) => (st.find(x => x.t === tag) || { s: 'ABSENT' }).s; |
| 222 | /// One click on a pool chip: the same cycle a chip on a Diamond box walks. |
| 223 | const poolClick = async (tag) => { |
| 224 | const hit = await page.$$eval('#diamond-filter .tagf-pool .tag-chip', (els, n) => { |
| 225 | for (const e of els) if (e.textContent === n) { e.click(); return true; } |
| 226 | return false; |
| 227 | }, tag); |
| 228 | if (!hit) check(false, `the pool offers a "${tag}" chip to click`); |
| 229 | await page.waitForTimeout(400); |
| 230 | }; |
| 231 | /// Round the cycle until the tag is filtering nothing. Bounded: the cycle is |
| 232 | /// three legs, so a fourth click would mean it does not close. |
| 233 | const poolOff = async (tag) => { |
| 234 | for (let i = 0; i < 3 && stateOf(await poolState(), tag) !== 'off'; i++) await poolClick(tag); |
| 235 | }; |
| 236 | /// One click on a tag chip on a Diamond BOX -- the other way into the filter, |
| 237 | /// and the only one there is while the pool is closed. |
| 238 | const clickTag = async (name) => { |
| 239 | await page.$$eval('.session-box-tags .tag-chip', (els, n) => { |
| 240 | for (const e of els) if (e.textContent === n) { e.click(); return; } |
| 241 | }, name); |
| 242 | await page.waitForTimeout(400); |
| 243 | }; |
| 244 | |
| 245 | // ── The disclosure the pool sits behind ────────────────────────── |
| 246 | // One row, always there once a tag is, naming the feature and counting what is |
| 247 | // behind it. This is the whole of the answer to both faults at once: a reader |
| 248 | // who goes looking under the search box finds the filter named, and a reader |
| 249 | // who never touches it pays one line for it instead of four. |
| 250 | |
| 251 | /// The disclosure row: what it says, what it is holding, and how much of the |
| 252 | /// rail it takes -- a label is a word to hit, a row is a row. |
| 253 | const disc = () => page.evaluate(() => { |
| 254 | const b = document.querySelector('#diamond-filter .tagf-toggle'); |
| 255 | if (!b) return null; |
| 256 | const f = document.getElementById('diamond-filter'); |
| 257 | const chev = document.querySelector('#diamond-filter .tagf-chev'); |
| 258 | const cs = chev ? getComputedStyle(chev) : null; |
| 259 | return { |
| 260 | text: b.textContent.trim(), |
| 261 | open: b.getAttribute('aria-expanded') === 'true', |
| 262 | pool: !!document.querySelector('#diamond-filter .tagf-pool'), |
| 263 | w: Math.round(b.getBoundingClientRect().width), |
| 264 | fw: Math.round(f.getBoundingClientRect().width), |
| 265 | // The chevron must be INK, not a character: borders on an empty box, |
| 266 | // turned. A font glyph would be one missing-glyph box away from saying |
| 267 | // nothing at all, in a UI that ships eight languages. |
| 268 | chev: cs ? { rt: cs.borderRightWidth, bt: cs.borderBottomWidth, |
| 269 | tr: cs.transform, txt: chev.textContent } : null, |
| 270 | }; |
| 271 | }); |
| 272 | /// What the filter is saying while the pool is CLOSED: the chips that are |
| 273 | /// holding it, standing on their own. |
| 274 | const astate = () => page.evaluate(() => { |
| 275 | const f = document.getElementById('diamond-filter'); |
| 276 | const row = f && f.querySelector('.tagf-active'); |
| 277 | const chips = row ? [...row.querySelectorAll('.tag-chip')] : []; |
| 278 | return { |
| 279 | row: !!row, |
| 280 | pool: !!(f && f.querySelector('.tagf-pool')), |
| 281 | ctl: !!(f && f.querySelector('.tagf-ctl')), |
| 282 | chips: chips.map(c => ({ |
| 283 | t: c.textContent, |
| 284 | s: c.classList.contains('tag-inc') ? 'inc' : c.classList.contains('tag-no') ? 'exc' : 'off', |
| 285 | })), |
| 286 | }; |
| 287 | }); |
| 288 | /// One click on a chip in the collapsed active row. |
| 289 | const actClick = async (tag) => { |
| 290 | const hit = await page.$$eval('#diamond-filter .tagf-active .tag-chip', (els, n) => { |
| 291 | for (const e of els) if (e.textContent === n) { e.click(); return true; } |
| 292 | return false; |
| 293 | }, tag); |
| 294 | if (!hit) check(false, `the active row offers a "${tag}" chip to click`); |
| 295 | await page.waitForTimeout(400); |
| 296 | }; |
| 297 | |
| 298 | const d0 = await disc(); |
| 299 | check(!!d0 && !d0.pool, |
| 300 | `the pool is DOWN by default -- it is rent the rail pays on every screen: ${JSON.stringify(d0)}`); |
| 301 | check(!!d0 && d0.open === false, 'and the disclosure says so, in the attribute a screen reader reads'); |
| 302 | check(!!d0 && /Filter by tag/.test(d0.text) && /\(2\)/.test(d0.text), |
| 303 | `the row names the feature and counts what is behind it: ${JSON.stringify(d0 && d0.text)}`); |
| 304 | // The fault this replaces was a feature read as ABSENT, twice. A row that hid |
| 305 | // its own count would be the same mistake: "Filter by tag" with nothing behind |
| 306 | // it and "Filter by tag" with twelve are different offers. |
| 307 | check(!!d0 && d0.w >= d0.fw - 1, |
| 308 | `and the whole row is the target, not the words -- at 250px a rail is mostly empty on the right: ${d0 && d0.w}px of ${d0 && d0.fw}px`); |
| 309 | check(!!d0 && !!d0.chev && parseFloat(d0.chev.rt) > 0 && parseFloat(d0.chev.bt) > 0 |
| 310 | && d0.chev.tr !== 'none' && d0.chev.txt === '', |
| 311 | `the chevron is drawn in CSS, not typed: ${JSON.stringify(d0 && d0.chev)}`); |
| 312 | check(!!d0 && !/[▸▾►▼▶#⌄›»→]/.test(d0.text), |
| 313 | `so no glyph rides in the label's text, where a missing font would eat it: ${JSON.stringify(d0 && d0.text)}`); |
| 314 | await shot(s, 'tags-pool-collapsed'); |
| 315 | |
| 316 | // Opening it, and the divider not moving when it does. The furniture grows by |
| 317 | // a whole pool here; the two lists below are cut out of the same column. |
| 318 | const shareShut = await shareOn(page); |
| 319 | await setPoolOn(page, true); |
| 320 | const dOpen = await disc(); |
| 321 | const shareOpen = await shareOn(page); |
| 322 | check(!!dOpen && dOpen.open && dOpen.pool, 'one click opens the pool, and the row says which way it is now'); |
| 323 | check(shareOpen.list + shareOpen.sess < shareShut.list + shareShut.sess, |
| 324 | `opening it really does take height off the two lists: ${shareShut.list + shareShut.sess}px -> ${shareOpen.list + shareOpen.sess}px`); |
| 325 | check(Math.abs(shareOpen.share - shareShut.share) <= 0.03, |
| 326 | `and the divider holds its share rather than walking: ${shareShut.share} -> ${shareOpen.share}`); |
| 327 | |
| 328 | check(await page.isVisible('#diamond-filter .tagf-pool'), |
| 329 | 'the tag pool is one click under the search box the moment a tag exists'); |
| 330 | check(JSON.stringify(await poolText()) === JSON.stringify(['person', 'rust']), |
| 331 | `and holds the tags actually in use, sorted: ${JSON.stringify(await poolText())}`); |
| 332 | // The editor offers `project`, `topic` and `org` whatever the store holds. The |
| 333 | // rail must not: filtering on a tag no Diamond carries can only empty the rail, |
| 334 | // so a chip for one would be a control whose single outcome is nothing. |
| 335 | check(!(await poolText()).some(x => ['project', 'topic', 'org'].includes(x)), |
| 336 | `the starter suggestions nobody has used are NOT offered as filters: ${JSON.stringify(await poolText())}`); |
| 337 | // The chips are the reason every reader of a chip can strip a closer and be |
| 338 | // wrong: here there is nothing to strip. |
| 339 | check((await poolText()).every(x => x === x.trim() && !/[׬]/.test(x)), |
| 340 | `a pool chip's text is the bare tag -- no closer, no mark: ${JSON.stringify(await poolText())}`); |
| 341 | await shot(s, 'tags-pool-idle'); |
| 342 | await shot(s, 'tags-chips-no-hint'); |
| 343 | |
| 344 | // ── The choice is kept, and a live filter is never invisible ───── |
| 345 | // Opening the pool is a habit of working, like the ALL/ANY mode, so it lasts |
| 346 | // past the page. Closing it must not take the FILTER with it: a rail that is |
| 347 | // hiding Diamonds and will not say why is the fault this whole surface exists |
| 348 | // to avoid, one size worse than the one it replaced. |
| 349 | await setPoolOn(page, false); |
| 350 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 351 | await signInAs(s, 'tags'); |
| 352 | await page.waitForTimeout(1500); |
| 353 | const dReload = await disc(); |
| 354 | check(!!dReload && !dReload.open && !dReload.pool, |
| 355 | `the pool stays down across a reload, because that is what was chosen: ${JSON.stringify(dReload)}`); |
| 356 | |
| 357 | // The one path that reaches the filter with the pool closed: a chip on a |
| 358 | // Diamond box. It must surface the state where the pool would have shown it. |
| 359 | await clickTag('person'); |
| 360 | const aInc = await astate(); |
| 361 | check(aInc.row && !aInc.pool && JSON.stringify(aInc.chips) === JSON.stringify([{ t: 'person', s: 'inc' }]), |
| 362 | `closed, a filter puts its OWN chips up rather than nothing: ${JSON.stringify(aInc)}`); |
| 363 | check(aInc.ctl, 'and the controls that are not tags come with them, exactly as they do open'); |
| 364 | // Only "Ship a CSV parser" carries a tag at this point in the pass. |
| 365 | check((await boxes()).length === 1, 'the filter really is on while the pool is down'); |
| 366 | await shot(s, 'tags-pool-collapsed-active'); |
| 367 | // The same chip, cycling the same way it does in the pool -- it IS a pool chip, |
| 368 | // standing somewhere else, so there is no second behaviour to keep in step. |
| 369 | await actClick('person'); |
| 370 | const aExc = await astate(); |
| 371 | check(JSON.stringify(aExc.chips) === JSON.stringify([{ t: 'person', s: 'exc' }]) |
| 372 | && (await boxes()).length === 2, |
| 373 | `a chip in the active row cycles in place, and refusing takes its carrier off the rail: ${JSON.stringify(aExc)}, ${JSON.stringify(await boxes())}`); |
| 374 | await actClick('person'); |
| 375 | const aOff = await astate(); |
| 376 | check(!aOff.row && !aOff.ctl && !aOff.pool && (await boxes()).length === 3, |
| 377 | `and the row goes when the last tag comes off the filter, leaving the one row: ${JSON.stringify(aOff)}`); |
| 378 | // Opening it while a filter is on shows the state IN the pool, where each chip |
| 379 | // already carries its own -- so the two rows are never both saying it. |
| 380 | await clickTag('person'); |
| 381 | await setPoolOn(page, true); |
| 382 | const aOpen = await astate(); |
| 383 | check(!aOpen.row && aOpen.pool && stateOf(await poolState(), 'person') === 'inc', |
| 384 | `open, the active row stands down: the pool chip is already wearing the state: ${JSON.stringify(aOpen)}`); |
| 385 | await page.click('#diamond-filter .tag-clear-all', { force: true }); |
| 386 | await page.waitForTimeout(400); |
| 387 | |
| 388 | // ── And comes back when the last tag goes ──────────────────────── |
| 389 | // The state the user was actually left in when the sync bug ate the tag set: |
| 390 | // the filing system is empty again, and the rail has to say so rather than |
| 391 | // look like the feature went with it. |
| 392 | await openTagEditor('Ship a CSV parser'); |
| 393 | for (let i = 0; i < 2; i++) { |
| 394 | await page.click('.tag-row:not(.tag-sug) .tag-x', { force: true }); |
| 395 | await page.waitForTimeout(600); |
| 396 | } |
| 397 | check((await editorTags()).length === 0, `both tags come off: ${JSON.stringify(await editorTags())}`); |
| 398 | check(await page.isVisible('#diamond-tag-hint'), 'deleting the LAST tag brings the hint back'); |
| 399 | check(!(await page.isVisible('#diamond-filter')), |
| 400 | 'and the pool goes with it -- an empty pool is a row of nothing, which is what the hint is for'); |
| 401 | // Put them back the way the pool offers them, so the rest of this pass sees |
| 402 | // the store it expects. |
| 403 | for (const want of ['person', 'rust']) { |
| 404 | for (const c of await page.$$('.tag-sug .tag-chip')) { |
| 405 | if ((await c.textContent()) === want) { await c.click({ force: true }); break; } |
| 406 | } |
| 407 | await page.waitForTimeout(600); |
| 408 | } |
| 409 | const restoredTwo = await editorTags(); |
| 410 | check(restoredTwo.includes('person') && restoredTwo.includes('rust') && !(await page.isVisible('#diamond-tag-hint')), |
| 411 | `and goes again once a tag is back: ${JSON.stringify(restoredTwo)}`); |
| 412 | await page.click('.crystal-act', { force: true }); // ← Back to the crystal |
| 413 | await page.waitForTimeout(500); |
| 414 | |
| 415 | // ── Tag the second Diamond, so a filter has something to exclude ─── |
| 416 | await openTagEditor('Mum birthday plan'); |
| 417 | for (const t of ['person', 'family', 'gifts', 'urgent']) { |
| 418 | await page.fill('.tag-input', t); await page.keyboard.press('Enter'); await page.waitForTimeout(450); |
| 419 | } |
| 420 | await page.click('.crystal-act', { force: true }); |
| 421 | await page.waitForTimeout(500); |
| 422 | |
| 423 | // ── Chips in the rail ──────────────────────────────────────────── |
| 424 | const railChips = await railOf(); |
| 425 | const mum = railChips.find(r => r.name === 'Mum birthday plan'); |
| 426 | // EVERY TAG RENDERS AND THE ROW SCROLLS. Until 2026-08-21 this asserted a cap of |
| 427 | // three chips and a `+N`, which is what the tile drew then. The overflow chip was |
| 428 | // removed on the owner's instruction, because a tag that is never drawn cannot be |
| 429 | // clicked back into the filter -- and a Diamond you cannot see the filing of is a |
| 430 | // Diamond you cannot find by it. The check now asserts the replacement rather than |
| 431 | // the thing replaced, or it would hold the old design in place the way |
| 432 | // verify_daimonface's Fold check did. |
| 433 | check(mum && mum.tags.length === 4 && mum.more === null, |
| 434 | `every tag renders and no overflow chip stands in for any: ${JSON.stringify(mum)}`); |
| 435 | const csv = railChips.find(r => r.name === 'Ship a CSV parser'); |
| 436 | check(csv && csv.tags.length === 2, `chips render on the tagged Diamond: ${JSON.stringify(csv && csv.tags)}`); |
| 437 | |
| 438 | // Colour is deterministic and theme-driven, not hardcoded. |
| 439 | const hues = await page.$$eval('.session-box-tags .tag-chip', |
| 440 | els => els.map(e => ({ t: e.textContent, h: e.style.getPropertyValue('--tag-h') }))); |
| 441 | const byTag = {}; |
| 442 | let stable = true; |
| 443 | for (const { t, h } of hues) { if (byTag[t] && byTag[t] !== h) stable = false; byTag[t] = h; } |
| 444 | check(stable && hues.every(x => x.h !== ''), `each tag carries one hue: ${JSON.stringify(byTag)}`); |
| 445 | const personHue = byTag['person']; |
| 446 | check(new Set(Object.values(byTag)).size > 1, 'different tags get different hues'); |
| 447 | await shot(s, 'tags-dark-rail'); |
| 448 | |
| 449 | // ── Persistence: the whole point of the Rust side ──────────────── |
| 450 | // Reload and sign back in: tags must come off disk, not out of memory. |
| 451 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 452 | await signInAs(s, 'tags'); |
| 453 | await page.waitForTimeout(1500); |
| 454 | const afterReload = await railOf(); |
| 455 | const mumAfter = afterReload.find(r => r.name === 'Mum birthday plan'); |
| 456 | const csvAfter = afterReload.find(r => r.name === 'Ship a CSV parser'); |
| 457 | check(mumAfter && JSON.stringify(mumAfter.tags) === JSON.stringify(['person', 'family', 'gifts', 'urgent']) && mumAfter.more === null, |
| 458 | `tags PERSIST across a page reload: ${JSON.stringify(mumAfter)}`); |
| 459 | check(csvAfter && csvAfter.tags.length === 2, |
| 460 | `every tagged Diamond survives the reload: ${JSON.stringify(csvAfter && csvAfter.tags)}`); |
| 461 | check(JSON.stringify(afterReload.map(r => r.name)) === JSON.stringify(orderBefore), |
| 462 | `rail order survives the reload: ${JSON.stringify(afterReload.map(r => r.name))}`); |
| 463 | |
| 464 | // ── Filter by clicking a chip ──────────────────────────────────── |
| 465 | await clickTag('person'); |
| 466 | const filtered = await boxes(); |
| 467 | check(filtered.length === 2 && filtered.includes('Mum birthday plan') && filtered.includes('Ship a CSV parser'), |
| 468 | `clicking a chip filters to that tag: ${JSON.stringify(filtered)}`); |
| 469 | // The pool is the same surface whichever chip started the filter: clicking one |
| 470 | // on a Diamond box lights that tag WHERE THE POOL ALREADY SHOWS IT. |
| 471 | check(stateOf(await poolState(), 'person') === 'inc', |
| 472 | `a click on a box chip lights that tag in the pool: ${JSON.stringify(await poolState())}`); |
| 473 | check(await page.$eval('#diamond-filter .tag-chip.tag-inc', e => e.style.getPropertyValue('--tag-h')) === personHue, |
| 474 | 'the pool chip is the same colour as the tag it stands for'); |
| 475 | // The controls that are NOT tags sit below the pool, and only when there is a |
| 476 | // filter for them to act on. |
| 477 | check(await page.isVisible('#diamond-filter .tagf-ctl'), |
| 478 | 'a filter raises the controls below the pool'); |
| 479 | await shot(s, 'tags-dark-filter'); |
| 480 | // The clear is the one-click way off. A pool chip carries no closer -- its text |
| 481 | // is the tag and nothing else -- so the cycle takes two clicks to put one tag |
| 482 | // down, and this is offered for a filter of any size because of it. |
| 483 | await page.click('#diamond-filter .tag-clear-all', { force: true }); |
| 484 | await page.waitForTimeout(400); |
| 485 | check((await boxes()).length === 3 && !(await page.isVisible('#diamond-filter .tagf-ctl')) |
| 486 | && stateOf(await poolState(), 'person') === 'off', |
| 487 | 'the clear puts the filter down, and the pool stays up with every chip off'); |
| 488 | |
| 489 | // ── The boolean the rail actually holds ────────────────────────── |
| 490 | // One tag was never enough to say "the family things, but not the ones already |
| 491 | // bought". The rail keeps two lists -- the tags wanted and the tags refused -- |
| 492 | // and a rule for combining the first. Every state below is reached the way a |
| 493 | // user reaches it: by clicking chips. |
| 494 | // |
| 495 | // The fixture here: "Ship a CSV parser" [person, rust], "Mum birthday plan" |
| 496 | // [person, family, gifts, urgent], "Rust compiler notes" []. |
| 497 | |
| 498 | /// What the pool beside the search box is saying, read off the DOM. |
| 499 | /// |
| 500 | /// No text is stripped anywhere here. A pool chip's textContent is the tag and |
| 501 | /// nothing else -- both marks are drawn by the theme -- so the state is read |
| 502 | /// from the classes and `raw` can prove the text itself carries neither. |
| 503 | /// `up` is now "something is filtering", not "the element is drawn": the pool |
| 504 | /// is standing furniture and is drawn whenever a tag exists at all. |
| 505 | const fstate = () => page.evaluate(() => { |
| 506 | const f = document.getElementById('diamond-filter'); |
| 507 | const shown = !!f && f.style.display !== 'none' && getComputedStyle(f).display !== 'none'; |
| 508 | const chips = shown ? [...f.querySelectorAll('.tagf-pool .tag-chip')] : []; |
| 509 | const inc = chips.filter(c => c.classList.contains('tag-inc')); |
| 510 | const exc = chips.filter(c => c.classList.contains('tag-no')); |
| 511 | const on = shown ? f.querySelector('.tag-mode-btn.on') : null; |
| 512 | return { |
| 513 | shown, |
| 514 | up: inc.length + exc.length > 0, |
| 515 | pool: chips.map(c => c.textContent), |
| 516 | inc: inc.map(c => c.textContent), |
| 517 | exc: exc.map(c => c.textContent), |
| 518 | raw: inc.concat(exc).map(c => c.textContent), |
| 519 | mode: on ? on.dataset.mode : null, |
| 520 | clear: shown && !!f.querySelector('.tag-clear-all'), |
| 521 | ctl: shown && !!f.querySelector('.tagf-ctl'), |
| 522 | }; |
| 523 | }); |
| 524 | const setMode = async (m) => { |
| 525 | await page.click(`#diamond-filter .tag-mode-btn[data-mode="${m}"]`, { force: true }); |
| 526 | await page.waitForTimeout(400); |
| 527 | }; |
| 528 | const railMarks = () => page.$$eval('.session-box-tags .tag-chip', |
| 529 | els => els.map(e => ({ t: e.textContent, on: e.classList.contains('tag-inc') }))); |
| 530 | |
| 531 | // A chip cycles: off -> wanted -> refused -> off. Every leg is now walked in |
| 532 | // the POOL, from one chip that never moves -- which is the point of it. On a |
| 533 | // Diamond box the last leg is out of reach: refusing a tag takes every Diamond |
| 534 | // carrying it off the rail, and the box chip goes with them. |
| 535 | const cyc = []; |
| 536 | await clickTag('person'); |
| 537 | cyc.push(await fstate()); |
| 538 | const wantMarks = await railMarks(); |
| 539 | check(wantMarks.some(m => m.t === 'person' && m.on) && wantMarks.every(m => m.t === 'person' || !m.on), |
| 540 | `the chip doing the filtering is marked in the rail, and only it: ${JSON.stringify(wantMarks)}`); |
| 541 | check(stateOf(await poolState(), 'person') === 'inc', |
| 542 | 'and the pool wears the same mark for the same tag, so the two surfaces agree'); |
| 543 | await poolClick('person'); |
| 544 | cyc.push(await fstate()); |
| 545 | const refused = await boxes(); |
| 546 | check(refused.length === 1 && refused[0] === 'Rust compiler notes', |
| 547 | `a second click refuses the tag, and its carriers leave the rail: ${JSON.stringify(refused)}`); |
| 548 | // The property the pool exists for. Every Diamond carrying `person` has just |
| 549 | // left the rail, so there is no box chip for it anywhere -- and the refusal is |
| 550 | // still one click from being put down, because the pool is drawn from the store. |
| 551 | check(stateOf(await poolState(), 'person') === 'exc' |
| 552 | && !(await railOf()).some(r => r.tags.includes('person')), |
| 553 | `a REFUSED tag stays in the pool though no Diamond showing it carries it: ${JSON.stringify(await poolState())}`); |
| 554 | check(JSON.stringify(cyc[1].raw) === JSON.stringify(['person']), |
| 555 | `a refused chip's text is the bare tag and nothing else: ${JSON.stringify(cyc[1].raw)}`); |
| 556 | const negation = await page.$eval('#diamond-filter .tag-no', |
| 557 | e => getComputedStyle(e, '::before').content).catch(() => ''); |
| 558 | check(negation.includes('¬'), |
| 559 | `the negation is drawn by the theme, not put in the chip's text: ${JSON.stringify(negation)}`); |
| 560 | await shot(s, 'tags-dark-exclude'); |
| 561 | await poolClick('person'); |
| 562 | cyc.push(await fstate()); |
| 563 | check(!cyc[2].up && (await boxes()).length === 3, |
| 564 | `a third click in the pool puts the tag down again: ${JSON.stringify(cyc[2])}`); |
| 565 | check(cyc.every(st => st.shown) && cyc.map(st => st.ctl).join() === 'true,true,false', |
| 566 | `the pool stands through all three legs; only the controls come and go: ${JSON.stringify(cyc.map(st => [st.shown, st.ctl]))}`); |
| 567 | // The state a predicate would have to invent a meaning for -- one tag both |
| 568 | // wanted and refused -- is one the cycle cannot produce: each leg MOVES the |
| 569 | // tag, and moving is not copying. A step holding it in both lists would read |
| 570 | // "inc+exc" below and fail, and so would a leg that never happened, so this one |
| 571 | // line is both the order and the impossibility. |
| 572 | const where = st => ['inc', 'exc'].filter(k => st[k].indexOf('person') !== -1).join('+') || 'off'; |
| 573 | check(JSON.stringify(cyc.map(where)) === JSON.stringify(['inc', 'exc', 'off']), |
| 574 | `the cycle is off -> wanted -> refused -> off, one list at a time: ${JSON.stringify(cyc.map(where))}`); |
| 575 | |
| 576 | // ── ALL and ANY ────────────────────────────────────────────────── |
| 577 | await clickTag('person'); |
| 578 | check((await fstate()).mode === null, |
| 579 | 'with one tag wanted there is no combining control: ALL and ANY would name the same list'); |
| 580 | // The clear IS offered at one tag now, where it used to wait for two. The chip |
| 581 | // that used to be the one-click way off carried an × and so read "person×" to |
| 582 | // everything that reads a chip; the pool's chip reads "person", and pays for it |
| 583 | // with a second click round the cycle. This is what buys that back. |
| 584 | check((await fstate()).clear === true, |
| 585 | 'but the clear is, because a pool chip has no closer and the cycle takes two clicks to put one tag down'); |
| 586 | await clickTag('rust'); |
| 587 | const bothTags = await boxes(); |
| 588 | check(bothTags.length === 1 && bothTags[0] === 'Ship a CSV parser', |
| 589 | `two wanted tags default to ALL -- only the Diamond carrying both: ${JSON.stringify(bothTags)}`); |
| 590 | const modeUp = await fstate(); |
| 591 | check(modeUp.mode === 'all' && modeUp.clear === true, |
| 592 | `a second tag raises the combining control on ALL, and the clear stays: ${JSON.stringify(modeUp)}`); |
| 593 | await setMode('any'); |
| 594 | const anyBoxes = await boxes(); |
| 595 | check(anyBoxes.length === 2 && anyBoxes.includes('Mum birthday plan') && anyBoxes.includes('Ship a CSV parser'), |
| 596 | `ANY gives the union of the two instead: ${JSON.stringify(anyBoxes)}`); |
| 597 | await shot(s, 'tags-dark-any'); |
| 598 | |
| 599 | // The mode is a habit of reading and is kept; the two lists are a way of looking |
| 600 | // at this page and are not. An account switch reloads, so this is that too. |
| 601 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 602 | await signInAs(s, 'tags'); |
| 603 | await page.waitForTimeout(1500); |
| 604 | const afterBoot = await fstate(); |
| 605 | check(!afterBoot.up && (await boxes()).length === 3, |
| 606 | `the wanted and refused lists do not survive a reload -- which is what an account switch is: ${JSON.stringify(afterBoot)}`); |
| 607 | // The pool is not a filter state and does not reset with one: it is the |
| 608 | // vocabulary, and it comes back with every chip off. This is the hard refresh |
| 609 | // the user did, which found nothing under the search box at all. |
| 610 | check(afterBoot.shown && !afterBoot.ctl |
| 611 | && JSON.stringify(afterBoot.pool) === JSON.stringify(['family', 'gifts', 'person', 'rust', 'urgent']), |
| 612 | `but the pool itself comes straight back on a hard reload, every chip off: ${JSON.stringify(afterBoot.pool)}`); |
| 613 | // Open, because it was left open. The other half of the kept choice: closed |
| 614 | // survived a reload above, and so does open -- it is one preference, not a |
| 615 | // default that only bites one way. |
| 616 | const dBoot = await disc(); |
| 617 | check(!!dBoot && dBoot.open === true, |
| 618 | `and comes back OPEN, because that is the way it was left: ${JSON.stringify(dBoot)}`); |
| 619 | check(!(await page.isVisible('#diamond-tag-hint')), |
| 620 | 'and the empty-pool hint stays away, because it keys off tags existing, not off filtering'); |
| 621 | await clickTag('person'); |
| 622 | await clickTag('rust'); |
| 623 | const kept = await fstate(); |
| 624 | check(kept.mode === 'any' && (await boxes()).length === 2, |
| 625 | `the ALL/ANY choice IS kept across the reload: ${JSON.stringify(kept)}`); |
| 626 | await poolOff('rust'); |
| 627 | const oneLeft = await fstate(); |
| 628 | check(oneLeft.mode === null && oneLeft.clear === true && (await boxes()).length === 2, |
| 629 | `the combining control goes again when one tag is left, and the clear stays: ${JSON.stringify(oneLeft)}`); |
| 630 | |
| 631 | // ── A refusal beats a want ─────────────────────────────────────── |
| 632 | // "Mum birthday plan" carries `person`, which is wanted. Refusing `family` |
| 633 | // still takes it off the rail: a tag you have said you do not want to see |
| 634 | // cannot be talked round by one you do. |
| 635 | await clickTag('rust'); // wanted: person, rust (ANY) |
| 636 | await clickTag('family'); // wanted: + family, so Mum still shows |
| 637 | await clickTag('family'); // refused |
| 638 | const beaten = await boxes(); |
| 639 | check(beaten.length === 1 && beaten[0] === 'Ship a CSV parser', |
| 640 | `a refused tag hides its Diamond even though a wanted tag matches it: ${JSON.stringify(beaten)}`); |
| 641 | const halves = await fstate(); |
| 642 | check(JSON.stringify(halves.inc.slice().sort()) === JSON.stringify(['person', 'rust']) |
| 643 | && JSON.stringify(halves.exc) === JSON.stringify(['family']), |
| 644 | `the pool shows both halves at once, so neither reason for hiding is silent: ${JSON.stringify(halves)}`); |
| 645 | // One row, in one order, whatever the boolean is: the wanted, the refused and |
| 646 | // the untouched sit alphabetically among each other rather than being gathered |
| 647 | // into groups that move under a click. |
| 648 | check(JSON.stringify(halves.pool) === JSON.stringify(['family', 'gifts', 'person', 'rust', 'urgent']), |
| 649 | `and does not reorder itself around them: ${JSON.stringify(halves.pool)}`); |
| 650 | await shot(s, 'tags-dark-boolean'); |
| 651 | // ── Clear-all, and an honest empty rail ────────────────────────── |
| 652 | await page.click('#diamond-filter .tag-clear-all', { force: true }); |
| 653 | await page.waitForTimeout(400); |
| 654 | const cleared = await fstate(); |
| 655 | check(!cleared.up && cleared.shown && (await boxes()).length === 3, |
| 656 | `one click on the clear puts both lists down and leaves the pool standing: ${JSON.stringify(cleared)}`); |
| 657 | // ALL over two tags no Diamond shares. |
| 658 | await clickTag('person'); |
| 659 | await clickTag('rust'); |
| 660 | await clickTag('family'); |
| 661 | await poolOff('person'); |
| 662 | const anyTwo = await boxes(); |
| 663 | check(anyTwo.length === 2, `ANY over rust and family holds both their Diamonds: ${JSON.stringify(anyTwo)}`); |
| 664 | await setMode('all'); |
| 665 | const impossible = await boxes(); |
| 666 | check(impossible.length === 0 && (await page.textContent('.diamond-list')).includes('No Diamonds match'), |
| 667 | `ALL over two tags no Diamond shares empties the rail and says why: ${JSON.stringify(impossible)}`); |
| 668 | await page.click('#diamond-filter .tag-clear-all', { force: true }); |
| 669 | await page.waitForTimeout(400); |
| 670 | check((await boxes()).length === 3, 'and the clear-all brings them back'); |
| 671 | |
| 672 | // ── Removing a tag ─────────────────────────────────────────────── |
| 673 | await openTagEditor('Ship a CSV parser'); |
| 674 | await page.click('.tag-row:not(.tag-sug) .tag-x', { force: true }); |
| 675 | await page.waitForTimeout(600); |
| 676 | check((await editorTags()).length === 1, `a tag can be removed: ${JSON.stringify(await editorTags())}`); |
| 677 | await shot(s, 'tags-dark-editor'); |
| 678 | |
| 679 | // ── The real normalisation, on the real wasm ───────────────────── |
| 680 | // Straight at the boundary, because the editor's input caps length itself and |
| 681 | // a restore can hand over whatever a hand-edited backup contains. |
| 682 | const normCases = await wasm(async (app) => { |
| 683 | const id = JSON.parse(await app.list_diamonds()).find(r => r.name === 'Rust compiler notes').id; |
| 684 | const run = async (tags) => { |
| 685 | await app.set_tags(id, JSON.stringify(tags)); |
| 686 | return JSON.parse(await app.list_diamonds()).find(r => r.id === id).tags; |
| 687 | }; |
| 688 | const res = { |
| 689 | collapse: await run([' My BIG Project ']), |
| 690 | empties: await run(['ok', '', ' ', 'fine']), |
| 691 | dedupe: await run(['dup', 'DUP', ' dup ']), |
| 692 | charCap: await run(['é'.repeat(30)]), |
| 693 | tagCap: await run(Array.from({ length: 12 }, (_, i) => `t${i}`)), |
| 694 | }; |
| 695 | // Malformed input must degrade, not throw. |
| 696 | try { |
| 697 | await app.set_tags(id, 'not json at all'); |
| 698 | res.malformed = JSON.parse(await app.list_diamonds()).find(r => r.id === id).tags; |
| 699 | } catch (e) { res.malformed = `THREW: ${e}`; } |
| 700 | // What doImport hands over if a hand-edited backup has "tags":"person" |
| 701 | // (a bare string, not an array). It reaches set_tags as JSON `"person"`. |
| 702 | try { |
| 703 | await app.set_tags(id, JSON.stringify('person')); |
| 704 | res.stringNotArray = JSON.parse(await app.list_diamonds()).find(r => r.id === id).tags; |
| 705 | } catch (e) { res.stringNotArray = `THREW: ${e}`; } |
| 706 | res.cleared = await run([]); |
| 707 | return res; |
| 708 | }); |
| 709 | check(JSON.stringify(normCases.collapse) === JSON.stringify(['my big project']), |
| 710 | `internal whitespace collapses and lowercases: ${JSON.stringify(normCases.collapse)}`); |
| 711 | check(JSON.stringify(normCases.empties) === JSON.stringify(['ok', 'fine']), |
| 712 | `empty and whitespace-only tags are dropped: ${JSON.stringify(normCases.empties)}`); |
| 713 | check(JSON.stringify(normCases.dedupe) === JSON.stringify(['dup']), |
| 714 | `tags differing only by case/padding dedupe: ${JSON.stringify(normCases.dedupe)}`); |
| 715 | check(Array.isArray(normCases.charCap) && normCases.charCap.length === 1 && [...normCases.charCap[0]].length === 24, |
| 716 | `the 24 cap counts CHARACTERS not bytes: ${[...(normCases.charCap[0] || '')].length} chars of 'é' kept`); |
| 717 | check(normCases.tagCap.length === 8, `at most 8 tags are kept: got ${normCases.tagCap.length}`); |
| 718 | check(Array.isArray(normCases.malformed) && normCases.malformed.length === 0, |
| 719 | `malformed JSON degrades to no tags rather than throwing: ${JSON.stringify(normCases.malformed)}`); |
| 720 | check(Array.isArray(normCases.stringNotArray), |
| 721 | `a bare string where an array was expected degrades rather than throwing: ${JSON.stringify(normCases.stringNotArray)}`); |
| 722 | check(JSON.stringify(normCases.cleared) === JSON.stringify([]), 'an empty array clears every tag'); |
| 723 | |
| 724 | // ── Themes ─────────────────────────────────────────────────────── |
| 725 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 726 | await signInAs(s, 'tags'); |
| 727 | await page.waitForTimeout(1200); |
| 728 | await openTagEditor('Ship a CSV parser'); |
| 729 | await page.evaluate(() => window.DaimondTheme.set('light')); |
| 730 | await page.waitForTimeout(600); |
| 731 | await shot(s, 'tags-light-editor'); |
| 732 | await page.click('.crystal-act', { force: true }); |
| 733 | await page.waitForTimeout(400); |
| 734 | await shot(s, 'tags-light-rail'); |
| 735 | const contrast = await page.$$eval('.session-box-tags .tag-chip', els => els.map(e => { |
| 736 | const cs = getComputedStyle(e); |
| 737 | return { t: e.textContent, fg: cs.color, bg: cs.backgroundColor }; |
| 738 | })); |
| 739 | check(contrast.length > 0 && contrast.every(c => c.fg !== c.bg && c.bg !== 'rgba(0, 0, 0, 0)'), |
| 740 | `light theme resolves chip colours: ${JSON.stringify(contrast[0])}`); |
| 741 | await page.evaluate(() => window.DaimondTheme.set('lollypop')); |
| 742 | await page.waitForTimeout(600); |
| 743 | await shot(s, 'tags-lollypop-rail'); |
| 744 | await page.evaluate(() => window.DaimondTheme.set('dark')); |
| 745 | await page.waitForTimeout(400); |
| 746 | |
| 747 | // ── Honesty: a tag must never reach the model ──────────────────── |
| 748 | // Tag a Diamond with a string that could not occur naturally, steer it, then |
| 749 | // read back everything the mock was actually sent. |
| 750 | await openTagEditor('Mum birthday plan'); |
| 751 | await page.fill('.tag-input', 'zqxwmarker'); |
| 752 | await page.keyboard.press('Enter'); |
| 753 | await page.waitForTimeout(600); |
| 754 | await page.click('.crystal-act', { force: true }); |
| 755 | await page.waitForTimeout(400); |
| 756 | clearMockLog(); |
| 757 | await page.fill('#chat-input', 'Say hello.'); |
| 758 | await page.keyboard.press('Enter'); |
| 759 | for (let i = 0; i < 40; i++) { if (mockLog().length) break; await page.waitForTimeout(400); } |
| 760 | await page.waitForTimeout(1000); |
| 761 | const sent = JSON.stringify(mockLog()); |
| 762 | check(sent.length > 2, 'the model was actually called (so the next check can mean something)'); |
| 763 | check(!sent.includes('zqxwmarker'), 'no tag reached the model: not in the prompt, the tools or the transcript'); |
| 764 | const allCrystals = await wasm(async (app) => { |
| 765 | const rows = JSON.parse(await app.list_diamonds()); |
| 766 | let all = ''; |
| 767 | for (const r of rows) { try { all += await app.read_crystal_data(r.id); } catch (e) { /* none yet */ } } |
| 768 | return all; |
| 769 | }); |
| 770 | check(!allCrystals.includes('zqxwmarker'), 'no tag leaked into any crystal'); |
| 771 | |
| 772 | // ── The backup round trip ──────────────────────────────────────── |
| 773 | // Tags travel with a Diamond, or a restore silently drops the user's whole |
| 774 | // filing system while looking like it worked. |
| 775 | await page.click('#user-row'); |
| 776 | await page.waitForTimeout(400); |
| 777 | const dl = page.waitForEvent('download', { timeout: 15000 }); |
| 778 | await page.click('button.admin-item:has-text("Export a backup")'); |
| 779 | const bpath = scratch('tags-backup.json'); |
| 780 | await (await dl).saveAs(bpath); |
| 781 | const backup = JSON.parse(fs.readFileSync(bpath, 'utf8')); |
| 782 | const bmum = (backup.diamonds || []).find(f => f.name === 'Mum birthday plan'); |
| 783 | check(bmum && Array.isArray(bmum.tags) && bmum.tags.includes('family'), |
| 784 | `the export carries tags: ${JSON.stringify(bmum && bmum.tags)}`); |
| 785 | const brust = (backup.diamonds || []).find(f => f.name === 'Rust compiler notes'); |
| 786 | check(brust && Array.isArray(brust.tags) && brust.tags.length === 0, |
| 787 | `an untagged Diamond exports tags:[] not undefined: ${JSON.stringify(brust && brust.tags)}`); |
| 788 | |
| 789 | // Admin is still open from the export above, and its drawer sits over the rail -- |
| 790 | // including the New Diamond button the next section presses. A person would shut |
| 791 | // it; the test has to as well, or it is asserting about a button nobody can reach. |
| 792 | await page.evaluate(() => { const b = document.getElementById('admin-close'); if (b) b.click(); }); |
| 793 | await page.waitForTimeout(400); |
| 794 | |
| 795 | // ── A create must never land behind a filter ───────────────────── |
| 796 | // Proved through the search box in verify_diamonds until the box was removed. |
| 797 | // The hazard is the filter's, not the box's: a Diamond made while the rail is |
| 798 | // filtered to something it does not match is a Diamond the user cannot see and |
| 799 | // did not lose. |
| 800 | await clickTag('person'); |
| 801 | const beforeCreate = await boxes(); |
| 802 | check(!beforeCreate.includes('Rust compiler notes'), |
| 803 | `the rail is filtered to something the new Diamond will not match: ${JSON.stringify(beforeCreate)}`); |
| 804 | await newDiamond('Made while filtered'); |
| 805 | await page.waitForTimeout(600); |
| 806 | const afterCreate = await boxes(); |
| 807 | check(afterCreate.includes('Made while filtered'), |
| 808 | `a Diamond made while the rail is filtered is still visible: ${JSON.stringify(afterCreate)}`); |
| 809 | check(!(await page.isVisible('#diamond-filter .tagf-ctl')), |
| 810 | 'and the filter that would have hidden it is cleared'); |
| 811 | |
| 812 | const errsA = errors(s).filter(e => !/502 \(Bad Gateway\)/.test(e)); |
| 813 | check(errsA.length === 0, `no console errors beyond the offline gateway: ${JSON.stringify(errsA.slice(0, 3))}`); |
| 814 | await s.close(); |
| 815 | |
| 816 | // A fresh profile: nothing of the first session is on this disk. |
| 817 | const b = await open({ name: 'tagsB', defaults: false }); |
| 818 | await b.page.click('#user-row'); |
| 819 | await b.page.waitForTimeout(400); |
| 820 | const chooser = b.page.waitForEvent('filechooser', { timeout: 15000 }); |
| 821 | await b.page.click('button.admin-item:has-text("Import a backup")'); |
| 822 | await (await chooser).setFiles(bpath); |
| 823 | // A restore now confirms and reloads to bring every restored surface back |
| 824 | // consistent; acknowledge it, let it reload, and unlock the fresh session. |
| 825 | await b.page.waitForSelector('.dlg-ok', { timeout: 15000 }); |
| 826 | await b.page.click('.dlg-ok'); |
| 827 | await b.page.waitForSelector('#id-primary', { timeout: 15000 }); |
| 828 | await signInAs(b, 'tagsB'); |
| 829 | await b.page.waitForTimeout(800); |
| 830 | const restored = await b.page.$$eval('.diamond-box', els => els.map(e => ({ |
| 831 | name: (e.querySelector('.session-box-name') || {}).textContent, |
| 832 | tags: [...e.querySelectorAll('.session-box-tags .tag-chip')].map(c => c.textContent), |
| 833 | more: (e.querySelector('.tag-more') || {}).textContent || null, |
| 834 | }))); |
| 835 | // A restore must bring each Diamond back ONCE. The workspace files carry the whole |
| 836 | // of `diamonds/<id>/` (crystal, versions, log, meta with tags), so restoring them |
| 837 | // reconstitutes each Diamond with its history; doImport no longer ALSO recreates it |
| 838 | // from the summary, which used to yield two ids for one Diamond. |
| 839 | check(restored.length === 3, `a restore brings each Diamond back exactly once -- got ${restored.length}, expected 3: ${JSON.stringify(restored.map(r => r.name))}`); |
| 840 | |
| 841 | // The thing this pass is actually proving: tags survive the round trip. |
| 842 | const rmum = restored.filter(r => r.name === 'Mum birthday plan'); |
| 843 | check(rmum.length > 0 && rmum.every(r => r.tags.length === 5 && r.more === null), |
| 844 | `tags RESTORE from a backup into a fresh profile (5 tags, 5 chips, no "+N"): ${JSON.stringify(rmum[0])}`); |
| 845 | const rrust = restored.filter(r => r.name === 'Rust compiler notes'); |
| 846 | check(rrust.length > 0 && rrust.every(r => r.tags.length === 0), |
| 847 | `an untagged Diamond restores untagged, with no chips: ${JSON.stringify(rrust[0])}`); |
| 848 | await shot(b, 'tags-restored'); |
| 849 | |
| 850 | // ── Deleting a tag that the filter is holding ──────────────────── |
| 851 | // Done on this profile because it destroys tags, and nothing follows it. A tag |
| 852 | // deleted while WANTED would leave a filter no Diamond can satisfy; deleted |
| 853 | // while REFUSED it would go on hiding Diamonds with no chip anywhere to click. |
| 854 | // It has to leave both lists. |
| 855 | const bp = b.page; |
| 856 | const bBoxes = () => bp.$$eval('.diamond-box .session-box-name', els => els.map(e => e.textContent)); |
| 857 | const bClickTag = async (name) => { |
| 858 | await bp.$$eval('.session-box-tags .tag-chip', (els, n) => { |
| 859 | for (const e of els) if (e.textContent === n) { e.click(); return; } |
| 860 | }, name); |
| 861 | await bp.waitForTimeout(400); |
| 862 | }; |
| 863 | const bState = () => bp.evaluate(() => { |
| 864 | const f = document.getElementById('diamond-filter'); |
| 865 | const shown = !!f && f.style.display !== 'none' && getComputedStyle(f).display !== 'none'; |
| 866 | const chips = shown ? [...f.querySelectorAll('.tagf-pool .tag-chip')] : []; |
| 867 | const inc = chips.filter(c => c.classList.contains('tag-inc')); |
| 868 | const exc = chips.filter(c => c.classList.contains('tag-no')); |
| 869 | return { |
| 870 | shown, pool: chips.map(c => c.textContent), |
| 871 | up: inc.length + exc.length > 0, |
| 872 | inc: inc.map(c => c.textContent), |
| 873 | exc: exc.map(c => c.textContent), |
| 874 | }; |
| 875 | }); |
| 876 | /// Delete a tag from the pool in the tag editor, and confirm it. |
| 877 | const bKill = async (name) => { |
| 878 | await bp.evaluate((n) => { |
| 879 | const chip = [...document.querySelectorAll('.tag-sug .tag-chip')].find(e => e.textContent === n); |
| 880 | if (chip) chip.querySelector('.tag-kill').click(); |
| 881 | }, name); |
| 882 | await bp.waitForSelector('.dlg-ok', { timeout: 10000 }); |
| 883 | await bp.click('.dlg-ok'); |
| 884 | await bp.waitForTimeout(1200); |
| 885 | }; |
| 886 | // Unlocking left the Admin drawer over the rail, and it takes the clicks meant |
| 887 | // for a Diamond box. Dismiss it exactly as a user would. |
| 888 | const adminX = await bp.$('#admin-close'); |
| 889 | if (adminX && await adminX.isVisible()) { await adminX.click({ force: true }); await bp.waitForTimeout(400); } |
| 890 | // A profile that has never been touched, holding five restored tags: the pool |
| 891 | // is down and the row that names it is up. The first session proved the default |
| 892 | // on a store it had built; this proves it on a disk it has never seen. |
| 893 | const bFresh = await bp.evaluate(() => { |
| 894 | const b = document.querySelector('#diamond-filter .tagf-toggle'); |
| 895 | return { row: !!b, open: b ? b.getAttribute('aria-expanded') === 'true' : null, |
| 896 | text: b ? b.textContent.trim() : null, |
| 897 | pool: !!document.querySelector('#diamond-filter .tagf-pool') }; |
| 898 | }); |
| 899 | check(bFresh.row && bFresh.open === false && !bFresh.pool && /\(\d+\)/.test(bFresh.text || ''), |
| 900 | `a fresh profile opens with the pool down and its count on the row: ${JSON.stringify(bFresh)}`); |
| 901 | await setPoolOn(bp, true); |
| 902 | // The count on the closed row is the promise; the chips are what it is a |
| 903 | // promise OF. A number that did not match what opening gives you would be worse |
| 904 | // than no number, and it is read from the store rather than from the chips. |
| 905 | const bCount = await bp.$$eval('#diamond-filter .tagf-pool .tag-chip', els => els.length); |
| 906 | check(bCount > 0 && +((bFresh.text || '').match(/\((\d+)\)/) || [])[1] === bCount, |
| 907 | `and the count it promised is the number of chips behind it: ${JSON.stringify(bFresh.text)} -> ${bCount} chips`); |
| 908 | // The editor is opened FIRST, on "Ship a CSV parser": the pool only offers what |
| 909 | // is not already on the Diamond being edited, and the filter below is built out |
| 910 | // of tags that are on another one. The editor lives in the centre; the rail it |
| 911 | // is read against stays where it is. |
| 912 | const bIdx = (await bBoxes()).indexOf('Ship a CSV parser'); |
| 913 | await bp.$$eval('.diamond-box', (els, i) => els[i].click(), bIdx); |
| 914 | await bp.waitForTimeout(500); |
| 915 | for (const btn of await bp.$$('.crystal-act')) { |
| 916 | if ((await btn.textContent()).includes('Tags')) { await btn.click({ force: true }); break; } |
| 917 | } |
| 918 | await bp.waitForSelector('.tag-editor', { timeout: 5000 }); |
| 919 | await bClickTag('gifts'); // wanted: "Mum birthday plan" alone |
| 920 | await bClickTag('family'); // wanted as well; Mum carries both |
| 921 | await bClickTag('family'); // refused, so Mum goes and the rail empties |
| 922 | const bSet = await bState(); |
| 923 | check(JSON.stringify(bSet.inc) === JSON.stringify(['gifts']) && JSON.stringify(bSet.exc) === JSON.stringify(['family']), |
| 924 | `a filter with one tag in each list, built by clicking: ${JSON.stringify(bSet)}`); |
| 925 | check((await bBoxes()).length === 0, 'and it is currently hiding everything, which is the point'); |
| 926 | await bKill('family'); |
| 927 | const afterExcKill = await bState(); |
| 928 | check(afterExcKill.exc.length === 0 && JSON.stringify(afterExcKill.inc) === JSON.stringify(['gifts']) |
| 929 | && (await bBoxes()).length === 1, |
| 930 | `deleting a REFUSED tag takes it out of the filter, leaves the rest, and gives the Diamonds back: ${JSON.stringify(afterExcKill)}`); |
| 931 | await bKill('gifts'); |
| 932 | const afterIncKill = await bState(); |
| 933 | check(!afterIncKill.up && (await bBoxes()).length === 3, |
| 934 | `deleting a WANTED tag clears the filter rather than emptying the rail: ${JSON.stringify(afterIncKill)}, ${JSON.stringify(await bBoxes())}`); |
| 935 | check(!afterIncKill.pool.includes('family') && !afterIncKill.pool.includes('gifts'), |
| 936 | `and both deleted tags leave the pool, so nothing offers a filter that cannot match: ${JSON.stringify(afterIncKill.pool)}`); |
| 937 | |
| 938 | // ── The divider does not drift under the pool ──────────────────── |
| 939 | // The pool is furniture, and the Diamonds and Chats lists are cut out of what |
| 940 | // the furniture leaves -- once, in pixels. Anything that changes the pool's |
| 941 | // height has to make that cut again, or the height comes off the Chats list |
| 942 | // alone and the divider walks up the rail a row at a time. Here the change is |
| 943 | // live and small: a click raises the controls row under the pool. |
| 944 | const shareOf = () => shareOn(bp); |
| 945 | const beforeCtl = await shareOf(); |
| 946 | await bClickTag('person'); // raises .tagf-ctl: the furniture grows a row |
| 947 | const withCtl = await shareOf(); |
| 948 | await bp.evaluate(() => { const c = document.querySelector('.tag-clear-all'); if (c) c.click(); }); |
| 949 | await bp.waitForTimeout(400); |
| 950 | const afterCtl = await shareOf(); |
| 951 | check(withCtl.list + withCtl.sess < beforeCtl.list + beforeCtl.sess, |
| 952 | `raising the controls really does take height off the two lists: ${beforeCtl.list + beforeCtl.sess}px -> ${withCtl.list + withCtl.sess}px`); |
| 953 | check(Math.abs(withCtl.share - beforeCtl.share) <= 0.03 && Math.abs(afterCtl.share - beforeCtl.share) <= 0.03, |
| 954 | `and the divider stays at its share rather than walking: ${beforeCtl.share} -> ${withCtl.share} -> ${afterCtl.share}`); |
| 955 | check(beforeCtl.handle && afterCtl.handle, 'the divider itself is still there afterwards'); |
| 956 | |
| 957 | // ── A vocabulary the rail cannot afford ────────────────────────── |
| 958 | // Thirty-odd tags is a filing system, not a mistake, and the pool is furniture: |
| 959 | // the rail's two lists live on what it leaves. Unbounded, it would push the |
| 960 | // Diamonds off the bottom of the rail to show a row of chips for them. |
| 961 | const beforeBulk = await shareOf(); |
| 962 | await bp.evaluate(async () => { |
| 963 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 964 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 965 | const words = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta']; |
| 966 | for (let d = 0; d < 4; d++) { |
| 967 | const id = await app.create_diamond('Bulk ' + d); |
| 968 | await app.set_tags(id, JSON.stringify(words.map(w => w + '-' + d))); // 8 is the per-Diamond cap |
| 969 | } |
| 970 | }); |
| 971 | // A hard reload, because that is the state being tested: a store that already |
| 972 | // has this much in it when the app opens. |
| 973 | await bp.reload({ waitUntil: 'domcontentloaded' }); |
| 974 | await signInAs(b, 'tagsB'); |
| 975 | await bp.waitForTimeout(1800); |
| 976 | const adminX2 = await bp.$('#admin-close'); |
| 977 | if (adminX2 && await adminX2.isVisible()) { await adminX2.click({ force: true }); await bp.waitForTimeout(400); } |
| 978 | // Left open, so it comes up open -- on a store that now has a real vocabulary |
| 979 | // in it, which is the case where the choice is worth most either way. |
| 980 | const bBig = await bp.$eval('#diamond-filter .tagf-toggle', |
| 981 | el => ({ open: el.getAttribute('aria-expanded') === 'true', text: el.textContent.trim() })).catch(() => null); |
| 982 | check(!!bBig && bBig.open && /\(3\d\)/.test(bBig.text), |
| 983 | `the kept choice survives the vocabulary arriving, and the count follows it: ${JSON.stringify(bBig)}`); |
| 984 | const big = await bp.evaluate(() => { |
| 985 | const el = document.querySelector('#diamond-filter .tagf-pool'); |
| 986 | const list = document.getElementById('diamond-list'); |
| 987 | const sess = document.getElementById('session-list'); |
| 988 | const cs = el ? getComputedStyle(el) : {}; |
| 989 | const rail = document.getElementById('panel-rail').getBoundingClientRect(); |
| 990 | return { |
| 991 | tags: el ? el.children.length : 0, |
| 992 | wrap: cs.flexWrap, over: cs.overflowY, |
| 993 | clientH: el ? el.clientHeight : 0, |
| 994 | scrollH: el ? el.scrollHeight : 0, |
| 995 | list: list ? Math.round(list.getBoundingClientRect().height) : 0, |
| 996 | sess: sess ? Math.round(sess.getBoundingClientRect().height) : 0, |
| 997 | // Nothing may hang out of the rail's width; a chip row that did would |
| 998 | // put a horizontal scrollbar under the whole column. |
| 999 | widest: el ? Math.max(0, ...[...el.children].map(c => Math.round(c.getBoundingClientRect().right))) : 0, |
| 1000 | railRight: Math.round(rail.right), |
| 1001 | // Is a row CUT by the cap? A cap landing on a row boundary would show |
| 1002 | // thirty tags as three tidy rows and nothing to say there are more. |
| 1003 | cut: el ? (() => { |
| 1004 | const box = el.getBoundingClientRect(); |
| 1005 | return [...el.children].some(c => { |
| 1006 | const r = c.getBoundingClientRect(); |
| 1007 | return r.top < box.bottom - 1 && r.bottom > box.bottom + 1; |
| 1008 | }); |
| 1009 | })() : false, |
| 1010 | }; |
| 1011 | }); |
| 1012 | check(big.tags >= 30, `a store with a real vocabulary in it: ${big.tags} tags in the pool`); |
| 1013 | check(big.wrap === 'wrap' && big.over === 'auto' && big.scrollH > big.clientH, |
| 1014 | `the pool wraps and then scrolls rather than growing: ${JSON.stringify(big)}`); |
| 1015 | check(big.clientH <= 80, |
| 1016 | `and is capped at about three rows however many tags there are: ${big.clientH}px for ${big.tags} chips`); |
| 1017 | // The complaint that started all this was a control that gave no sign it was |
| 1018 | // there. A pool that ended flush on a row would be the same mistake one size |
| 1019 | // down: thirty tags looking like nine, with nothing saying otherwise. |
| 1020 | check(big.cut, |
| 1021 | `a full pool cuts a row rather than ending flush, so the reader can see there is more: ${big.clientH}px of ${big.scrollH}px`); |
| 1022 | check(big.widest <= big.railRight, |
| 1023 | `no chip hangs out of the rail: widest right edge ${big.widest} vs rail ${big.railRight}`); |
| 1024 | // The furniture moved, so the split below it has to have been cut again -- |
| 1025 | // which is what `fitTagFilter` is for. Both lists still have a usable height. |
| 1026 | check(big.list >= 60 && big.sess >= 60, |
| 1027 | `the two lists still share the rail under a full pool: Diamonds ${big.list}px, Chats ${big.sess}px`); |
| 1028 | check(Math.abs(big.list / (big.list + big.sess) - beforeBulk.share) <= 0.03, |
| 1029 | `and the divider is where it was before the vocabulary arrived: ${beforeBulk.share} -> ${(big.list / (big.list + big.sess)).toFixed(3)}`); |
| 1030 | await shot(b, 'tags-pool-many'); |
| 1031 | // A pool that is scrolled must stay where it was put: it repaints on every |
| 1032 | // click, and one that jumped back to the top would make the tags past the |
| 1033 | // third row unusable. |
| 1034 | const scrollKept = await bp.evaluate(async () => { |
| 1035 | const el = document.querySelector('.tagf-pool'); |
| 1036 | el.scrollTop = el.scrollHeight; |
| 1037 | const before = el.scrollTop; |
| 1038 | [...el.querySelectorAll('.tag-chip')].pop().click(); |
| 1039 | await new Promise(r => setTimeout(r, 350)); |
| 1040 | const now = document.querySelector('.tagf-pool'); |
| 1041 | return { before, after: now.scrollTop, lit: !!now.querySelector('.tag-inc') }; |
| 1042 | }); |
| 1043 | check(scrollKept.before > 0 && scrollKept.after === scrollKept.before && scrollKept.lit, |
| 1044 | `the pool holds its scroll across the repaint a click causes: ${JSON.stringify(scrollKept)}`); |
| 1045 | await shot(b, 'tags-pool-many-scrolled'); |
| 1046 | await bp.evaluate(() => { const c = document.querySelector('.tag-clear-all'); if (c) c.click(); }); |
| 1047 | await bp.waitForTimeout(300); |
| 1048 | |
| 1049 | // ── Putting a full pool away gives the rail back ───────────────── |
| 1050 | // The whole point of the disclosure, measured: with a real vocabulary in it the |
| 1051 | // pool is three and a half rows of furniture, and closing it must hand every |
| 1052 | // pixel of that to the two lists WITHOUT moving the divider between them. |
| 1053 | const bigOpen = await shareOf(); |
| 1054 | await setPoolOn(bp, false); |
| 1055 | const bigShut = await shareOf(); |
| 1056 | check(bigShut.list + bigShut.sess > bigOpen.list + bigOpen.sess + 40, |
| 1057 | `closing a full pool gives the rail back a real amount: ${bigOpen.list + bigOpen.sess}px -> ${bigShut.list + bigShut.sess}px`); |
| 1058 | check(Math.abs(bigShut.share - bigOpen.share) <= 0.03, |
| 1059 | `and the divider does not drift for it: ${bigOpen.share} -> ${bigShut.share}`); |
| 1060 | // Closed, with 36 tags filed, the whole filter is one row. |
| 1061 | const bigRow = await bp.evaluate(() => { |
| 1062 | const f = document.getElementById('diamond-filter'); |
| 1063 | return f ? Math.round(f.getBoundingClientRect().height) : 0; |
| 1064 | }); |
| 1065 | check(bigRow > 0 && bigRow <= 26, |
| 1066 | `and 36 tags cost the rail one row instead of four: the filter is ${bigRow}px tall`); |
| 1067 | await shot(b, 'tags-pool-many-collapsed'); |
| 1068 | // A filter built while it is closed still speaks, at this size too. |
| 1069 | await bClickTag('alpha-0'); |
| 1070 | const bigAct = await bp.evaluate(() => { |
| 1071 | const row = document.querySelector('#diamond-filter .tagf-active'); |
| 1072 | return { chips: row ? [...row.querySelectorAll('.tag-chip')].map(c => c.textContent) : null, |
| 1073 | pool: !!document.querySelector('#diamond-filter .tagf-pool'), |
| 1074 | ctl: !!document.querySelector('#diamond-filter .tagf-ctl') }; |
| 1075 | }); |
| 1076 | check(!!bigAct.chips && JSON.stringify(bigAct.chips) === JSON.stringify(['alpha-0']) |
| 1077 | && !bigAct.pool && bigAct.ctl, |
| 1078 | `one tag filtering out of 36 shows ONE chip, not 36: ${JSON.stringify(bigAct)}`); |
| 1079 | const bigActShare = await shareOf(); |
| 1080 | check(Math.abs(bigActShare.share - bigOpen.share) <= 0.03, |
| 1081 | `and the divider holds when that row arrives: ${bigShut.share} -> ${bigActShare.share}`); |
| 1082 | await shot(b, 'tags-pool-many-collapsed-active'); |
| 1083 | await bp.evaluate(() => { const c = document.querySelector('.tag-clear-all'); if (c) c.click(); }); |
| 1084 | await bp.waitForTimeout(300); |
| 1085 | await setPoolOn(bp, true); |
| 1086 | |
| 1087 | // ── The phone drawer ───────────────────────────────────────────── |
| 1088 | // The rail is a drawer there, 380px of a 390px screen, and the pool is the |
| 1089 | // first thing under the search box on it. |
| 1090 | await bp.setViewportSize({ width: 390, height: 844 }); |
| 1091 | await bp.waitForTimeout(600); |
| 1092 | await bp.evaluate(() => document.getElementById('drawer-btn').click()); |
| 1093 | await bp.waitForTimeout(700); |
| 1094 | // Back to the top of the pool, so the shot below is the state a thumb opens on. |
| 1095 | await bp.evaluate(() => { const el = document.querySelector('.tagf-pool'); if (el) el.scrollTop = 0; }); |
| 1096 | await bp.waitForTimeout(200); |
| 1097 | const phone = await bp.evaluate(() => { |
| 1098 | const el = document.querySelector('#diamond-filter .tagf-pool'); |
| 1099 | if (!el) return null; |
| 1100 | const r = el.getBoundingClientRect(); |
| 1101 | const cs = getComputedStyle(el); |
| 1102 | return { |
| 1103 | up: cs.display !== 'none' && r.height > 0, |
| 1104 | h: Math.round(r.height), right: Math.round(r.right), vw: window.innerWidth, |
| 1105 | scrollH: el.scrollHeight, |
| 1106 | // The drawer scrolls as one column; the pool must not drag it along |
| 1107 | // when a thumb runs off the end of the chips. |
| 1108 | contain: cs.overscrollBehaviorY, |
| 1109 | chips: el.children.length, |
| 1110 | }; |
| 1111 | }); |
| 1112 | check(!!phone && phone.up && phone.chips >= 30, |
| 1113 | `the pool is on the phone drawer too: ${JSON.stringify(phone)}`); |
| 1114 | check(!!phone && phone.h <= 80 && phone.right <= phone.vw, |
| 1115 | `capped there as well, and inside the screen: ${JSON.stringify(phone)}`); |
| 1116 | check(!!phone && phone.contain === 'contain', |
| 1117 | `and its own scroll does not become the drawer's: ${JSON.stringify(phone && phone.contain)}`); |
| 1118 | await shot(b, 'tags-pool-phone'); |
| 1119 | // And the row it collapses to, on the same screen: the drawer is where the rent |
| 1120 | // is dearest -- a phone shows a handful of Diamonds at a time, and four rows of |
| 1121 | // chips is most of what a thumb came for. |
| 1122 | await setPoolOn(bp, false); |
| 1123 | const phoneShut = await bp.evaluate(() => { |
| 1124 | const b2 = document.querySelector('#diamond-filter .tagf-toggle'); |
| 1125 | const f = document.getElementById('diamond-filter'); |
| 1126 | if (!b2 || !f) return null; |
| 1127 | const r = b2.getBoundingClientRect(), fr = f.getBoundingClientRect(); |
| 1128 | return { text: b2.textContent.trim(), h: Math.round(fr.height), |
| 1129 | w: Math.round(r.width), right: Math.round(r.right), vw: window.innerWidth, |
| 1130 | pool: !!document.querySelector('#diamond-filter .tagf-pool') }; |
| 1131 | }); |
| 1132 | check(!!phoneShut && !phoneShut.pool && phoneShut.h <= 26 && phoneShut.right <= phoneShut.vw, |
| 1133 | `the phone drawer collapses to one row inside the screen: ${JSON.stringify(phoneShut)}`); |
| 1134 | await shot(b, 'tags-pool-phone-collapsed'); |
| 1135 | await bp.setViewportSize({ width: 1400, height: 900 }); |
| 1136 | await bp.waitForTimeout(500); |
| 1137 | |
| 1138 | const errsB = errors(b).filter(e => !/502 \(Bad Gateway\)/.test(e)); |
| 1139 | check(errsB.length === 0, `no console errors on the restoring session: ${JSON.stringify(errsB.slice(0, 3))}`); |
| 1140 | await b.close(); |
| 1141 | |
| 1142 | console.log(out.join('\n')); |
| 1143 | if (known.length) console.log(`\nKNOWN, NOT THIS CHANGE:\n - ${known.join('\n - ')}`); |
| 1144 | console.log(bad === 0 ? `\nALL ${out.length} CHECKS PASSED` : `\n${bad} of ${out.length} FAILED`); |
| 1145 | process.exit(bad === 0 ? 0 : 1); |