oxedyne/daimond/dev/verify_reachlegible.mjs
24.2 KiB, 1 run
created by r2519314175:629, 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_reachlegible.mjs — the Workspace panel says what a daimon may reach, and |
| 2 | // the control that widens it says so in a word. |
| 3 | // |
| 4 | // ── WHY ────────────────────────────────────────────────────────────────────── |
| 5 | // |
| 6 | // Two faults, both found by the owner failing to use his own app on 2026-08-23, |
| 7 | // both in the Workspace panel, and both invisible to every check that existed. |
| 8 | // |
| 9 | // 1. THE SCOPE CHIP READ AS A PERMISSION SETTING AND IS A VIEW FILTER. He |
| 10 | // worked an evening on `Everything`, where the panel drew the workspace at |
| 11 | // large and said nothing whatever about the Diamond in front of him: the |
| 12 | // Toolchains row was not drawn, the marks were in the other tree, and there |
| 13 | // was no sentence anywhere. Nothing was marked into that Diamond, so |
| 14 | // `diamond_bounds` gave it one `OnlyWriteUnder` — its own folder — and a |
| 15 | // `file_search` naming no path started there, because `walk_starts` |
| 16 | // (src/tools.rs) takes its default from exactly those prefixes. His daimon |
| 17 | // reported a function present ten times as absent. Every fact needed to see |
| 18 | // that coming was in the store and none of it was on the screen. |
| 19 | // |
| 20 | // 2. TWO `+` BUTTONS WITH IDENTICAL TEXT. One marked a folder into a fence and |
| 21 | // one put a file in front of a model, and the only difference on screen was |
| 22 | // a tooltip. Worse, the crystal footer's `+` was labelled "Attach" while |
| 23 | // doing the marking, because on a Diamond `Files.attachAdd` writes a `holds` |
| 24 | // link and `Files.bounds` turns every one of those into an `OnlyWriteUnder`. |
| 25 | // |
| 26 | // ── WHAT IT ASSERTS, WHICH IS THE PROPERTY AND NOT THE FIX ─────────────────── |
| 27 | // |
| 28 | // Nothing here asks whether an element exists. Six properties, and each can hold |
| 29 | // while another breaks: |
| 30 | // |
| 31 | // 1. THE REACH ROW IS DRAWN IN BOTH TREES. `Everything` is where the fault |
| 32 | // happened, so `Everything` is where it is measured first. |
| 33 | // 2. IT AGREES WITH THE FENCE, path for path and in order, against |
| 34 | // `DaimondDiamond.bounds` — the same call `scopeAgentTo` hands the engine. |
| 35 | // A row built from the panel's own cache would pass an existence check and |
| 36 | // fail this one. |
| 37 | // 3. THE ZERO STATE IS SAID OUT LOUD, IN INK, WITHOUT A HOVER. This is the |
| 38 | // state the owner was in and could not see. Asserted as a laid-out element |
| 39 | // carrying a non-empty sentence that names the Diamond — a `title` would |
| 40 | // not do, which is the whole complaint. |
| 41 | // 4. THE TOOLCHAINS ROW IS OFFERED ON `Everything` TOO. |
| 42 | // 5. ONE PRESS MARKS THE FOLDER ON SCREEN, and the fence and the row both |
| 43 | // move. He had to open a picker and walk a tree back down to the folder he |
| 44 | // was already standing in. |
| 45 | // 6. A `+` THAT WIDENS A FENCE SAYS SO, and does not read the same as a `+` |
| 46 | // that does not. Measured as TEXT, because the tooltip was the fault. |
| 47 | // 7. A FOLDER'S MARK CONTROL IS NOT BEHIND A HOVER. A grant nobody can see is |
| 48 | // a grant nobody takes back. |
| 49 | // |
| 50 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a |
| 51 | // deliberately damaged copy of a real file to the real page through |
| 52 | // `page.route`, and the run is then EXPECTED TO FAIL. A break whose anchor is |
| 53 | // not found aborts rather than passing quietly. |
| 54 | // |
| 55 | // node dev/verify_reachlegible.mjs --break noreach # 1,2,3: the row only in the Diamond tree |
| 56 | // node dev/verify_reachlegible.mjs --break reachlies # 2: the marks dropped from the row |
| 57 | // node dev/verify_reachlegible.mjs --break reachown # 2: the own folder dropped from it |
| 58 | // node dev/verify_reachlegible.mjs --break quietzero # 3: the zero state left unsaid |
| 59 | // node dev/verify_reachlegible.mjs --break scopeonly # 4: Toolchains back in one tree |
| 60 | // node dev/verify_reachlegible.mjs --break nomarkhere # 5: no one-press mark |
| 61 | // node dev/verify_reachlegible.mjs --break bareplus # 6: the `+` back to a bare `+` |
| 62 | // node dev/verify_reachlegible.mjs --break sameword # 6: the two `+` given one word again |
| 63 | // node dev/verify_reachlegible.mjs --break hoveronly # 7: the mark hidden until hover |
| 64 | // node dev/verify_reachlegible.mjs --break noclass # 7: the row control never marked as a grant |
| 65 | // node dev/verify_reachlegible.mjs --break panelmute # 8: both rows silent in both trees |
| 66 | // node dev/verify_reachlegible.mjs # and then, clean |
| 67 | // |
| 68 | // BRING THE WORLD DOWN AND UP BETWEEN RUNS. The mock keeps state across a run, |
| 69 | // so a re-run is not a repeat — see the header of dev/verify_twodepth.mjs for |
| 70 | // the hour that cost. |
| 71 | // |
| 72 | // bash dev/world.sh 4 --down && eval "$(bash dev/world.sh 4 --up)" |
| 73 | // |
| 74 | // Needs dev/serve.mjs and dev/mockllm.mjs. No gateway and no real provider: |
| 75 | // nothing here sends a turn, because every fault is a thing on the screen. The |
| 76 | // mock is connected only because the New Diamond form refuses to create one |
| 77 | // without a model it can resolve a key for. |
| 78 | |
| 79 | import fs from 'node:fs'; |
| 80 | import path from 'node:path'; |
| 81 | import { fileURLToPath } from 'node:url'; |
| 82 | import { open, shot, scratch, errors } from './harness.mjs'; |
| 83 | |
| 84 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 85 | const WWW = path.join(HERE, '..', 'www'); |
| 86 | |
| 87 | const BREAK = (() => { |
| 88 | const i = process.argv.indexOf('--break'); |
| 89 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 90 | })(); |
| 91 | |
| 92 | const PROFILE = scratch('pw', 'reachlegible' + (BREAK ? '-' + BREAK : '')); |
| 93 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 94 | |
| 95 | const ok = [], bad = []; |
| 96 | const check = (name, pass, detail) => { |
| 97 | (pass ? ok : bad).push(name); |
| 98 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail != null ? ' — ' + detail : '')); |
| 99 | }; |
| 100 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 101 | |
| 102 | // ── The breaks ─────────────────────────────────────────────────────────────── |
| 103 | // |
| 104 | // Each is one line and is scoped to survive every check but the ones it proves. |
| 105 | // `noreach` and `scopeonly` are the two defects exactly as they stood. |
| 106 | const BREAKS = { |
| 107 | // The row put back behind the scope chip, which is where every fact about the |
| 108 | // fence used to live. |
| 109 | noreach: [{ |
| 110 | file: 'js/daimond.js', |
| 111 | find: " if (!currentDiamond) { reachEl.innerHTML = ''; reachEl.style.display = 'none'; return; }", |
| 112 | with: " if (!currentDiamond || !diamondScope()) { reachEl.innerHTML = ''; reachEl.style.display = 'none'; return; }", |
| 113 | }], |
| 114 | // The row drawn, and drawn from nothing: the marks dropped, so it reports a |
| 115 | // fence narrower than the one the turn is given. Existence checks all pass. |
| 116 | reachlies: [{ |
| 117 | file: 'js/daimond.js', |
| 118 | find: " var marks = b.attached || [], ro = b.read_only || [];", |
| 119 | with: " var marks = [], ro = b.read_only || [];", |
| 120 | }], |
| 121 | // The Diamond's own folder left off the row, so the panel under-reports the |
| 122 | // fence by exactly the place a bare search starts when nothing else is marked. |
| 123 | // `reachlies` cannot prove 2, because at that point there is nothing to drop. |
| 124 | reachown: [{ |
| 125 | file: 'js/daimond.js', |
| 126 | find: " reachEl.appendChild(reachChip(t('dws.reach_own'), own, false));", |
| 127 | with: " void own;", |
| 128 | }], |
| 129 | // The state the owner was in, left unsaid again. |
| 130 | quietzero: [{ |
| 131 | file: 'js/daimond.js', |
| 132 | find: " : t('dws.reach_none', { name: currentDiamond.name });", |
| 133 | with: " : '';", |
| 134 | }], |
| 135 | // Toolchains back in the Diamond tree only — absent rather than explained. |
| 136 | scopeonly: [{ |
| 137 | file: 'js/daimond.js', |
| 138 | find: " if (!currentDiamond) { kitsEl.style.display = 'none'; return; }", |
| 139 | with: " if (!currentDiamond || !diamondScope()) { kitsEl.style.display = 'none'; return; }", |
| 140 | }], |
| 141 | // The one-press mark taken away, so the only route back is the picker. |
| 142 | nomarkhere: [{ |
| 143 | file: 'js/daimond.js', |
| 144 | find: " if (!currentDiamond || !curDir) return null;", |
| 145 | with: " if (true) return null;", |
| 146 | }], |
| 147 | // The `+` back to what it was: one glyph, two meanings, a tooltip between them. |
| 148 | bareplus: [{ |
| 149 | file: 'js/daimond.js', |
| 150 | find: " add.textContent = '+ ' + t(grants ? 'attach.add_mark' : 'attach.add');", |
| 151 | with: " add.textContent = '+';", |
| 152 | }], |
| 153 | // The class never applied, so nothing on the row says it is a grant and the |
| 154 | // rule that lifts it has nothing to match. `hoveronly` breaks the rule and |
| 155 | // this breaks the mark, which are two different ways to lose the same thing. |
| 156 | noclass: [{ |
| 157 | file: 'js/daimond.js', |
| 158 | find: " btn.classList.toggle('mark', marks);", |
| 159 | with: " btn.classList.toggle('mark', false);", |
| 160 | }], |
| 161 | // The two `+` buttons given one word again. Nothing about the page changes; |
| 162 | // the only thing that breaks is that they can no longer be told apart, which |
| 163 | // is fault 2 exactly. |
| 164 | sameword: [{ |
| 165 | file: 'i18n/en.js', |
| 166 | find: " 'attach.add_mark': 'Mark a folder in',", |
| 167 | with: " 'attach.add_mark': 'Attach',", |
| 168 | }], |
| 169 | // Both rows muted at once: the panel back to saying nothing about the Diamond |
| 170 | // in either tree, which is what makes 8 a check about the OTHER tree rather |
| 171 | // than a restatement of 1 and 4. |
| 172 | panelmute: [ |
| 173 | { |
| 174 | file: 'js/daimond.js', |
| 175 | find: " async function paintReach() {\n if (!reachEl) return;", |
| 176 | with: " async function paintReach() {\n if (reachEl) { reachEl.style.display = 'none'; reachEl.innerHTML = ''; }\n if (true) return;", |
| 177 | }, |
| 178 | { |
| 179 | file: 'js/daimond.js', |
| 180 | find: " if (!currentDiamond) { kitsEl.style.display = 'none'; return; }", |
| 181 | with: " if (true) { kitsEl.style.display = 'none'; return; }", |
| 182 | }, |
| 183 | ], |
| 184 | // The grant on a folder row hidden until the pointer finds it. |
| 185 | hoveronly: [{ |
| 186 | file: 'css/files.css', |
| 187 | find: '.attach-btn.mark { visibility: visible; }', |
| 188 | with: '.attach-btn.mark { visibility: hidden; }', |
| 189 | }], |
| 190 | }; |
| 191 | |
| 192 | function damagedFiles() { |
| 193 | const byFile = new Map(); |
| 194 | for (const spec of (BREAKS[BREAK] || [])) { |
| 195 | // Off the WORKING copy, not off disk: a break with two edits to one file |
| 196 | // would otherwise keep only the last of them and quietly test half of itself. |
| 197 | const src = byFile.has(spec.file) |
| 198 | ? byFile.get(spec.file) |
| 199 | : fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 200 | const n = src.split(spec.find).length - 1; |
| 201 | if (n !== 1) { |
| 202 | console.error(`--break ${BREAK}: anchor appears ${n} time(s) in ${spec.file}, wanted 1.`); |
| 203 | console.error('The break is stale. A break that patches nothing launders a plain run as proof.'); |
| 204 | process.exit(1); |
| 205 | } |
| 206 | byFile.set(spec.file, src.replace(spec.find, spec.with)); |
| 207 | } |
| 208 | return byFile; |
| 209 | } |
| 210 | |
| 211 | async function serveBreaks(page) { |
| 212 | if (!BREAK) return; |
| 213 | if (!BREAKS[BREAK]) { |
| 214 | console.error(`--break ${BREAK}: no such break. Known: ${Object.keys(BREAKS).join(', ')}`); |
| 215 | process.exit(1); |
| 216 | } |
| 217 | for (const [file, body] of damagedFiles()) { |
| 218 | await page.route('**/' + file, r => r.fulfill({ |
| 219 | status: 200, |
| 220 | contentType: file.endsWith('.css') ? 'text/css' : 'application/javascript', |
| 221 | body, |
| 222 | })); |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | const s = await open({ name: 'reachlegible', profile: PROFILE, route: serveBreaks }); |
| 227 | const p = s.page; |
| 228 | |
| 229 | // Every expected word is asked of the running app rather than spelled here: this |
| 230 | // app ships eight languages and a test that hard-codes English passes in one. |
| 231 | const T = (k, v) => p.evaluate(([k, v]) => DaimondI18n.t(k, v || undefined), [k, v || null]); |
| 232 | |
| 233 | // ── The fixture ────────────────────────────────────────────────────────────── |
| 234 | // |
| 235 | // Written through the engine's own door, which is not fenced because it is not a |
| 236 | // turn — see the header of dev/harness.mjs. |
| 237 | const seeded = await p.evaluate(async () => { |
| 238 | const M = await import('/pkg/oxedyne_daimond.js'); |
| 239 | await M.write_file('code/parser/src/lib.rs', 'pub fn parse() {}\n'); |
| 240 | await M.write_file('code/parser/README.md', '# parser\n'); |
| 241 | await M.write_file('books/draft.md', '# draft\n'); |
| 242 | return true; |
| 243 | }); |
| 244 | check('0a the fixture is written', seeded === true); |
| 245 | |
| 246 | await p.click('#new-diamond-btn', { force: true }); |
| 247 | await p.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 248 | await p.fill('.dlg-input', 'Ship a CSV parser'); |
| 249 | await p.click('.dlg-ok', { force: true }); |
| 250 | await sleep(1600); |
| 251 | // Opened as well as made: `currentDiamond` is what every row of this panel is |
| 252 | // about, and a Diamond that exists but is not in focus draws no scope row at all. |
| 253 | await p.$$eval('.diamond-box', els => els[0] && els[0].click()); |
| 254 | await sleep(1200); |
| 255 | |
| 256 | const did = await p.evaluate(() => { |
| 257 | const d = window.DaimondDiamond && DaimondDiamond.current && DaimondDiamond.current(); |
| 258 | return d ? d.id : ''; |
| 259 | }).catch(() => ''); |
| 260 | |
| 261 | async function openPanel() { |
| 262 | await p.evaluate(() => window.DaimondPanels && DaimondPanels.show('work')); |
| 263 | await sleep(600); |
| 264 | await p.evaluate(() => { |
| 265 | const b = document.querySelector('#panel-work [data-act="refresh"]'); |
| 266 | if (b) b.click(); // NOT page.click: a forced click is inert headless |
| 267 | }); |
| 268 | await sleep(1100); |
| 269 | } |
| 270 | const setScope = async (which) => { |
| 271 | await p.click(`.files-scope-chip[data-scope="${which}"]`, { force: true }); |
| 272 | await sleep(1100); |
| 273 | }; |
| 274 | |
| 275 | /// The reach row exactly as it is on the screen: laid out or not, its chips in |
| 276 | /// order, and the sentence under them as INK. |
| 277 | const reach = () => p.evaluate(() => { |
| 278 | const el = document.querySelector('#panel-work .files-reach'); |
| 279 | if (!el) return { present: false }; |
| 280 | const says = el.querySelector('.files-reach-says'); |
| 281 | return { |
| 282 | present: true, |
| 283 | // `offsetParent` AND a non-zero box: a row that is `display:none` and one |
| 284 | // that is laid out at nothing are both invisible, and only one of them |
| 285 | // answers the attribute. |
| 286 | shown: !!el.offsetParent && el.getBoundingClientRect().height > 0, |
| 287 | label: (el.querySelector('.files-reach-label') || {}).textContent || '', |
| 288 | chips: [...el.querySelectorAll('.files-reach-chip')].map(c => ({ |
| 289 | path: c.dataset.path || '', text: c.textContent || '', |
| 290 | ro: c.classList.contains('ro'), |
| 291 | })), |
| 292 | says: says ? (says.textContent || '') : null, |
| 293 | // The zero state has a class of its own so it can be coloured; read it |
| 294 | // back so "it said something" and "it said the alarming thing" are two |
| 295 | // different questions. |
| 296 | saysNone: !!(says && says.classList.contains('none')), |
| 297 | saysShown: !!(says && says.offsetParent && says.getBoundingClientRect().height > 0), |
| 298 | markHere: (() => { |
| 299 | const b = el.querySelector('[data-act="mark-here"]'); |
| 300 | return b ? { path: b.dataset.path || '', text: b.textContent || '' } : null; |
| 301 | })(), |
| 302 | }; |
| 303 | }); |
| 304 | |
| 305 | /// What the engine would actually be told, from the call `scopeAgentTo` makes. |
| 306 | const bounds = (id) => p.evaluate((i) => window.DaimondDiamond.bounds(i), id); |
| 307 | |
| 308 | /// The Toolchains row, as furniture that is there or is not. |
| 309 | const kits = () => p.evaluate(() => { |
| 310 | const el = document.querySelector('#panel-work .files-kits'); |
| 311 | if (!el) return { present: false }; |
| 312 | return { |
| 313 | present: true, |
| 314 | shown: !!el.offsetParent && el.getBoundingClientRect().height > 0, |
| 315 | chips: [...el.querySelectorAll('.files-kit-chip')].map(c => c.textContent), |
| 316 | }; |
| 317 | }); |
| 318 | |
| 319 | await openPanel(); |
| 320 | |
| 321 | // ── 1. The row is in the tree the fault happened in ────────────────────────── |
| 322 | const scopeNow = await p.evaluate(() => window.DaimondFilesPanelScope || null); |
| 323 | const active = await p.evaluate(() => { |
| 324 | const c = document.querySelector('.files-scope-chip.active'); |
| 325 | return c ? c.dataset.scope : null; |
| 326 | }); |
| 327 | check('1a the panel opens on Everything, as it always has', active === 'all', |
| 328 | `active chip = ${active}${scopeNow ? '' : ''}`); |
| 329 | |
| 330 | const r0 = await reach(); |
| 331 | check('1b THE REACH ROW IS DRAWN ON `Everything`, which is where the evening went', |
| 332 | !!r0 && r0.shown === true, |
| 333 | r0 ? `present=${r0.present} laid out=${r0.shown}` : 'no row'); |
| 334 | check('1c and it is labelled, so the chips above it read as a view control', |
| 335 | !!r0 && r0.label.trim().length > 0, r0 ? JSON.stringify(r0.label) : ''); |
| 336 | |
| 337 | // ── 2. It agrees with the fence ────────────────────────────────────────────── |
| 338 | // |
| 339 | // Against `DaimondDiamond.bounds`, which is what `scopeAgentTo` hands the |
| 340 | // engine. A row built from the panel's own cached `attached` would draw the same |
| 341 | // pixels and fail here the moment the two differ. |
| 342 | const b0 = await bounds(did); |
| 343 | const wantPaths = [b0.own_dir].concat(b0.attached || []); |
| 344 | check('2a THE CHIPS ARE THE FENCE, path for path and in order', |
| 345 | !!r0 && JSON.stringify(r0.chips.map(c => c.path)) === JSON.stringify(wantPaths), |
| 346 | `row ${JSON.stringify(r0 ? r0.chips.map(c => c.path) : null)} vs fence ${JSON.stringify(wantPaths)}`); |
| 347 | check('2b and the Diamond’s own folder is the first of them, as diamond_bounds declares it', |
| 348 | !!r0 && r0.chips.length > 0 && r0.chips[0].path === b0.own_dir, |
| 349 | r0 && r0.chips[0] ? r0.chips[0].path : ''); |
| 350 | |
| 351 | // ── 3. The zero state, said out loud ───────────────────────────────────────── |
| 352 | check('3a nothing is marked in yet, which is the state under test', |
| 353 | Array.isArray(b0.attached) && b0.attached.length === 0, |
| 354 | JSON.stringify(b0.attached)); |
| 355 | const zeroWords = await T('dws.reach_none', { name: 'Ship a CSV parser' }); |
| 356 | check('3b THE PANEL SAYS SO, IN INK, WITH NOTHING HOVERED', |
| 357 | !!r0 && r0.saysNone === true && r0.saysShown === true && (r0.says || '').trim().length > 0, |
| 358 | r0 ? `none=${r0.saysNone} laid out=${r0.saysShown} text=${JSON.stringify((r0.says || '').slice(0, 60))}` : ''); |
| 359 | check('3c and it names the Diamond, so it is about the thing in front of him', |
| 360 | !!r0 && (r0.says || '').indexOf('Ship a CSV parser') !== -1, |
| 361 | r0 ? JSON.stringify((r0.says || '').slice(0, 90)) : ''); |
| 362 | check('3d it is the app’s own sentence for the state, not one this test invented', |
| 363 | !!r0 && (r0.says || '').trim() === (zeroWords || '').trim(), |
| 364 | JSON.stringify((zeroWords || '').slice(0, 60))); |
| 365 | await shot(s, 'reach-everything-zero'); |
| 366 | |
| 367 | // ── 4. Toolchains, in the tree the fault happened in ───────────────────────── |
| 368 | const k0 = await kits(); |
| 369 | check('4a THE TOOLCHAINS ROW IS OFFERED ON `Everything` TOO', |
| 370 | !!k0 && k0.shown === true, k0 ? `present=${k0.present} laid out=${k0.shown}` : 'no row'); |
| 371 | check('4b and it offers the whole set, not a subset of it', |
| 372 | !!k0 && k0.chips.length === 5, k0 ? JSON.stringify(k0.chips) : ''); |
| 373 | |
| 374 | // ── 5. One press, from the folder already on screen ────────────────────────── |
| 375 | // |
| 376 | // The route he had to take was: open the `+`, walk the picker's tree back down |
| 377 | // to the folder he was standing in, tick it, press OK. |
| 378 | const walked = await p.evaluate(() => { |
| 379 | const rows = [...document.querySelectorAll('#panel-work .files-tree .files-row')]; |
| 380 | const hit = rows.find(r => ((r.querySelector('.files-name') || {}).textContent || '') |
| 381 | .replace(/^[^A-Za-z0-9._-]+/, '').trim() === 'code'); |
| 382 | if (!hit) return false; |
| 383 | hit.click(); |
| 384 | return true; |
| 385 | }); |
| 386 | check('5a the tree walks into a folder', walked === true); |
| 387 | await sleep(1100); |
| 388 | |
| 389 | const r1 = await reach(); |
| 390 | check('5b THE PANEL OFFERS TO MARK THE FOLDER ON SCREEN', |
| 391 | !!r1 && !!r1.markHere && r1.markHere.path === 'code', |
| 392 | r1 ? JSON.stringify(r1.markHere) : ''); |
| 393 | |
| 394 | await p.evaluate(() => { |
| 395 | const b = document.querySelector('#panel-work .files-reach [data-act="mark-here"]'); |
| 396 | if (b) b.click(); |
| 397 | }); |
| 398 | await sleep(1400); |
| 399 | |
| 400 | const b1 = await bounds(did); |
| 401 | check('5c ONE PRESS AND THE FENCE MOVED', |
| 402 | Array.isArray(b1.attached) && b1.attached.indexOf('code') !== -1, |
| 403 | JSON.stringify(b1.attached)); |
| 404 | |
| 405 | const r2 = await reach(); |
| 406 | check('5d and the row moved with it: a chip for the folder now stands in the fence', |
| 407 | !!r2 && r2.chips.some(c => c.path === 'code'), |
| 408 | r2 ? JSON.stringify(r2.chips.map(c => c.path)) : ''); |
| 409 | check('5e and the alarm is gone, because the state it described is gone', |
| 410 | !!r2 && r2.saysNone === false && (r2.says || '').trim().length > 0, |
| 411 | r2 ? `none=${r2.saysNone} text=${JSON.stringify((r2.says || '').slice(0, 60))}` : ''); |
| 412 | check('5f and it does not offer to mark a folder that is already inside the fence', |
| 413 | !!r2 && r2.markHere === null, r2 ? JSON.stringify(r2.markHere) : ''); |
| 414 | await shot(s, 'reach-everything-marked'); |
| 415 | |
| 416 | // ── 6. The `+` says what it does ───────────────────────────────────────────── |
| 417 | // |
| 418 | // The crystal footer's `+` was labelled "Attach" while marking, because on a |
| 419 | // Diamond attaching IS marking. TEXT, not `title`: the tooltip was the fault. |
| 420 | const stripOpen = await p.evaluate(() => { |
| 421 | const strip = document.getElementById('arte-strip'); |
| 422 | if (!strip) return false; |
| 423 | strip.click(); |
| 424 | return true; |
| 425 | }); |
| 426 | check('6a the artefact strip opens', stripOpen === true); |
| 427 | await sleep(900); |
| 428 | const plus = await p.evaluate(() => { |
| 429 | const b = document.querySelector('#arte-list [data-act="attach-add"]'); |
| 430 | if (!b) return null; |
| 431 | return { text: (b.textContent || '').trim(), title: b.title || '', grants: b.dataset.grants || '' }; |
| 432 | }); |
| 433 | const markWord = (await T('attach.add_mark')) || ''; |
| 434 | const plainWord = (await T('attach.add')) || ''; |
| 435 | check('6b the Diamond’s `+` carries a WORD and not only a glyph', |
| 436 | !!plus && plus.text.replace(/^\+\s*/, '').length > 0, JSON.stringify(plus)); |
| 437 | check('6c AND IT IS THE MARKING WORD, because on a Diamond this `+` widens the fence', |
| 438 | !!plus && plus.text.indexOf(markWord) !== -1, |
| 439 | `button ${JSON.stringify(plus && plus.text)} vs ${JSON.stringify(markWord)}`); |
| 440 | check('6d and it does not read as the one that merely attaches', |
| 441 | !!plus && markWord !== plainWord && plus.text.replace(/^\+\s*/, '').trim() !== plainWord.trim(), |
| 442 | `mark=${JSON.stringify(markWord)} plain=${JSON.stringify(plainWord)}`); |
| 443 | check('6e and it says so to a screen reader as well as to an eye', |
| 444 | !!plus && plus.grants === '1' && plus.title.length > 0, JSON.stringify(plus)); |
| 445 | await shot(s, 'reach-plus-labelled'); |
| 446 | |
| 447 | // ── 7. A grant on a folder row is not behind a hover ───────────────────────── |
| 448 | // |
| 449 | // Measured with the pointer nowhere near it, and as the COMPUTED visibility: a |
| 450 | // class is not evidence, because the rule that reads it is in a file this test |
| 451 | // can also damage. |
| 452 | await p.mouse.move(5, 5); |
| 453 | await sleep(200); |
| 454 | const rowMark = await p.evaluate(() => { |
| 455 | const rows = [...document.querySelectorAll('#panel-work .files-tree .files-row.dir')]; |
| 456 | const row = rows.find(r => r.querySelector('.attach-btn')); |
| 457 | if (!row) return null; |
| 458 | const b = row.querySelector('.attach-btn'); |
| 459 | const cs = getComputedStyle(b); |
| 460 | return { |
| 461 | path: row.dataset.path || '', |
| 462 | mark: b.classList.contains('mark'), |
| 463 | vis: cs.visibility, |
| 464 | display: cs.display, |
| 465 | title: b.title || '', |
| 466 | }; |
| 467 | }); |
| 468 | check('7a a folder row carries the control that marks it in', |
| 469 | !!rowMark && rowMark.mark === true, JSON.stringify(rowMark)); |
| 470 | check('7b AND IT IS ON THE SCREEN WITH NOTHING HOVERED', |
| 471 | !!rowMark && rowMark.vis === 'visible' && rowMark.display !== 'none', |
| 472 | rowMark ? `visibility=${rowMark.vis} display=${rowMark.display}` : ''); |
| 473 | check('7c and it names the marking, not the attaching', |
| 474 | !!rowMark && rowMark.title.indexOf('Ship a CSV parser') !== -1, |
| 475 | rowMark ? JSON.stringify(rowMark.title) : ''); |
| 476 | |
| 477 | // ── 8. And the row survives the switch it was hidden behind ────────────────── |
| 478 | await setScope('diamond'); |
| 479 | const r3 = await reach(); |
| 480 | const b3 = await bounds(did); |
| 481 | check('8a the row is drawn in the Diamond tree as well', |
| 482 | !!r3 && r3.shown === true, r3 ? `laid out=${r3.shown}` : 'no row'); |
| 483 | check('8b and still agrees with the fence there', |
| 484 | !!r3 && JSON.stringify(r3.chips.map(c => c.path)) |
| 485 | === JSON.stringify([b3.own_dir].concat(b3.attached || [])), |
| 486 | r3 ? JSON.stringify(r3.chips.map(c => c.path)) : ''); |
| 487 | const k3 = await kits(); |
| 488 | check('8c and the Toolchains row did not move either', !!k3 && k3.shown === true); |
| 489 | await shot(s, 'reach-diamond-tree'); |
| 490 | |
| 491 | // ── 9. The console ─────────────────────────────────────────────────────────── |
| 492 | const errs = errors(s).filter(e => !/favicon|404|401|402|502|Bad Gateway|Account service|net::ERR/.test(e)); |
| 493 | check('9 nothing throws while all this happens', errs.length === 0, errs.slice(0, 2).join(' | ')); |
| 494 | |
| 495 | await s.close(); |
| 496 | |
| 497 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 498 | if (BREAK) { |
| 499 | console.log(bad.length |
| 500 | ? `--break ${BREAK}: reddened ${bad.length} check(s), as it must` |
| 501 | : `--break ${BREAK}: CHANGED NOTHING — the check it names is not testing what it says`); |
| 502 | process.exit(bad.length ? 0 : 1); |
| 503 | } |
| 504 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 505 | process.exit(bad.length ? 1 : 0); |