diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/gallery-probes/README.md | 26 | ||||
| -rw-r--r-- | tests/gallery-probes/audit-extraction.mjs | 77 | ||||
| -rw-r--r-- | tests/gallery-probes/probe-fams.mjs | 101 | ||||
| -rw-r--r-- | tests/gallery-probes/probe-vstatus.mjs | 209 | ||||
| -rw-r--r-- | tests/gallery-probes/probe.mjs | 1563 | ||||
| -rw-r--r-- | tests/gallery-tokens/test_gen_tokens.py | 278 | ||||
| -rw-r--r-- | tests/gallery-widgets/test-gallery-widget.el | 114 | ||||
| -rw-r--r-- | tests/installer-steps/test_pacman_hook_order.py | 27 |
8 files changed, 2395 insertions, 0 deletions
diff --git a/tests/gallery-probes/README.md b/tests/gallery-probes/README.md new file mode 100644 index 0000000..9b5ce7d --- /dev/null +++ b/tests/gallery-probes/README.md @@ -0,0 +1,26 @@ +# Gallery CDP probes + +Behavioral verification for docs/prototypes/panel-widget-gallery.html — headless Chrome +driven over the DevTools protocol (Node global WebSocket/fetch, no playwright). + +- probe.mjs — full regression: card count, size toggle, drag + click behavior under zoom, + zero-exception gate. Run: `node probe.mjs [--shot out.png]`. Update the card-count + assertion when cards are added. +- probe-fams.mjs — screen-color families: default fallbacks pixel-identical, chip clicks + recolor ink/face/trace, dynamic fills stay var-based. + +Both exit nonzero on failure. The gallery's componentization work (widgets.js extraction) +uses a green run of these as its no-regression gate per batch. + +## Traps when writing new checks + +- Dispatch clicks on the element that owns the listener, not an ancestor; drive drags + with in-page synthetic PointerEvents (setPointerCapture stubbed). +- A `find()` by textContent that matches nothing returns undefined, and the dispatch + no-ops silently — the check then fails looking like a widget bug. Match glyphs + exactly against the builder source (e.g. the transport stop button is '⏹' U+23F9, + not '■' U+25A0), and prefer asserting the find() hit before dispatching. +- Reduced-motion emulation must be set before Page.navigate (launch on about:blank, + Emulation.setEmulatedMedia, then navigate) or the page's matchMedia snapshot misses it. +- Kill stale headless Chromes after a crashed run; bracket the pkill pattern + (`pkill -f 'remote-debugging-port=934[5]'`) so it can't match its own command line. diff --git a/tests/gallery-probes/audit-extraction.mjs b/tests/gallery-probes/audit-extraction.mjs new file mode 100644 index 0000000..3a69f2b --- /dev/null +++ b/tests/gallery-probes/audit-extraction.mjs @@ -0,0 +1,77 @@ +// audit-extraction.mjs — report-only grader for the extraction-readiness bar. +// +// Every DUPRE builder should be liftable out of the gallery without archaeology: +// a contract comment documenting opts + handle, its CSS in DUPRE_CSS rather than +// the page stylesheet, and no page code reaching past a handle into instrument +// DOM. This audit is static (no browser): it reads the sources and reports a +// worklist. It gates nothing — the gate flips on only when the sweep reaches +// 100% (the VSTATUS/policy ratchet pattern). Exit 0 always, unless the audit +// itself cannot parse the sources. +// +// Checks: +// C1 contract comment — the block comment directly above DUPRE.<name> = +// function mentions both opts and the handle (the split-flap shape). +// C2 page-styled internals — class selectors in the page <style> that also +// appear in DUPRE_CSS (the page overriding component internals). +// C3 page reach-ins — page script querySelectors descending into a card's +// stage (past the handle) instead of using the .dupre surface. +// +// Usage: node audit-extraction.mjs [--verbose] + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const kitSrc = readFileSync(join(root, 'docs/prototypes/widgets.js'), 'utf8'); +const page = readFileSync(join(root, 'docs/prototypes/panel-widget-gallery.html'), 'utf8'); +const verbose = process.argv.includes('--verbose'); + +// ---- C1: contract comments ------------------------------------------------- +// Builders are `DUPRE.<name> = function`; the comment block that ends directly +// above (allowing blank lines) is its documentation. +const builders = []; +const re = /DUPRE\.(\w+)\s*=\s*function/g; +for (let m; (m = re.exec(kitSrc)); ) { + const name = m[1]; + const head = kitSrc.slice(0, m.index); + // Only the LAST comment block directly above counts — a lazy match from the + // file start would hand every builder the whole file's text and grade + // everything compliant (it did, on the audit's first run). + const at = head.lastIndexOf('/*'); + const tail = at >= 0 ? head.slice(at) : ''; + const comment = /^\/\*[\s\S]*\*\/\s*$/.test(tail) ? tail : ''; + const contract = /\bopts\b/i.test(comment) && /\bhandle\b/i.test(comment); + builders.push({ name, contract, commentLen: comment.length }); +} + +// ---- C2: page-styled instrument internals ------------------------------------- +const classSels = src => new Set([...src.matchAll(/\.([a-zA-Z][\w-]*)/g)].map(x => x[1])); +const cssBlock = kitSrc.match(/DUPRE_CSS\s*=\s*\[([\s\S]*?)\]\.join|DUPRE_CSS\s*=\s*`([\s\S]*?)`/); +const dupreCss = cssBlock ? (cssBlock[1] || cssBlock[2]) : ''; +if (!dupreCss) { console.error('AUDIT ERROR: DUPRE_CSS not found'); process.exit(1); } +const pageStyle = [...page.matchAll(/<style>([\s\S]*?)<\/style>/g)].map(x => x[1]).join('\n'); +const dupreClasses = classSels(dupreCss); +// Rig and page chrome legitimately live in the page; only instrument-internal +// classes (defined in DUPRE_CSS) styled by the page are violations. +const pageOverrides = [...classSels(pageStyle)].filter(c => dupreClasses.has(c)); + +// ---- C3: page reach-ins ---------------------------------------------------- +// Page script (inline <script> bodies only; widgets.js is external). +const pageScript = [...page.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(x => x[1]).join('\n'); +const RIG = /\.(famchips|fc|fgroup|lab|opts|wrd|stagew|winfo|wnote|wname|no)\b/; +const reachIns = [...pageScript.matchAll(/querySelector(?:All)?\(\s*['"](#card-[\w-]+\s+[^'"]+)['"]/g)] + .map(x => x[1]).filter(sel => !RIG.test(sel)); + +// ---- report ---------------------------------------------------------------- +const good = builders.filter(b => b.contract); +console.log(`extraction audit — ${builders.length} builders`); +console.log(`C1 contract comments: ${good.length}/${builders.length} compliant`); +if (verbose || true) { + const bad = builders.filter(b => !b.contract).map(b => b.name); + if (bad.length) console.log(` missing (${bad.length}): ${bad.join(' ')}`); +} +console.log(`C2 page-styled instrument internals: ${pageOverrides.length}${pageOverrides.length ? ' — ' + [...new Set(pageOverrides)].join(' ') : ' (clean)'}`); +console.log(`C3 page reach-ins past handles: ${reachIns.length}${reachIns.length ? '' : ' (clean)'}`); +reachIns.forEach(r => console.log(' ' + r)); +process.exit(0); diff --git a/tests/gallery-probes/probe-fams.mjs b/tests/gallery-probes/probe-fams.mjs new file mode 100644 index 0000000..a2333fb --- /dev/null +++ b/tests/gallery-probes/probe-fams.mjs @@ -0,0 +1,101 @@ +// Verify screen-family chips: presence, default fallbacks intact, live recolor on click. +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +const PORT = 9335; +const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html'; +const chrome = spawn('google-chrome-stable', ['--headless=new', `--remote-debugging-port=${PORT}`, '--no-first-run', '--window-size=1600,1200', URL], { stdio: 'ignore' }); +const sleep = ms => new Promise(r => setTimeout(r, ms)); +let ws, id = 0; const pending = new Map(); const events = []; +for (let i = 0; i < 40; i++) { try { const l = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); const p = l.find(t => t.type === 'page' && t.url.startsWith('file')); if (p) { ws = new WebSocket(p.webSocketDebuggerUrl); break; } } catch {} await sleep(250); } +await new Promise(r => ws.onopen = r); +ws.onmessage = m => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } else if (d.method) events.push(d); }; +const send = (method, params = {}) => new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); +const evl = async e => { const r = await send('Runtime.evaluate', { expression: e, returnByValue: true }); if (r.result.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails)); return r.result.result.value; }; +const fails = []; +const ok = (n, c, d = '') => { console.log(`${c ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`); if (!c) fails.push(n); }; +await send('Runtime.enable'); await sleep(1500); + +/* Everything below runs inside try/finally so a failing check cannot orphan the + browser. It could before, and the consequence was worse than a leak: the next + run connected to the survivor on this same port and reported results from a + STALE page — old widgets.js, chips already clicked. A probe that answers from + the previous run is worse than one that crashes. */ +try { + ok('no exceptions on load', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0); + // count screen-family rows only — card option groups (e.g. card 01) reuse .famchips + const famRows = await evl(`[...document.querySelectorAll('.famchips .lab')].filter(l=>l.textContent==='screen').length`); + ok('6 screen chip rows', famRows === 6, `got ${famRows}`); + + // default fallback: dmx ink computes to gold-hi rgb(255,190,84) + const dmxFill0 = await evl(`getComputedStyle(document.querySelector('#card-R10 svg text')).fill`); + ok('R10 default ink = gold-hi', dmxFill0 === 'rgb(255, 190, 84)', dmxFill0); + + // click green chip on R10 -> ink becomes phos rgb(127,224,160) + await evl(`document.querySelector('#card-R10').querySelector('.fc[title="green"]').click()`); + const dmxFill1 = await evl(`getComputedStyle(document.querySelector('#card-R10 svg text')).fill`); + ok('R10 green chip recolors ink', dmxFill1 === 'rgb(127, 224, 160)', dmxFill1); + + // radar: default sweep line = gold-hi; click green -> phos + const swSel = `document.querySelector('#card-R31')`; + const line0 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`); + await evl(`${swSel}.querySelector('.fc[title="green"]').click()`); + const line1 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`); + ok('R31 green chip recolors furniture', line0 !== line1 && line1 === 'rgb(88, 184, 126)', `${line0} -> ${line1}`); + + // scope (CSS instrument): trace stroke changes on amber chip + const tr0 = await evl(`getComputedStyle(document.querySelector('.dupre-scope polyline')).stroke`); + await evl(`document.querySelector('.dupre-scope').closest('.card').querySelector('.fc[title="amber"]').click()`); + const tr1 = await evl(`getComputedStyle(document.querySelector('.dupre-scope polyline')).stroke`); + ok('N11 amber chip recolors trace', tr0 !== tr1 && tr1 === 'rgb(255, 190, 84)', `${tr0} -> ${tr1}`); + + // R19: drag still works after family switch (click vfd, then check the handle's set() writes var-based fills) + await evl(`document.querySelector('#card-R19 .fc[title="vfd"]').click()`); + await evl(`document.getElementById('card-R19').dupre.set(30,70)`); + const barFill = await evl(`(()=>{const b=document.querySelectorAll('#card-R19 svg rect')[10];return b.getAttribute('fill');})()`); + ok('R19 dynamic fills use vars', barFill.startsWith('var(--scr-'), barFill); + + // R17: face gradient stop resolves to amber face after chip + await evl(`document.querySelector('#card-R17 .fc[title="amber"]').click()`); + const face = await evl(`getComputedStyle(document.querySelector('#card-R17 svg radialGradient stop')).stopColor`); + ok('R17 amber chip retints face', face === 'rgb(216, 203, 166)', face); + + // R57's window is a screen like any other, and it offers ALL six families + // rather than the five its siblings carry — including vfd, the marquee cyan. + const kpFams = await evl(`(()=>{ + const chips = [...document.querySelectorAll('#card-R57 .fc')].map(c => c.title); + const want = ['amber','green','red','blue','vfd','white']; + return want.every(f => chips.includes(f)) ? 'ok' : 'got ' + JSON.stringify(chips); + })()`); + ok('R57 offers all six screen families', kpFams === 'ok', kpFams); + + // Default is the shipped gold, unchanged until a chip is clicked. It reads the + // same as the amber family's --scr-hi because --gold-hi IS #ffbe54 — which is + // what makes the amber default pixel-identical rather than a near-miss. + const kpInk0 = await evl(`getComputedStyle(document.querySelector('#card-R57 .dupre-kp-pad text')).fill`); + ok('R57 default ink = gold-hi', kpInk0 === 'rgb(255, 190, 84)', kpInk0); + + // vfd recolors both the ink and the window behind it — a screen is not just its text + await evl(`document.querySelector('#card-R57 .fc[title="vfd"]').click()`); + const kpInk1 = await evl(`getComputedStyle(document.querySelector('#card-R57 .dupre-kp-pad text')).fill`); + ok('R57 vfd chip recolors the ink to marquee cyan', kpInk1 === 'rgb(99, 230, 200)', kpInk1); + /* .dupre-kp-win, not the first rect on the pad — that one is the faceplate, and + reading it made this check report the plate's colour and fail for the wrong + reason. */ + const kpBg = await evl(`getComputedStyle(document.querySelector('#card-R57 .dupre-kp-win')).fill`); + ok('R57 vfd chip recolors the window too', kpBg === 'rgb(6, 16, 13)', kpBg); + + ok('no exceptions after chip clicks', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0); + + // screenshot the recolored screens region (scroll R31 into view) + await evl(`document.querySelector('#card-R31 svg').scrollIntoView({block:'center'})`); + await sleep(400); + const shot = await send('Page.captureScreenshot', { format: 'png' }); + writeFileSync('fams.png', Buffer.from(shot.result.data, 'base64')); + console.log('shot: fams.png'); +} catch (e) { + console.error('PROBE ERROR: ' + e.message); + fails.push('probe-error'); +} finally { + chrome.kill(); +} +process.exit(fails.length ? 1 : 0); diff --git a/tests/gallery-probes/probe-vstatus.mjs b/tests/gallery-probes/probe-vstatus.mjs new file mode 100644 index 0000000..aefe43b --- /dev/null +++ b/tests/gallery-probes/probe-vstatus.mjs @@ -0,0 +1,209 @@ +// CDP probe for the gallery's baked validation statuses (VSTATUS) — no playwright. +// Usage: node probe-vstatus.mjs +// +// Runs against a throwaway --user-data-dir so localStorage starts empty every run: +// the whole point of VSTATUS is what renders when there is no local state, so a +// probe that inherited a profile's leftover gv-* keys would test nothing. +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const PORT = 9334; +const URL_FILE = '/home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html'; +const URL = 'file://' + URL_FILE; +const PROFILE = mkdtempSync(join(tmpdir(), 'gallery-vstatus-')); + +const chrome = spawn('google-chrome-stable', [ + '--headless=new', `--remote-debugging-port=${PORT}`, + `--user-data-dir=${PROFILE}`, + '--no-first-run', '--no-default-browser-check', + '--window-size=1600,1200', URL, +], { stdio: 'ignore' }); + +const sleep = ms => new Promise(r => setTimeout(r, ms)); +let ws, id = 0; +const pending = new Map(); +const events = []; + +async function connect() { + for (let i = 0; i < 40; i++) { + try { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); + const page = list.find(t => t.type === 'page' && t.url.startsWith('file')); + if (page) { ws = new WebSocket(page.webSocketDebuggerUrl); break; } + } catch { /* retry */ } + await sleep(250); + } + if (!ws) throw new Error('no page target'); + await new Promise(r => ws.onopen = r); + ws.onmessage = m => { + const d = JSON.parse(m.data); + if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } + else if (d.method) events.push(d); + }; +} +function send(method, params = {}) { + return new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); +} +async function evl(expr) { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }); + if (r.result.exceptionDetails) throw new Error('eval failed: ' + JSON.stringify(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text)); + return r.result.result.value; +} +async function reload() { + await send('Page.reload'); + await sleep(1200); +} +const lampOf = no => `document.querySelector('#card-${no} .vlamp').dataset.v`; + +const fails = []; +const ok = (name, cond, detail = '') => { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) fails.push(name); }; + +try { + await connect(); + await send('Runtime.enable'); + await send('Page.enable'); + await sleep(1500); + + const errs = events.filter(e => e.method === 'Runtime.exceptionThrown'); + ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200)); + + // 1. VSTATUS exists and is a plain record of no -> state + const shape = await evl(`(()=>{ + if (typeof VSTATUS === 'undefined') return 'undefined'; + if (typeof VSTATUS !== 'object' || VSTATUS === null || Array.isArray(VSTATUS)) return 'not-an-object'; + const bad = Object.entries(VSTATUS).filter(([,v]) => !['amber','green'].includes(v)); + return bad.length ? 'bad-values:' + JSON.stringify(bad.slice(0,3)) : 'ok'; + })()`); + ok('VSTATUS is a no->state record of amber/green only', shape === 'ok', shape); + + // 2. every baked entry names a real card + const orphans = await evl(`Object.keys(VSTATUS).filter(no => !document.getElementById('card-'+no))`); + ok('no baked entry names a missing card', orphans.length === 0, orphans.join(', ')); + + // 2b. the baked block's SOURCE TEXT is in DOM order — it is hand-pasted from + // GVexport, and a block written in some other order reshuffles wholesale on + // the next bake. This reads the file rather than the live object on purpose: + // JS hoists canonical integer keys ('12' before '01') on any object, so + // Object.keys can never report DOM order and asserting it would be + // unsatisfiable. Only the text on disk carries the property that matters. + const src = readFileSync(URL_FILE, 'utf8'); + const block = src.match(/const VSTATUS=\{([\s\S]*?)\n\};/); + if (!block) { + ok('VSTATUS source block is findable', false, 'regex did not match'); + } else { + const textKeys = [...block[1].matchAll(/"([^"]+)"\s*:/g)].map(m => m[1]); + const domOrder = await evl(`[...document.querySelectorAll('.vlamp')].map(l => l.closest('.card').id.slice(5))`); + const want = domOrder.filter(no => textKeys.includes(no)); + ok('VSTATUS source text is in DOM order', + JSON.stringify(textKeys) === JSON.stringify(want), + `baked=${JSON.stringify(textKeys)} want=${JSON.stringify(want)}`); + } + + // 3. with empty localStorage, baked entries render as their baked state + const mismatched = await evl(`Object.entries(VSTATUS) + .filter(([no,v]) => document.querySelector('#card-'+no+' .vlamp').dataset.v !== v) + .map(([no,v]) => no+':want='+v+',got='+document.querySelector('#card-'+no+' .vlamp').dataset.v)`); + ok('baked entries render on a fresh profile', mismatched.length === 0, mismatched.slice(0, 5).join(' ')); + + // 4. a card with no baked entry renders off + const unbaked = await evl(`(()=>{ + const l = [...document.querySelectorAll('.vlamp')].find(l => !(l.closest('.card').id.slice(5) in VSTATUS)); + return l ? l.closest('.card').id.slice(5) + '=' + l.dataset.v : 'none'; + })()`); + ok('an unbaked card defaults to off', unbaked === 'none' || unbaked.endsWith('=off'), unbaked); + + // 5. the tally's done count equals the green lamps on screen + const tally = await evl(`(()=>{ + const green = document.querySelectorAll('.vlamp[data-v="green"]').length; + const row = document.querySelector('.vtally .vrow[data-v="green"] .vn'); + return [green, row ? +row.textContent : -1]; + })()`); + ok('tally done count matches green lamps', tally[0] === tally[1], `lamps=${tally[0]} tally=${tally[1]}`); + + // 6. localStorage wins over the default. Holds with an empty VSTATUS, so this + // check is live from the first commit rather than waiting on a baked block. + const any = await evl(`(()=>{ + const l = [...document.querySelectorAll('.vlamp')].find(l => !(l.closest('.card').id.slice(5) in VSTATUS)); + return l ? l.closest('.card').id.slice(5) : null; + })()`); + if (any) { + await evl(`localStorage.setItem('gv-${any}','green'); ''`); + await reload(); + const set = await evl(lampOf(any)); + ok('localStorage sets an unbaked lamp', set === 'green', `card ${any} -> ${set}`); + await evl(`localStorage.removeItem('gv-${any}'); ''`); + await reload(); + const cleared = await evl(lampOf(any)); + ok('clearing an unbaked lamp returns it to off', cleared === 'off', `card ${any} -> ${cleared}`); + } + + // 6b. localStorage beats baked, both ways — a deliberate un-check must survive a + // baked green, and clearing must fall back rather than stick at off. + const target = await evl(`Object.keys(VSTATUS)[0] || null`); + if (target) { + await evl(`localStorage.setItem('gv-${target}','off'); ''`); + await reload(); + const got = await evl(lampOf(target)); + ok('localStorage off overrides a baked entry', got === 'off', `card ${target} -> ${got}`); + await evl(`localStorage.removeItem('gv-${target}'); ''`); + await reload(); + const back = await evl(lampOf(target)); + const want = await evl(`VSTATUS['${target}']`); + ok('clearing localStorage falls back to baked', back === want, `card ${target} -> ${back}, want ${want}`); + } else { + console.log('SKIP baked-override checks — VSTATUS is empty (nothing baked yet)'); + } + + // 7. the export affordance reproduces what is on screen, in VSTATUS shape + const round = await evl(`(()=>{ + if (typeof GVexport !== 'function') return 'no-GVexport'; + let parsed; + try { parsed = JSON.parse(GVexport()); } catch (e) { return 'not-json: ' + e.message; } + const live = {}; + document.querySelectorAll('.vlamp').forEach(l => { + const v = l.dataset.v; if (v !== 'off') live[l.closest('.card').id.slice(5)] = v; + }); + return JSON.stringify(parsed) === JSON.stringify(live) ? 'ok' + : 'export=' + JSON.stringify(parsed).slice(0,120) + ' live=' + JSON.stringify(live).slice(0,120); + })()`); + ok('GVexport reproduces the live lamp state', round === 'ok', round); + + // 7b. exported keys follow card order. JSON.stringify would hoist canonical + // integer keys ('12') above the rest ('01', 'R05'), reshuffling the baked + // block on every bake; the export builds its text by hand to avoid it. + const order = await evl(`(()=>{ + const keys = [...GVexport().matchAll(/"([^"]+)":/g)].map(m => m[1]); + const dom = [...document.querySelectorAll('.vlamp')] + .filter(l => l.dataset.v !== 'off').map(l => l.closest('.card').id.slice(5)); + return JSON.stringify(keys) === JSON.stringify(dom) ? 'ok' + : 'export=' + JSON.stringify(keys) + ' dom=' + JSON.stringify(dom); + })()`); + ok('exported keys follow card order', order === 'ok', order); + + // 8. export tracks a click, so a mid-walk copy is current + const clicked = await evl(`(()=>{ + const l = document.querySelector('.vlamp'); + const no = l.closest('.card').id.slice(5); + l.click(); + const v = l.dataset.v; + const parsed = JSON.parse(GVexport()); + return (v === 'off' ? !(no in parsed) : parsed[no] === v) ? 'ok' : 'stale: lamp=' + v + ' export=' + JSON.stringify(parsed[no]); + })()`); + ok('GVexport tracks a lamp click', clicked === 'ok', clicked); + + const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); + ok('no exceptions after interaction', errs2.length === 0); +} catch (e) { + console.error('PROBE ERROR: ' + e.message); + fails.push('probe-error'); +} finally { + // Wait for Chrome to actually exit before removing its profile — kill() only + // signals, and a browser still flushing its profile dir makes rmSync throw + // ENOTEMPTY, which would fail an otherwise-green run. + chrome.kill(); + await Promise.race([new Promise(r => chrome.once('exit', r)), sleep(3000)]); + rmSync(PROFILE, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); +} +process.exit(fails.length ? 1 : 0); diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs new file mode 100644 index 0000000..3db09a5 --- /dev/null +++ b/tests/gallery-probes/probe.mjs @@ -0,0 +1,1563 @@ +// CDP probe for the instrument gallery — no playwright, Node global WebSocket/fetch. +// Usage: node probe.mjs [--shot out.png] [--size N] +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; + +const PORT = 9333; +const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html'; +const shotIdx = process.argv.indexOf('--shot'); +const shotPath = shotIdx > -1 ? process.argv[shotIdx + 1] : null; + +const chrome = spawn('google-chrome-stable', [ + '--headless=new', `--remote-debugging-port=${PORT}`, + '--no-first-run', '--no-default-browser-check', + '--window-size=1600,1200', URL, +], { stdio: 'ignore' }); + +const sleep = ms => new Promise(r => setTimeout(r, ms)); +let ws, id = 0; +const pending = new Map(); +const events = []; + +async function connect() { + for (let i = 0; i < 40; i++) { + try { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); + const page = list.find(t => t.type === 'page' && t.url.startsWith('file')); + if (page) { ws = new WebSocket(page.webSocketDebuggerUrl); break; } + } catch { /* retry */ } + await sleep(250); + } + if (!ws) throw new Error('no page target'); + await new Promise(r => ws.onopen = r); + ws.onmessage = m => { + const d = JSON.parse(m.data); + if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } + else if (d.method) events.push(d); + }; +} +function send(method, params = {}) { + return new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); +} +async function evl(expr) { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }); + if (r.result.exceptionDetails) throw new Error('eval failed: ' + JSON.stringify(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text)); + return r.result.result.value; +} +async function drag(x1, y1, x2, y2) { + await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: x1, y: y1, button: 'left', clickCount: 1 }); + const steps = 8; + for (let i = 1; i <= steps; i++) + await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: x1 + (x2 - x1) * i / steps, y: y1 + (y2 - y1) * i / steps, button: 'left' }); + await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: x2, y: y2, button: 'left', clickCount: 1 }); +} + +const fails = []; +const ok = (name, cond, detail = '') => { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) fails.push(name); }; + +try { + await connect(); + await send('Runtime.enable'); + await send('Page.enable'); + await sleep(1500); + + // 0. console errors / exceptions + const errs = events.filter(e => e.method === 'Runtime.exceptionThrown'); + ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200)); + + // 1. card count (the instruments/row default is checked in 1h) + const cards = await evl(`document.querySelectorAll('.card').length`); + ok('111 cards', cards === 111, `got ${cards}`); + + // 1b. Every card carries a spec sheet. R58 shipped without one and nothing + // noticed — the sheet is the card's actual specification, so a card without + // it is a drawing with no contract. Cheap to forget, cheap to check. + const sheets = await evl(`(()=>{ + const missing = [...document.querySelectorAll('.card')] + .filter(c => !c.querySelector('.winfo')) + .map(c => c.id.slice(5)); + return missing.length ? 'no spec sheet: ' + missing.join(' ') : 'ok'; + })()`); + ok('every card has a spec sheet', sheets === 'ok', sheets); + + // 1c. Card 07's lit colour comes from the shared accent family, not a hardcode. + // The chip said exactly one thing (gold) when "on" is good in one panel, a + // warning in the next and a fault in the one after. Checks the family is + // shared rather than copied: a second consumer must get the same five. + const accents = await evl(`(()=>{ + const want = ['amber','green','red','white','vfd']; + const chips = [...document.querySelectorAll('#card-07 .fc')].map(c => c.title); + const missing = want.filter(w => !chips.includes(w)); + if (missing.length) return 'card 07 missing accents: ' + missing.join(' '); + const built = Object.keys(DUPRE.accentStyles('--x')); + return JSON.stringify(built) === JSON.stringify(want) ? 'ok' : 'family is ' + JSON.stringify(built); + })()`); + ok('card 07 offers the shared accent family', accents === 'ok', accents); + + // 1d. The chip actually recolours, and its default is untouched until asked — + // the same fallback discipline the screen families use. + const chipInk = await evl(`(()=>{ + const chip = document.querySelector('#card-07 .dupre-chip'); + const lit = () => getComputedStyle(chip).color; + const before = lit(); + document.querySelector('#card-07 .fc[title="vfd"]').click(); + const after = lit(); + return before === 'rgb(226, 160, 56)' && after === 'rgb(99, 230, 200)' + ? 'ok' : before + ' -> ' + after; + })()`); + ok('card 07 defaults to gold and recolours on click', chipInk === 'ok', chipInk); + + // 1e. The colour-policy backbone. Every instrument's colour is either free (the + // consumer picks) or locked for a stated reason, and the colour pass kept + // finding cards where "is this one a standard?" was answered from memory and + // wrong. So policy is declared on the builder and checked here. The invariant + // that matters: the accent recolour mechanism and the 'accent' declaration + // imply each other, so a locked card can't silently sprout accent chips and + // an accent card can't forget to say so. Coverage grows card by card; this + // round classifies the touched set, and the count is reported not gated. + const policy = await evl(`(()=>{ + const KINDS = DUPRE.POLICIES; + if (!KINDS) return 'no POLICIES vocabulary'; + const valid = new Set(Object.keys(KINDS)); + const fns = Object.entries(DUPRE).filter(([,v]) => typeof v === 'function'); + const declared = fns.filter(([,v]) => v.POLICY); + // each POLICY record is complete: a valid kind, a why, an authentic range + const shape = declared.filter(([,v]) => !valid.has(v.POLICY.kind) || !v.POLICY.why || !v.POLICY.authentic) + .map(([k,v]) => k + '(' + v.POLICY.kind + ')'); + if (shape.length) return 'malformed policy record: ' + shape.join(' '); + // A recolour mechanism and an 'accent' kind imply each other, BOTH directions, + // and both directions must recognise the SAME mechanisms — or a locked card + // carrying a multi-colour on-family slips the reverse check. A recolour + // mechanism is a STYLES.accent axis or a STYLES.on family (slideToggle's shape). + const hasMech = v => !!(v.STYLES && (v.STYLES.accent || v.STYLES.on)); + const mechNotAccent = fns.filter(([,v]) => hasMech(v) && (!v.POLICY || v.POLICY.kind !== 'accent')).map(([k]) => k); + if (mechNotAccent.length) return 'recolour mechanism but not declared accent: ' + mechNotAccent.join(' '); + const accentNoMech = declared.filter(([,v]) => v.POLICY.kind === 'accent' && !hasMech(v)).map(([k]) => k); + if (accentNoMech.length) return 'declared accent but no recolour mechanism: ' + accentNoMech.join(' '); + // a screen kind must actually read the screen vars + const screenNoVars = declared.filter(([,v]) => v.POLICY.kind === 'screen' && !v.toString().includes('--scr-')).map(([k]) => k); + if (screenNoVars.length) return 'declared screen but reads no --scr- vars: ' + screenNoVars.join(' '); + return 'ok (' + declared.length + ' classified)'; + })()`); + ok('policy records valid, complete and mechanism-matched', policy.startsWith('ok'), policy); + + // 1f. The policy is on the PAGE, not just in code and this probe. Each card + // shows a badge derived from its builder's COLOR (single source, so the + // page can't drift from the declaration), and the badge marks free vs + // locked vs unclassified. Checks a known classified card renders its policy + // and a locked/unclassified one renders honestly. + const shown = await evl(`(()=>{ + const badge = no => document.querySelector('#card-' + no + ' .cpol'); + const b07 = badge('07'), b10 = badge('R10'); + if (!b07 || !b10) return 'no policy badge on card'; + if (b07.textContent !== 'accent' || b07.dataset.free !== 'true') return 'card 07 badge: ' + b07.textContent + '/' + b07.dataset.free; + if (b10.textContent !== 'screen') return 'card R10 badge: ' + b10.textContent; + // an unclassified card shows the dash, not a stale or invented policy + const un = [...document.querySelectorAll('.cpol')].find(b => b.dataset.pol === 'none'); + if (un && un.textContent !== '—') return 'unclassified badge should read dash: ' + un.textContent; + // the badge text always matches the builder's actual COLOR, never a copy + const drift = [...document.querySelectorAll('.card')].map(c => { + const b = c.querySelector('.cpol'); const want = c.dataset.cpol; + return (want === 'none' ? '—' : want) === b.textContent ? null : c.id; + }).filter(Boolean); + return drift.length ? 'badge drifts from data-cpol: ' + drift.slice(0,3).join(' ') : 'ok'; + })()`); + ok('colour policy is shown on each card, derived not copied', shown === 'ok', shown); + + // 1g. The index tally counts coverage the way the validation tally counts the + // walk, so "how far has the colour pass reached" is a glance, not a grep. + // free + locked + unclassified must sum to the card total. + const ptally = await evl(`(()=>{ + const rows = [...document.querySelectorAll('#ptally .vrow[data-g] .vn')].map(v => +v.textContent); + const total = +document.querySelector('#ptally .vtot .vn').textContent; + const cards = document.querySelectorAll('.card').length; + if (total !== cards) return 'tally total ' + total + ' != ' + cards + ' cards'; + if (rows.reduce((a,b)=>a+b,0) !== total) return 'free+locked+unclassified ' + rows + ' != ' + total; + return 'ok free=' + rows[0] + ' locked=' + rows[1] + ' unclassified=' + rows[2]; + })()`); + ok('colour policy tally sums to the card total', ptally.startsWith('ok'), ptally); + + // 1h. instruments/row: the control sets an explicit column count and the cards + // resize to match. One-per-row cards must be much wider than four-per-row, + // and the count actually reaches the grid. + ok('default is 3 instruments/row', await evl(`document.body.dataset.cols`) === '3'); + await evl(`document.querySelector('.szbar .dupre-key[data-cols="1"]').click()`); + const w1col = await evl(`document.querySelector('.card').getBoundingClientRect().width`); + const cols1 = await evl(`getComputedStyle(document.querySelector('.grid')).gridTemplateColumns.split(' ').length`); + await evl(`document.querySelector('.szbar .dupre-key[data-cols="4"]').click()`); + const w4col = await evl(`document.querySelector('.card').getBoundingClientRect().width`); + const cols4 = await evl(`getComputedStyle(document.querySelector('.grid')).gridTemplateColumns.split(' ').length`); + ok('1/row is much wider than 4/row', w1col > w4col * 2.5, `w1=${Math.round(w1col)} w4=${Math.round(w4col)}`); + ok('the grid renders the chosen column count', cols1 === 1 && cols4 === 4, `1->${cols1} 4->${cols4}`); + ok('instruments/row chip flips state', await evl(`document.body.dataset.cols`) === '4'); + + // 3. behavioral, zoomed: fader drag on card 03 changes readout. Run at 2/row, + // not 1/row: at one-per-row the card is wide enough that its controls fall + // outside the 1600px probe window and the dispatched mouse events miss. + await evl(`document.querySelector('.szbar .dupre-key[data-cols="2"]').click()`); + await evl(`document.querySelectorAll('.card')[2].scrollIntoView({block:'center'}); ''`); + await sleep(200); + const fr = await evl(`(()=>{const c=document.querySelectorAll('.card')[2];const f=c.querySelector('.dupre-fader');const r=f.getBoundingClientRect();return [r.left,r.top,r.width,r.height];})()`); + const before = await evl(`document.getElementById('rd-03').textContent`); + await drag(fr[0] + fr[2] * 0.2, fr[1] + fr[3] / 2, fr[0] + fr[2] * 0.9, fr[1] + fr[3] / 2); + await sleep(150); + const after = await evl(`document.getElementById('rd-03').textContent`); + ok('fader drag tracks at 2/row', before !== after && after !== '—', `'${before}' -> '${after}'`); + + // 4. behavioral, zoomed: toggle click on card 01 + await evl(`document.querySelectorAll('.card')[0].scrollIntoView({block:'center'}); ''`); + await sleep(200); + const sw = await evl(`(()=>{const c=document.querySelectorAll('.card')[0];const s=c.querySelector('.switch')||c.querySelector('.stagew > *');const r=s.getBoundingClientRect();return [r.left+r.width/2,r.top+r.height/2];})()`); + const t0 = await evl(`document.getElementById('rd-01').textContent`); + await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: sw[0], y: sw[1], button: 'left', clickCount: 1 }); + await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sw[0], y: sw[1], button: 'left', clickCount: 1 }); + await sleep(150); + const t1 = await evl(`document.getElementById('rd-01').textContent`); + ok('toggle click responds at 2/row', t0 !== t1, `'${t0}' -> '${t1}'`); + + // 5. card 02 console keys: reading order and per-key tone. LIVE is green + // because --pass is what the kit means by live everywhere else; gold stays + // the generic engaged look. Craig's call, 2026-07-16. + const order = await evl(`[...document.querySelectorAll('#card-02 .dupre-key')].map(b => b.textContent)`); + ok('card 02 keys read SCAN, LIVE, MUTED', + JSON.stringify(order) === JSON.stringify(['SCAN', 'LIVE', 'MUTED']), JSON.stringify(order)); + + const engaged = await evl(`(()=>{ + const lit = [...document.querySelectorAll('#card-02 .dupre-key')].filter(b => /\\bdupre-(on|green|red)\\b/.test(b.className)); + return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('dupre-key dupre-','') : 'lit=' + lit.length; + })()`); + ok('card 02 engages LIVE in green by default', engaged === 'LIVE:green', engaged); + + const muted = await evl(`(()=>{ + const keys = [...document.querySelectorAll('#card-02 .dupre-key')]; + keys.find(b => b.textContent === 'MUTED').click(); + const lit = keys.filter(b => /\\bdupre-(on|green|red)\\b/.test(b.className)); + return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('dupre-key dupre-','') : 'lit=' + lit.length; + })()`); + ok('card 02 MUTED engages red and releases LIVE', muted === 'MUTED:red', muted); + + const rd02 = await evl(`document.getElementById('rd-02').textContent`); + ok('card 02 readout tracks the engaged key', rd02 === 'MUTED', rd02); + + // 6. card 01 slide toggle: the new tone atoms exist. red/warn on the `on` axis + // let the ENGAGED state be the notable one (mute, record, airplane) — before + // these, red was only reachable via the `off` axis, which colours the + // disengaged state instead. dim offText lets an off toggle recede in a dense + // panel. Craig's call, 2026-07-16. + const atoms = await evl(`(()=>{ + const S = DUPRE.slideToggle.STYLES; + const want = { on: ['amber','green','dark','red','warn'], onText: ['panel','cream','green'], + off: ['dark','red'], offText: ['white','red','black','dim'], + thumb: ['light','dark','chrome','brass'] }; + const missing = []; + for (const [axis, names] of Object.entries(want)) + for (const n of names) if (!S[axis] || !S[axis][n]) missing.push(axis + '.' + n); + return missing.length ? 'missing: ' + missing.join(', ') : 'ok'; + })()`); + ok('slide toggle style atoms present', atoms === 'ok', atoms); + + // 7. presets name intent (a combination), not paint. Every axis a preset names + // must resolve in STYLES — a typo here would silently fall through to the + // stylesheet default and look "nearly right", which is the worst outcome. + const presets = await evl(`(()=>{ + const P = DUPRE.slideToggle.PRESETS, S = DUPRE.slideToggle.STYLES; + if (!P) return 'no PRESETS'; + const AX = DUPRE.slideToggle.AXIS_ORDER; + const bad = []; + for (const [name, p] of Object.entries(P)) { + for (const ax of AX) { + if (!p[ax]) bad.push(name + ' missing ' + ax); + else if (!S[ax][p[ax]]) bad.push(name + '.' + ax + '="' + p[ax] + '" not in STYLES'); + } + } + return bad.length ? bad.join('; ') : Object.keys(P).join(','); + })()`); + ok('every preset names a resolvable style on every axis', + presets === 'panel,run,armed,caution,dark', presets); + + // 7a. the dark preset: neither state lights the pill, so the legend colour is + // the only thing carrying state. Both backgrounds must stay dark while the + // inks diverge — if either pill lights, the preset has lost its point. + const dark = await evl(`(()=>{ + const P = DUPRE.slideToggle.PRESETS.dark, S = DUPRE.slideToggle.STYLES; + const onBg = S.on[P.on].vars['--sw-on-bg'], offBg = S.off[P.off].vars['--sw-off-bg']; + const onInk = S.onText[P.onText].vars['--sw-on-ink'], offInk = S.offText[P.offText].vars['--sw-off-ink']; + if (onBg !== offBg) return 'pills differ: on=' + onBg + ' off=' + offBg; + if (onInk === offInk) return 'inks identical, state unreadable: ' + onInk; + return 'ok'; + })()`); + ok('dark preset keeps both pills dark and the inks distinct', dark === 'ok', dark); + + // 7b. the card claims the preset it is actually in. The instrument defaults to + // `panel`, so a preset group with nothing lit would assert "no preset + // active" — false, and exactly the kind of quiet mislabel this walk exists + // to catch. Nothing above this point touches card 01's chips (checks 2-4 + // only toggle its switch, which does not restyle), so the default still + // stands here — but this must stay ABOVE checks 8/8b/9, which do mutate + // the chips. Insert preset-touching checks after them, not before. + const defaultPreset = await evl(`(()=>{ + const pg = [...document.querySelectorAll('#card-01 .fgroup')] + .find(g => g.querySelector('.lab')?.textContent === 'preset'); + if (!pg) return 'no preset group'; + const lit = [...pg.querySelectorAll('.fc')].filter(c => c.classList.contains('on')); + return lit.length === 1 ? lit[0].title : 'lit=' + lit.length; + })()`); + ok('card 01 defaults to the panel preset', defaultPreset === 'panel', defaultPreset); + + // 8. a preset chip drives the instrument AND re-syncs the axis chips, so the card + // never shows a combination the instrument isn't in. + const applied = await evl(`(()=>{ + const card = document.getElementById('card-01'); + const groups = [...card.querySelectorAll('.fgroup')]; + const pg = groups.find(g => g.querySelector('.lab')?.textContent === 'preset'); + if (!pg) return 'no preset group'; + const armed = [...pg.querySelectorAll('.fc')].find(c => c.title === 'armed'); + if (!armed) return 'no armed chip'; + armed.click(); + const onGroup = groups.find(g => g.querySelector('.lab')?.textContent === 'on'); + const lit = [...onGroup.querySelectorAll('.fc')].filter(c => c.classList.contains('on')); + const brd = card.querySelector('.switch').style.getPropertyValue('--sw-on-brd'); + return (lit.length === 1 ? lit[0].title : 'lit=' + lit.length) + '|' + brd; + })()`); + ok('armed preset drives instrument and syncs the on chip', applied === 'red|var(--fail)', applied); + + // 8b. axes are not independent: onText overrides the ink `on` sets, so changing + // `on` after picking an onText must not silently revert the legend while its + // chip still shows lit. Drive it in the hazardous order and check the ink. + const orderSafe = await evl(`(()=>{ + const card = document.getElementById('card-01'); + const groups = [...card.querySelectorAll('.fgroup')]; + const grp = l => groups.find(g => g.querySelector('.lab')?.textContent === l); + const chip = (l, t) => [...grp(l).querySelectorAll('.fc')].find(c => c.title === t); + chip('on text', 'green').click(); // legend green + chip('on', 'dark').click(); // then change the pill — must keep it + const ink = card.querySelector('.switch').style.getPropertyValue('--sw-on-ink'); + const lit = [...grp('on text').querySelectorAll('.fc')].filter(c => c.classList.contains('on')); + return ink + '|' + (lit.length === 1 ? lit[0].title : 'lit=' + lit.length); + })()`); + ok('changing on keeps the chosen onText (chips cannot lie)', + orderSafe === 'var(--sevgrn)|green', orderSafe); + + // 9. diverging on one axis clears the preset — the card stops claiming a preset + // it is no longer in. Re-establishes its own precondition (preset lit) rather + // than inheriting it: the checks above already clicked axis chips, each of + // which clears the preset, so asserting lit===0 without re-lighting first + // would pass against an already-cleared group and prove nothing — including + // against a regression where clearing fired for onText but not for `on`. + const diverged = await evl(`(()=>{ + const groups = [...document.querySelectorAll('#card-01 .fgroup')]; + const grp = l => groups.find(g => g.querySelector('.lab')?.textContent === l); + const litPresets = () => [...grp('preset').querySelectorAll('.fc')].filter(c => c.classList.contains('on')).length; + [...grp('preset').querySelectorAll('.fc')].find(c => c.title === 'run').click(); + const before = litPresets(); + [...grp('on').querySelectorAll('.fc')].find(c => c.title === 'green').click(); + return before + '->' + litPresets(); + })()`); + ok('changing an axis clears the preset selection', diverged === '1->0', diverged); + + // 10. R57 ABC keypad — fills the taxonomy's text x alphanumeric empty cell. + // ABC order is the whole point: it is what industrial keypads do wherever + // the operator can't be assumed to touch-type, and it is what Craig's + // reference photos show. A QWERTY drift here would silently lose the idiom. + const abcOrder = await evl(`(()=>{ + const letters = [...document.querySelectorAll('#card-R57 .dupre-kp-key')] + .map(k => k.dataset.k).filter(k => /^[A-Z]$/.test(k)); + const want = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ']; + return JSON.stringify(letters) === JSON.stringify(want) + ? 'ok' : 'got ' + letters.length + ': ' + letters.join(''); + })()`); + ok('R57 carries A-Z in alphabetical order', abcOrder === 'ok', abcOrder); + + const abcDigits = await evl(`(()=>{ + const d = [...document.querySelectorAll('#card-R57 .dupre-kp-key')] + .map(k => k.dataset.k).filter(k => /^[0-9]$/.test(k)); + return d.length === 10 ? 'ok' : 'got ' + d.length + ': ' + d.join(''); + })()`); + ok('R57 carries a full 0-9 block', abcDigits === 'ok', abcDigits); + + // 10b. LAYOUT, geometrically. The A-Z check above reads DOM order, which the + // builder controls by push order — it would pass with every key rendered + // in the wrong place. This reads actual x positions instead: letters own + // the left columns, digits the right, and the alphabet column-aligns with + // itself all the way down (the discontinuity Craig caught: A-L used to + // start at column 3 while M-X started at column 0). + const layout = await evl(`(()=>{ + const x = k => { + const g = [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + return g ? Math.round(g.querySelector('rect').getBBox().x) : null; + }; + const colStarts = ['A','D','G','J','M','S','Y'].map(x); + if (new Set(colStarts).size !== 1) return 'alphabet not column-aligned: ' + JSON.stringify(colStarts); + if (!(x('A') < x('1'))) return 'letters not left of digits: A=' + x('A') + ' 1=' + x('1'); + if (!(x('DEL') > x('J'))) return 'DEL not in the block beside the digits: DEL=' + x('DEL') + ' J=' + x('J'); + return 'ok'; + })()`); + ok('R57 letters left, digits right, alphabet column-aligned', layout === 'ok', layout); + + // 10c. DEL sits where the hand already is and CLR is exiled to the corner. + // Frequency and blast radius pull the same way: DEL is constant and costs + // one character, CLR is rare and costs the entry. Pinned because it is a + // deliberate inversion of where they started, easy to "tidy" back. + const reach = await evl(`(()=>{ + const box = k => { + const g = [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + return g ? g.querySelector('rect').getBBox() : null; + }; + const del = box('DEL'), clr = box('CLR'), ent = box('ENT'); + if (!del || !clr || !ent) return 'missing key'; + if (!(del.y < clr.y)) return 'DEL should sit above CLR: DEL.y=' + del.y + ' CLR.y=' + clr.y; + if (!(Math.abs(del.y - ent.y) < 1)) return 'DEL should share the ENT row'; + return 'ok'; + })()`); + ok('R57 DEL is in reach, CLR is in the corner', reach === 'ok', reach); + + // 10d. The three function keys are a cost ladder — DEL takes one character + // back, CLR throws the entry away, ENT commits — so each must read as a + // different key before the legend is read. Checks they are mutually + // distinct and all differ from a plain cap, rather than naming a gradient: + // the palette may be retuned, the distinction may not collapse. + const ladder = await evl(`(()=>{ + const fill = k => { + const g = [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + return g ? g.querySelector('rect').getAttribute('fill') : null; + }; + const f = { DEL: fill('DEL'), CLR: fill('CLR'), ENT: fill('ENT'), plain: fill('A'), digit: fill('1') }; + const fn = [f.DEL, f.CLR, f.ENT]; + if (new Set(fn).size !== 3) return 'function keys not mutually distinct: ' + JSON.stringify(f); + if (fn.includes(f.plain) || fn.includes(f.digit)) return 'a function key wears a plain cap: ' + JSON.stringify(f); + return 'ok'; + })()`); + ok('R57 DEL/CLR/ENT each read as their own key', ladder === 'ok', ladder); + + // 11. typing accumulates, in order. A keypad that registers presses but drops + // or reorders them is the failure that matters for a password field. + const typed = await evl(`(()=>{ + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + for (const c of ['W','I','F','I','7']) key(c).dispatchEvent(new MouseEvent('click', {bubbles:true})); + return document.getElementById('rd-R57').textContent; + })()`); + ok('R57 accumulates typed characters in order', typed.includes('WIFI7'), typed); + + // 11b. DEL takes back ONE character. Without it the only way out of a typo is + // wiping the whole entry, which on a 20-character passphrase means + // starting over — so the check that matters is that DEL is not CLR. + const del = await evl(`(()=>{ + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true})); + click('CLR'); + for (const c of ['C','A','B','S']) click(c); + click('DEL'); + const back = document.getElementById('rd-R57').textContent; + for (let i = 0; i < 6; i++) click('DEL'); // past empty: must not throw or wrap + const floor = document.getElementById('rd-R57').textContent; + return back + ' | ' + floor; + })()`); + // Both halves are asserted: the earlier version computed the past-empty + // state and then never looked at it, so "stops at empty" was a promise in + // the name only — a DEL that wrapped the buffer would have passed. + ok('R57 DEL takes back one character, and stops at empty', + del.split(' | ')[0] === 'CAB' && del.split(' | ')[1] === 'empty', del); + + // 11c. A space must be VISIBLE in the window. The buffer is honest either way, + // but SVG collapses trailing whitespace, so a space rendered as a space is + // a keypress with no feedback: the operator presses SPACE, sees nothing, + // presses again, and now carries two spaces they cannot see in a + // passphrase they can't read back. Checked past the 13-char truncation + // boundary, where there are no pad dots left for a space to displace. + const space = await evl(`(()=>{ + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true})); + const win = () => document.querySelector('#card-R57 text[font-size="14"]').textContent; + click('CLR'); + for (let i = 0; i < 13; i++) click('A'); + const before = win(); + click('SPC'); + const after = win(); + if (before === after) return 'space produced no visible change: ' + JSON.stringify(after); + if (/ $/.test(after)) return 'space rendered as a raw trailing space (invisible): ' + JSON.stringify(after); + click('CLR'); + return 'ok'; + })()`); + ok('R57 a typed space is visible in the window', space === 'ok', space); + + // 12. the two committing keys do different things: ENTER commits the buffer, + // CLEAR empties it. Types its own buffer rather than inheriting one from + // the checks above — they mutate it, so a check that assumed their leftovers + // would pass or fail on their behaviour instead of its own. + const committed = await evl(`(()=>{ + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true})); + click('CLR'); + for (const c of ['N','E','T','5']) click(c); + click('ENT'); + const after = document.getElementById('rd-R57').textContent; + click('CLR'); + return after + ' | ' + document.getElementById('rd-R57').textContent; + })()`); + // Asserts the COMMIT SIGNAL ('ENTER · ' + buf), not merely that the buffer + // is still readable: typing the last character already put NET5 in the + // readout, so /NET5/ was true before ENT was ever pressed. That check + // passed with the ENT branch deleted (the key falls through to buf += 'ENT' + // and NET5 still matches) — it could not fail. + ok('R57 ENTER commits and CLEAR empties', + /^ENTER · NET5$/.test(committed.split(' | ')[0]) && !/NET5/.test(committed.split(' | ')[1]), committed); + + // 13. KEYS is a declarative TABLE, not a function over a DOM event. The Emacs + // port installs this same table into a keymap — it never sees a keydown — + // so a function here would force it to re-derive the instrument's intent and + // the two bindings would drift. (README, keyboard contract.) + const keysTable = await evl(`(()=>{ + const K = DUPRE.abcKeypad.KEYS; + if (!K) return 'no KEYS'; + if (typeof K !== 'object' || Array.isArray(K)) return 'KEYS is not a plain table: ' + typeof K; + const missing = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'].filter(c => K[c] !== c); + if (missing.length) return 'unmapped or mis-mapped: ' + missing.join(''); + const want = { Space: 'SPC', Backspace: 'DEL', Enter: 'ENT' }; + for (const [k, v] of Object.entries(want)) if (K[k] !== v) return k + ' -> ' + K[k] + ', want ' + v; + return 'ok'; + })()`); + ok('R57 KEYS is a declarative table covering the plate', keysTable === 'ok', keysTable); + + // 13b. THE contract's first rule: no document-level listener. An instrument that + // binds globally types into itself from anywhere on a 110-card page. + // Typing at the body with the card unfocused must do nothing at all. + const unfocused = await evl(`(()=>{ + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + document.activeElement.blur(); + for (const c of ['A','B','C']) document.body.dispatchEvent( + new KeyboardEvent('keydown', { key: c, bubbles: true, cancelable: true })); + return document.getElementById('rd-R57').textContent; + })()`); + ok('R57 ignores keys when it does not have focus', unfocused === 'cleared', unfocused); + + // 13c. Focused, the same keys land — and land through press(), so click and key + // cannot drift apart. + const typedByKey = await evl(`(()=>{ + const pad = document.querySelector('#card-R57 .dupre-kp-pad'); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + pad.focus(); + const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); + ['n','e','t',' ','5'].forEach(send); // lowercase must normalise; space must map to SPC + const typed = document.getElementById('rd-R57').textContent; + send('Backspace'); + const bs = document.getElementById('rd-R57').textContent; + send('Enter'); + return typed + ' | ' + bs + ' | ' + document.getElementById('rd-R57').textContent; + })()`); + ok('R57 types from the keyboard when focused', typedByKey === 'NET 5 | NET | ENTER · NET ', typedByKey); + + // 13c-2. Click and key must not drift. Both routes are supposed to land in the + // same press(), so the same sequence entered each way must produce an + // identical buffer and readout. Duplicated logic in the handler would + // pass every check above this one and fail here. + const drift = await evl(`(()=>{ + const pad = document.querySelector('#card-R57 .dupre-kp-pad'); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + const rd = () => document.getElementById('rd-R57').textContent; + const seq = ['A','B','SPC','7']; + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + seq.forEach(k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true}))); + const byClick = rd() + '/' + document.getElementById('card-R57').dupre.get(); + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + pad.focus(); + ['A','B',' ','7'].forEach(k => + pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }))); + const byKey = rd() + '/' + document.getElementById('card-R57').dupre.get(); + return byClick === byKey ? 'ok' : 'drift: click=' + byClick + ' key=' + byKey; + })()`); + ok('R57 click and key land in the same place', drift === 'ok', drift); + + // 13c-3. press() is the allowlist, not the keydown handler. The handler guards + // the web; the Emacs port installs KEYS into a keymap and calls press + // directly, with no handler in the stack — so a press that trusts its + // caller is a hole in exactly the target the table exists for. + const pressGuard = await evl(`(()=>{ + const card = document.getElementById('card-R57'); + const h = card.dupre; + if (!h || !h.press) return 'no handle'; + const key = k => [...card.querySelectorAll('.dupre-kp-key')].find(e => e.dataset.k === k); + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + h.press('F1'); h.press('ArrowLeft'); h.press(''); + const junk = h.get(); + h.press('A'); + return junk === '' && h.get() === 'A' ? 'ok' : 'junk=' + JSON.stringify(junk) + ' then=' + JSON.stringify(h.get()); + })()`); + ok('R57 press() filters junk from any caller, not just the keyboard', pressGuard === 'ok', pressGuard); + + // 13c-4. Every key the table maps must be a real plate action, or the Emacs + // port installs a binding that silently does nothing. + const keysSubset = await evl(`(()=>{ + const bad = Object.entries(DUPRE.abcKeypad.KEYS).filter(([,v]) => !DUPRE.abcKeypad.ACTIONS.has(v)); + return bad.length ? 'KEYS maps to non-actions: ' + JSON.stringify(bad) : 'ok'; + })()`); + ok('R57 every KEYS value is a real plate action', keysSubset === 'ok', keysSubset); + + // 13d. preventDefault is spent only where there is a default worth killing. + // Space scrolls and Backspace navigates back, so those are claimed; Tab is + // how the page is navigable and Escape belongs to the audit stepper, so a + // instrument that swallows either breaks something it cannot see. + const defaults = await evl(`(()=>{ + const pad = document.querySelector('#card-R57 .dupre-kp-pad'); + pad.focus(); + const fired = k => { + const e = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }); + pad.dispatchEvent(e); + return e.defaultPrevented; + }; + const claimed = { Space: fired(' '), Backspace: fired('Backspace') }; + /* 'A' and 'Enter' are MAPPED keys that must still not be claimed — they reach + the same code path as Space, so they are what catches a preventDefault + moved after the lookup. Tab/Escape/F1 return before it and would stay green + through that regression on their own. */ + const free = { A: fired('A'), Enter: fired('Enter'), Tab: fired('Tab'), Escape: fired('Escape'), F1: fired('F1') }; + if (!claimed.Space || !claimed.Backspace) return 'not claimed: ' + JSON.stringify(claimed); + if (Object.values(free).some(Boolean)) return 'swallowed: ' + JSON.stringify(free); + return 'ok'; + })()`); + ok('R57 claims Space and Backspace, lets Tab/Escape through', defaults === 'ok', defaults); + + // 13e. press() is an allowlist, not a mailbox: an unmapped key must not append. + const unmapped = await evl(`(()=>{ + const pad = document.querySelector('#card-R57 .dupre-kp-pad'); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + pad.focus(); + ['F1','ArrowLeft','Home','é','!'].forEach(k => + pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }))); + return document.getElementById('rd-R57').textContent; + })()`); + ok('R57 drops keys that are not on the plate', unmapped === 'cleared', unmapped); + + // 14. R58 index typewriter. THE check: selecting is not committing. Walking the + // stylus over the plate must print nothing at all — that separation IS the + // grammar, and it's the whole reason this card exists next to R57. If a cell + // click ever prints, the card has silently become a keypad with extra steps. + // Reads the BUFFER, not the card readout: the readout is supposed to change + // while hunting ("stylus over g"), and asserting on it would fail a correct + // instrument for showing the operator where the pointer is. + const grammar = await evl(`(()=>{ + const cell = c => document.querySelector('#card-R58 .dupre-ix-cell[data-c="' + c + '"]'); + const lever = document.querySelector('#card-R58 .dupre-ix-lever'); + const buf = () => document.getElementById('card-R58').dupre.get(); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + const empty = buf(); + ['M','i','g'].forEach(c => cell(c).dispatchEvent(new MouseEvent('click', {bubbles:true}))); + const afterSelecting = buf(); + lever.dispatchEvent(new MouseEvent('click', {bubbles:true})); + return JSON.stringify([empty, afterSelecting, buf()]); + })()`); + ok('R58 selecting prints nothing; only the lever commits', grammar === '["","","g"]', grammar); + + // 14b. The lever prints whatever the stylus is resting on, once per pull — the + // operator's two hands are two separate acts. + const spelled = await evl(`(()=>{ + const cell = c => document.querySelector('#card-R58 .dupre-ix-cell[data-c="' + c + '"]'); + const lever = document.querySelector('#card-R58 .dupre-ix-lever'); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + for (const c of ['M','i','g','n','o','n']) { + cell(c).dispatchEvent(new MouseEvent('click', {bubbles:true})); + lever.dispatchEvent(new MouseEvent('click', {bubbles:true})); + } + return document.getElementById('rd-R58').textContent; + })()`); + ok('R58 stylus + lever spells a word', spelled.includes('Mignon'), spelled); + + // 14c. Pulling the lever twice prints the character twice: the selection stays + // put, which is what lets you type 'ss' without re-aiming. + const repeat = await evl(`(()=>{ + const cell = c => document.querySelector('#card-R58 .dupre-ix-cell[data-c="' + c + '"]'); + const lever = document.querySelector('#card-R58 .dupre-ix-lever'); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + cell('s').dispatchEvent(new MouseEvent('click', {bubbles:true})); + lever.dispatchEvent(new MouseEvent('click', {bubbles:true})); + lever.dispatchEvent(new MouseEvent('click', {bubbles:true})); + return document.getElementById('card-R58').dupre.get(); + })()`); + /* Exact, on the buffer. /ss$/ on the readout also matched 'sss', so it passed + even if selecting printed — unable to fail on the one bug this card is about. */ + ok('R58 the lever repeats without re-aiming', repeat === 'ss', JSON.stringify(repeat)); + + // 14d. The plate's whole point, per Craig: it considered the characters a + // keyboard skips. Both cases with no shift key, plus accents, fractions and + // the section mark — that coverage is the idea being preserved from the + // Mignon, where the key ORDER deliberately is not. + // The plate is the Mignon's own, so the set is its set: both full cases, + // the accents and fractions a keyboard skips — and deliberately NO 1 and NO + // 0, because the machine has you type them with lowercase l and capital O. + // That economy is an old value worth keeping, so it's asserted rather than + // tolerated: adding a 1 key would be a silent departure from the reference. + const charset = await evl(`(()=>{ + const have = new Set([...document.querySelectorAll('#card-R58 .dupre-ix-cell')].map(c => c.dataset.c)); + const need = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ', ...'abcdefghijklmnopqrstuvwxyz', + ...'23456789', 'ä','ö','ü','§','½','¼','¾']; + const missing = need.filter(c => !have.has(c)); + if (missing.length) return 'missing: ' + missing.join(' '); + const shouldNotExist = ['1','0'].filter(c => have.has(c)); + if (shouldNotExist.length) return 'plate grew a key the Mignon economises away: ' + shouldNotExist.join(' '); + return 'ok'; + })()`); + ok('R58 carries the Mignon set: both cases, accents, no 1 or 0', charset === 'ok', charset); + + // 14j. The inversion is ZONAL, not per-case. The capitals block is dark discs on + // a light panel; the lowercase block is its photographic negative. But J is + // a capital sitting out on the ring, un-inverted — so a rule of "capitals + // are dark" is wrong, and only position decides. + const zones = await evl(`(()=>{ + const disc = c => { + const g = [...document.querySelectorAll('#card-R58 .dupre-ix-cell')].find(e => e.dataset.c === c); + return g ? g.querySelector('circle').getAttribute('fill') : null; + }; + const caps = disc('P'), lower = disc('p'), ringCap = disc('J'), ringPunct = disc('&'); + if (caps === lower) return 'capitals and lowercase discs are the same: ' + caps; + if (ringCap !== ringPunct) return 'J should wear the ring look like punctuation: J=' + ringCap + ' &=' + ringPunct; + if (ringCap === caps) return 'J is inverted, but it sits on the ring, not in the block'; + return 'ok'; + })()`); + ok('R58 inversion is zonal: J is a capital but not inverted', zones === 'ok', zones); + + // 14e. The layout is DATA, so revising the order is a table edit rather than a + // redraw. Craig has already said the keys will change; a layout welded into + // the drawing is one that never does. + const layoutData = await evl(`(()=>{ + const L = DUPRE.indexPlate && DUPRE.indexPlate.LAYOUT; + if (!L) return 'no LAYOUT'; + if (!Array.isArray(L) || !L.every(Array.isArray)) return 'LAYOUT is not a grid'; + const cells = document.querySelectorAll('#card-R58 .dupre-ix-cell').length; + const declared = L.flat().filter(Boolean).length; + return cells === declared ? 'ok' : 'drawn ' + cells + ' but declared ' + declared; + })()`); + ok('R58 layout is a declared table the plate renders', layoutData === 'ok', layoutData); + + // 14f. Nothing sits on top of the plate. The lever and CLR started life over the + // last column, burying characters and the PRINT legend, and every check + // above stayed green through it — geometry is invisible to behaviour. The + // gutter is sized off the layout table, so a wider plate must not slide the + // controls back onto the characters. + const clear = await evl(`(()=>{ + const box = sel => { const e = document.querySelector('#card-R58 ' + sel); return e ? e.getBBox() : null; }; + const cells = [...document.querySelectorAll('#card-R58 .dupre-ix-cell')].map(c => c.getBBox()); + const right = Math.max(...cells.map(b => b.x + b.width)); + const hits = []; + for (const sel of ['.dupre-ix-lever', '.dupre-ix-clear']) { + const b = box(sel); + if (!b) { hits.push(sel + ' missing'); continue; } + if (b.x < right) hits.push(sel + ' starts at ' + Math.round(b.x) + ', left of the plate edge ' + Math.round(right)); + } + return hits.length ? hits.join('; ') : 'ok'; + })()`); + ok('R58 lever and CLR clear the plate', clear === 'ok', clear); + + // 14g. The gutter stack doesn't collide with itself. The x-only check above + // can't see this: the lever and legend are anchored to the plate's top and + // CLR to the viewBox, so a SHORTER layout table (five rows is a plausible + // edit) used to ride CLR up over the PRINT legend. Growth was always safe; + // shrink was the trap, which is why VH now takes a floor. + const stack = await evl(`(()=>{ + const bb = sel => { const e = document.querySelector('#card-R58 ' + sel); return e ? e.getBBox() : null; }; + const lever = bb('.dupre-ix-lever'), clr = bb('.dupre-ix-clear'); + const legend = [...document.querySelectorAll('#card-R58 text')].find(t => t.textContent === 'PRINT'); + if (!lever || !clr || !legend) return 'missing gutter part'; + const lg = legend.getBBox(); + const overlaps = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && + a.y < b.y + b.height && b.y < a.y + a.height; + if (overlaps(lever, clr)) return 'lever overlaps CLR'; + if (overlaps(lg, clr)) return 'PRINT legend overlaps CLR'; + if (overlaps(lg, lever)) return 'PRINT legend overlaps the lever'; + const vb = document.querySelector('#card-R58 .dupre-ix-pad').viewBox.baseVal; + if (clr.y + clr.height > vb.height) return 'CLR falls outside the viewBox'; + return 'ok'; + })()`); + ok('R58 gutter stack does not collide or overflow', stack === 'ok', stack); + + // 14h. The layout table has no duplicate characters. cells{} and KEYS{} are both + // keyed by character, so a repeat would silently keep only the last: two + // cells would render and both be clickable, but clicking the first would + // jump the stylus across the plate to the second. 14e can't see it — a + // duplicate inflates the drawn count and the declared count equally. + const dupes = await evl(`(()=>{ + const flat = DUPRE.indexPlate.LAYOUT.flat().filter(Boolean); + const seen = new Set(), dup = new Set(); + for (const c of flat) (seen.has(c) ? dup : seen).add(c); + return dup.size ? 'duplicated on the plate: ' + [...dup].join(' ') : 'ok'; + })()`); + ok('R58 layout has no duplicate characters', dupes === 'ok', dupes); + + // 14i. press() is the allowlist for R58 too. R57 has this check; without it, + // press('F1') reaching select() unfiltered would ship green. + const ixPress = await evl(`(()=>{ + const h = document.getElementById('card-R58').dupre; + h.press('CLR'); + ['F1', 'PRINT ', '', 'constructor', 'ZZ'].forEach(k => h.press(k)); + return JSON.stringify([h.get(), h.selected()]); + })()`); + ok('R58 press() filters junk from any caller', ixPress.startsWith('[""'), ixPress); + + // 15. R58's keymap comes OUT of the layout table rather than beside it, so + // relaying the plate can't leave a keybinding pointing at a character the + // plate no longer carries. Enter is the lever. + const ixKeys = await evl(`(()=>{ + const K = DUPRE.indexPlate.KEYS, L = DUPRE.indexPlate.LAYOUT; + if (!K) return 'no KEYS'; + const chars = L.flat().filter(Boolean); + const missing = chars.filter(c => K[c] !== c); + if (missing.length) return 'plate chars not mapped: ' + missing.join(' '); + if (K.Enter !== 'PRINT') return 'Enter -> ' + K.Enter + ', want PRINT'; + const extra = Object.keys(K).filter(k => k !== 'Enter' && !chars.includes(k)); + return extra.length ? 'maps keys not on the plate: ' + extra.join(' ') : 'ok'; + })()`); + ok('R58 keymap is derived from the layout, Enter is the lever', ixKeys === 'ok', ixKeys); + + // 15b. THE grammar again, now through the keyboard. Typing a letter must move + // the stylus and print NOTHING. If a keypress ever prints, the card has + // quietly become R57 with a nicer plate, and the one thing it exists to + // demonstrate is gone. + const ixType = await evl(`(()=>{ + const pad = document.querySelector('#card-R58 .dupre-ix-pad'); + const h = document.getElementById('card-R58').dupre; + const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + pad.focus(); + ['M','i','g'].forEach(send); + const afterTyping = h.get(); + const resting = h.selected(); + send('Enter'); + return JSON.stringify([afterTyping, resting, h.get()]); + })()`); + ok('R58 typing selects, Enter prints', ixType === '["","g","g"]', ixType); + + // 15c. The plate holds both cases, so nothing is uppercased on the way in: + // Shift picks the case because 'a' and 'A' are different cells. R57 has to + // uppercase; this one must not, and that difference is the plate's whole + // argument for having no shift key. + const ixCase = await evl(`(()=>{ + const pad = document.querySelector('#card-R58 .dupre-ix-pad'); + const h = document.getElementById('card-R58').dupre; + const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + pad.focus(); + send('a'); send('Enter'); + send('A'); send('Enter'); + return h.get(); + })()`); + ok('R58 keeps case: a and A are different cells', ixCase === 'aA', ixCase); + + // 15d. Space isn't on the plate, so it isn't ours: it must scroll the page like + // always. (The missing space cell is a known gap — the real Mignon has a + // separate space key.) + const ixSpace = await evl(`(()=>{ + const pad = document.querySelector('#card-R58 .dupre-ix-pad'); + pad.focus(); + const e = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); + pad.dispatchEvent(e); + return e.defaultPrevented ? 'swallowed space' : 'ok'; + })()`); + ok('R58 leaves Space alone (not on the plate)', ixSpace === 'ok', ixSpace); + + // 15e. Unfocused, it hears nothing — the contract's first rule, on the second + // instrument to take keys. + // Asserts nothing CHANGED, rather than expecting a cleared selection: CLR + // is fresh paper, and fresh paper doesn't move the operator's hand, so the + // stylus legitimately stays where the previous check left it. + const ixBlur = await evl(`(()=>{ + const h = document.getElementById('card-R58').dupre; + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + document.activeElement.blur(); + const before = JSON.stringify([h.get(), h.selected()]); + ['Q','Z'].forEach(k => document.body.dispatchEvent( + new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }))); + const after = JSON.stringify([h.get(), h.selected()]); + return before === after ? 'ok' : before + ' -> ' + after; + })()`); + ok('R58 ignores keys when unfocused', ixBlur === 'ok', ixBlur); + + // 12. N20 split-flap — the flip is a mechanism, not a fade: cells step + // through the declared charset one flap at a time, retarget cleanly, and + // animate:false jumps straight to the target. + const flapApi = await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + return { reading: typeof h.reading, chars: typeof h.chars, set: typeof h.set }; + })()`); + ok('N20 handle exposes reading() and chars', flapApi.reading === 'function' && flapApi.chars === 'string', + JSON.stringify(flapApi)); + + // Park the page's 1.5s demo ticker so it can't retarget mid-check + // (IH.flap is the ticker's handle reference; the card element keeps the real one). + await evl(`(() => { window.__realFlap = IH.flap; IH.flap = { next(){}, set(){}, tick(){} }; })()`); + // Let the board settle on a known grid first — the demo ticker left it + // anywhere, and every command cascades (there is deliberately no teleport + // path). setText is the deterministic seam; next() is random by design. + await evl(`document.querySelector('#card-N20').dupre.setText('SYSTEM\\nVOLUME\\nSIGNAL')`); + await sleep(6500); + + // Start a cascade and sample the displayed reading every 15ms until stable. + await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + window.__n20 = { samples: [], anims: 0 }; + h.setText('VOLUME\\nSIGNAL\\nRECORD'); // multi-step distances on several cells + window.__n20.n = 0; + window.__n20.timer = setInterval(() => { + window.__n20.samples.push(h.reading()); + // Every 4th tick (keeps the callback light), count only WAAPI-driven + // animations: a CSSAnimation carries animationName, a WAAPI one doesn't, + // so the old flipdrop class swap can't satisfy this. + if (++window.__n20.n % 4 === 0 && + document.querySelector('#card-N20').getAnimations({ subtree: true }).some(a => !a.animationName)) + window.__n20.anims++; + }, 15); + })()`); + await sleep(6500); + const n20 = await evl(`(() => { + clearInterval(window.__n20.timer); + const h = document.querySelector('#card-N20').dupre; + return { samples: [...new Set(window.__n20.samples)], final: h.reading(), anims: window.__n20.anims, + chars: h.chars }; + })()`); + ok('N20 cascade arrives at the commanded grid', n20.final === 'VOLUME\nSIGNAL\nRECORD', JSON.stringify(n20.final)); + ok('N20 cascade passes through intermediate readings (never teleports)', + n20.samples.filter(s => s !== 'SYSTEM\nVOLUME\nSIGNAL' && s !== 'VOLUME\nSIGNAL\nRECORD').length >= 2, + n20.samples.length + ' distinct states'); + ok('N20 flip runs WAAPI animations (a CSS class swap cannot satisfy this)', n20.anims > 0, 'anim samples ' + n20.anims); + { + // Cell 0 travels S->V; every observed transition must step +1 through the charset. + const seq = [...new Set(n20.samples.map(s => s[0]))]; + let stepped = seq.length >= 3 ? 'ok' : 'too few states: ' + seq.join(''); + for (let i = 1; i < seq.length && stepped === 'ok'; i++) { + const a = n20.chars.indexOf(seq[i - 1]), b = n20.chars.indexOf(seq[i]); + if (b !== (a + 1) % n20.chars.length) stepped = seq[i - 1] + '->' + seq[i] + ' is not one flap'; + } + ok('N20 cell 0 steps one flap at a time in charset order', stepped === 'ok', stepped); + } + + // Retarget mid-cascade: aim at word 2, then word 3 while still spinning; must land on 3. + await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + window.__n20r = { samples: [] }; + window.__n20r.timer = setInterval(() => window.__n20r.samples.push(h.reading()), 15); + h.setText('SIGNAL\\nRECORD\\nONLINE'); setTimeout(() => h.setText('RECORD\\nONLINE\\nSTEREO'), 180); + })()`); + await sleep(7000); + const n20b = await evl(`(() => { + clearInterval(window.__n20r.timer); + const h = document.querySelector('#card-N20').dupre; + return { final: h.reading().split('\\n')[0], target: 'RECORD', + sawSync: window.__n20r.samples.some(s => s.split('\\n')[0] === 'SIGNAL'), + anims: document.querySelector('#card-N20').getAnimations({ subtree: true }).length }; + })()`); + ok('N20 retarget mid-cascade lands on the new target', n20b.final === n20b.target, n20b.final); + ok('N20 retarget re-aims rather than queueing (SIGNAL never fully forms)', !n20b.sawSync, n20b.sawSync ? 'saw SIGNAL' : 'ok'); + ok('N20 no cascade left running after arrival', n20b.anims === 0, 'live anims ' + n20b.anims); + + // The demo scramble: next() sends each row to a random pool word — every + // row changes, rows stay mutually distinct, all drawn from the pool. + const n20s = await evl(`(() => { + const el = document.createElement('div'); document.body.appendChild(el); + const pool = ['SYSTEM','VOLUME','SIGNAL','RECORD','ONLINE','STEREO','FILTER','OUTPUT']; + const h = DUPRE.splitFlap(el, { rows: 3, cells: 6, words: pool, animate: false }); + const grids = [h.reading().split('\\n')]; + h.next(); grids.push(h.reading().split('\\n')); + h.next(); grids.push(h.reading().split('\\n')); + el.remove(); + for (const g of grids) { + if (new Set(g).size !== 3) return 'rows not distinct: ' + g.join(','); + if (!g.every(w => pool.includes(w.trim()))) return 'word outside pool: ' + g.join(','); + } + for (let i = 1; i < grids.length; i++) + for (let r = 0; r < 3; r++) + if (grids[i][r] === grids[i - 1][r]) return 'row ' + r + ' kept its word: ' + grids[i][r]; + return 'ok'; + })()`); + ok('N20 scramble sends every row to a different pool word', n20s === 'ok', n20s); + + // animate:false (the reduced-motion path) jumps straight to the target. + const n20c = await evl(`(() => { + const el = document.createElement('div'); document.body.appendChild(el); + const settles = []; + const h = DUPRE.splitFlap(el, { animate: false, onSettle: r => settles.push(r) }); + h.set(1); + const r = h.reading(); el.remove(); + return { r, settles }; + })()`); + ok('N20 animate:false jumps straight to the target', n20c.r.trim() === 'LINK', n20c.r); + ok('N20 settle announces once per command', n20c.settles.length === 1 && n20c.settles[0].trim() === 'LINK', + JSON.stringify(n20c.settles)); + + // The card is a three-row six-letter board; the demo draws random pool words. + const n20d = await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + return { cells: document.querySelectorAll('#card-N20 .flapd').length, + lines: document.querySelectorAll('#card-N20 .flapline').length, + readLines: h.reading().split('\\n').length }; + })()`); + ok('N20 board is three rows of six cells', n20d.cells === 18 && n20d.lines === 3 && n20d.readLines === 3, + JSON.stringify(n20d)); + + // Skin axis: dark (cream on black) and the inverse light (black on cream), + // switched by the chip rig via setStyle, computed ink actually inverting. + const n20e = await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + if (typeof h.setStyle !== 'function' || !DUPRE.splitFlap.STYLES || !DUPRE.splitFlap.STYLES.skin) return 'no skin axis'; + const skins = Object.keys(DUPRE.splitFlap.STYLES.skin); + if (skins[0] !== 'paper') return 'paper must list first, got: ' + skins.join(','); + if (!['dark','white','light','paper'].every(k => skins.includes(k))) return 'skins are: ' + skins.join(','); + const ink = () => getComputedStyle(document.querySelector('#card-N20 .fh')).color; + const lum = c => { const m = c.match(/[0-9]+/g).map(Number); return m[0] + m[1] + m[2]; }; + const paperDef = ink(); // the card's default skin is the white card, dark letters + if (lum(paperDef) >= 300) return 'default ink is not dark-on-paper: ' + paperDef; + if (!document.querySelector('#card-N20 .flap').classList.contains('flap-paper')) return 'default skin class is not paper'; + h.setStyle('skin', 'white'); + const white = ink(); + if (white !== 'rgb(255, 255, 255)') return 'white skin ink is ' + white; + h.setStyle('skin', 'dark'); + const cream = ink(); + h.setStyle('skin', 'light'); + const light = ink(); + const gotClass = document.querySelector('#card-N20 .flap').classList.contains('flap-light'); + h.setStyle('skin', 'paper'); + const back = ink(); + if (!gotClass) return 'light skin class missing'; + if (!(lum(cream) > 400 && lum(cream) < 760 && lum(light) < 300)) return 'inks wrong: ' + cream + ' / ' + light; + if (back !== paperDef) return 'did not restore the paper default: ' + back; + return 'ok'; + })()`); + ok('N20 skins: paper default first, white ink, cream, ivory — all switch and restore', n20e === 'ok', n20e); + + const n20fnt = await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + if (!DUPRE.splitFlap.STYLES.font) return 'no font axis'; + const fam = () => getComputedStyle(document.querySelector('#card-N20 .fh')).fontFamily; + const helv = fam(); // the card's default face is Helvetica + if (!/helvetica/i.test(helv)) return 'default face is not helvetica: ' + helv; + h.setStyle('font', 'mono'); + const mono = fam(); + h.setStyle('font', 'helv'); + const back = fam(); + if (!/berkeley|mono/i.test(mono) || /helvetica/i.test(mono)) return 'mono did not apply: ' + mono; + if (back !== helv) return 'did not restore the helv default: ' + back; + return 'ok'; + })()`); + ok('N20 face: Helvetica default, Berkeley Mono one click away', n20fnt === 'ok', n20fnt); + const n20f = await evl(`(() => { + const chips = [...document.querySelectorAll('#card-N20 .famchips .fc, #card-N20 .famchips [title]')].map(c => c.title || c.textContent); + return chips.length ? chips.join(',') : 'no chips'; + })()`); + ok('N20 card offers skin and font chips', + ['dark','white','light','paper','mono','helv'].every(t => n20f.includes(t)), n20f); + const n20h = await evl(`(() => { + const rd = document.querySelector('#card-N20 .wrd'); + const slider = document.querySelector('#card-N20 .famchips input[type=range]'); + if (rd && getComputedStyle(rd).display !== 'none') return 'readout still visible'; + if (!slider) return 'slider not in the chips row'; + return 'ok'; + })()`); + ok('N20 board is the only display: readout hidden, slider rides with the chips', n20h === 'ok', n20h); + + // Flap speed: the handle takes setFlapMs and the card's slider drives it. + const n20v = await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + if (typeof h.setFlapMs !== 'function' || typeof h.flapMs !== 'function') return 'no speed api'; + const before = h.flapMs(); + if (before !== 100) return 'default flap rate is ' + before + ', want 100'; + h.setFlapMs(120); + if (h.flapMs() !== 120) return 'setFlapMs did not take: ' + h.flapMs(); + const slider = document.querySelector('#card-N20 input[type=range]'); + if (!slider) { h.setFlapMs(before); return 'no slider on the card'; } + slider.value = '90'; slider.dispatchEvent(new Event('input', { bubbles: true })); + const viaSlider = h.flapMs(); + h.setFlapMs(before); slider.value = String(before); + return viaSlider === 90 ? 'ok' : 'slider did not drive setFlapMs: ' + viaSlider; + })()`); + ok('N20 flap speed: setFlapMs api + card slider drive the rate', n20v === 'ok', n20v); + + // Unpark the ticker, restore the demo state, and measure the dwell: the + // board must settle, hold the reading ~2s, and only then take the next update. + await evl(`(() => { + IH.flap = window.__realFlap; + const h = document.querySelector('#card-N20').dupre; + window.__n20dwell = { settleAt: 0, changeAt: 0, settled: '' }; + h.onSettle(r => { if (!window.__n20dwell.settleAt) { window.__n20dwell.settleAt = performance.now(); window.__n20dwell.settled = r; } }); + window.__n20dwell.timer = setInterval(() => { + const d = window.__n20dwell; + if (d.settleAt && !d.changeAt && h.reading() !== d.settled) { d.changeAt = performance.now(); clearInterval(d.timer); } + }, 50); + h.set(0); + })()`); + await sleep(10500); + const dwell = await evl(`(() => { + const d = window.__n20dwell; clearInterval(d.timer); + return { settleAt: d.settleAt, delta: d.changeAt ? d.changeAt - d.settleAt : -1 }; + })()`); + ok('N20 settles, then dwells ~2s before the next update', + dwell.settleAt > 0 && dwell.delta >= 1800 && dwell.delta <= 4000, 'delta ' + Math.round(dwell.delta)); + + // Skin chips carry visible swatch dots (the rig paints STYLES[..].dot; + // a string-valued STYLES entry leaves both chips transparent and identical). + const n20g = await evl(`(() => { + const chips = [...document.querySelectorAll('#card-N20 .famchips .fc')]; + if (chips.length < 6) return 'chips missing: ' + chips.length; + // each chip is a miniature flap cell: board colour + a letter in the ink + const pair = c => getComputedStyle(c).backgroundColor + '/' + getComputedStyle(c, '::after').color; + const skins = chips.slice(0, 4).map(pair); + if (new Set(skins).size !== 4) return 'skin previews not distinct: ' + skins.join(' '); + if (chips.slice(0, 4).some(c => getComputedStyle(c, '::after').content === 'none')) return 'skin chip has no ink letter'; + const fams = chips.slice(4).map(c => getComputedStyle(c, '::after').fontFamily); + if (!fams.some(f => /helvetica/i.test(f)) || fams[0] === fams[1]) return 'font previews wrong: ' + fams.join(' vs '); + const size = parseFloat(getComputedStyle(chips[0]).width); + return size >= 15 ? 'ok' : 'chips too small to read: ' + size; + })()`); + ok('N20 chips are miniature flap cells: board colour, ink letter, real face', n20g === 'ok', n20g); + + // N24 jewels — the handle reads and drives lamp state. Waybar-bound: a + // consumer must be able to set a jewel without synthesizing clicks. + const jw = await evl(`(() => { + const h = document.getElementById('card-N24').dupre; + if (!h.get || !h.set) return 'no get/set on handle'; + const init = h.get().join(','); + if (init !== '0,1,2,-1') return 'initial ' + init; + h.set(3, 1); + if (h.get()[3] !== 1) return 'set(3,1) not reflected: ' + h.get().join(','); + const el = document.querySelectorAll('#card-N24 .dupre-jewel')[3]; + if (!el) return 'no .dupre-jewel elements'; + if (el.classList.contains('dupre-dim')) return 'jewel 3 still dark after set(3,1)'; + h.set(3, -1); + if (!el.classList.contains('dupre-dim')) return 'set(3,-1) did not dark'; + if (h.get()[3] !== -1) return 'get after dark: ' + h.get().join(','); + return 'ok'; + })()`); + ok('N24 jewels handle exposes get/set', jw === 'ok', jw); + + // Under prefers-reduced-motion the board paints once and RESTS — the dwell + // chain must stay gated off, or the panel self-advances every 2s forever. + await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); + await send('Page.reload'); + await sleep(2500); + const rm1 = await evl(`document.querySelector('#card-N20').dupre.reading()`); + await sleep(5200); + const rm2 = await evl(`(() => ({ r: document.querySelector('#card-N20').dupre.reading(), anims: document.getAnimations().length }))()`); + ok('N20 rests under reduced motion (no self-advancing dwell chain)', + rm1 === rm2.r && rm2.anims === 0, JSON.stringify({ before: rm1, after: rm2.r, anims: rm2.anims })); + + // Every card's ID badge is its own anchor: click it and the URL carries + // the card's deep link. + const anchors = await evl(`(() => { + const bad = [...document.querySelectorAll('.card .wname .no')] + .filter(n => n.tagName !== 'A' || n.getAttribute('href') !== '#' + n.closest('.card').id) + .map(n => n.textContent); + const count = document.querySelectorAll('.card .wname a.no').length; + if (bad.length) return 'not anchors: ' + bad.slice(0, 5).join(' '); + return count === document.querySelectorAll('.card').length ? 'ok' : 'anchor count ' + count; + })()`); + ok('every card ID badge links to its own anchor', anchors === 'ok', anchors); + + // Batch-3 set() hardening: out-of-range input cannot wedge an instrument. + // presetBank clamps; dualKnob clamps both spindles. Built on detached hosts + // so the gallery's own cards stay untouched. + const clamps = await evl(`(() => { + const pb = DUPRE.presetBank(document.createElement('div')); + pb.set(99); const hi = pb.get(); + pb.set(-5); const lo = pb.get(); + const dk = DUPRE.dualKnob(document.createElement('div')); + dk.set(500, -500); const [o, i] = dk.get(); + return JSON.stringify({ hi, lo, o, i }); + })()`); + ok('presetBank and dualKnob set() clamp out-of-range input', + clamps === JSON.stringify({ hi: 3, lo: 0, o: 100, i: 0 }), clamps); + + // Batch-4 set() hardening, same detached-host shape: vernierDial and + // flutedKnob clamp 0-100; filterBank clamps the band index (an out-of-range + // band lands on the last, it doesn't throw); multiBandDial clamps the band + // to its rings and keeps the current band when set() omits it. + const clamps4 = await evl(`(() => { + const vd = DUPRE.vernierDial(document.createElement('div')); + vd.set(500); const vHi = vd.get(); + vd.set(-5); const vLo = vd.get(); + const fk = DUPRE.flutedKnob(document.createElement('div')); + fk.set(500); const fHi = fk.get(); + const fb = DUPRE.filterBank(document.createElement('div')); + fb.set(99, 30); const fbLast = fb.get()[11]; + const mb = DUPRE.multiBandDial(document.createElement('div')); + mb.set(50, 99); const [, bHi] = mb.get(); + mb.set(70); const [v2, bKeep] = mb.get(); + return JSON.stringify({ vHi, vLo, fHi, fbLast, bHi, v2, bKeep }); + })()`); + ok('batch-4 set() clamps: vernier/fluted 0-100, filterBank + multiBand band index', + clamps4 === JSON.stringify({ vHi: 100, vLo: 0, fHi: 100, fbLast: 30, bHi: 3, v2: 70, bKeep: 3 }), clamps4); + + // The batch-4 rename holds end to end: a detached dipBank round-trips its + // word through the dupre- prefixed switch classes, and jogWheel's cached + // inner disc actually rotates on set. + const b4dom = await evl(`(() => { + const db = DUPRE.dipBank(document.createElement('div')); + db.set('101'); + const jw = DUPRE.jogWheel(document.createElement('div')); + jw.set(10); + const inner = jw.el.querySelector('.dupre-inner'); + return JSON.stringify({ word: db.get(), sw: db.el.querySelectorAll('.dupre-dipsw').length, + rot: inner ? inner.style.transform : 'missing' }); + })()`); + ok('dipBank word round-trips and jogWheel rotates via dupre- classes', + b4dom === JSON.stringify({ word: '101000', sw: 6, rot: 'rotate(40deg)' }), b4dom); + + // Batch-5 set() hardening, same detached-host shape: ledRow, pillSlide and + // discSwitch clamp their index (discSwitch used to throw); waveRegion keeps + // E at least 4 above S however set() is called, not only on the drag path. + // ledRow's default index is 5 only for the default program list — a caller's + // own items start at 0 (the consoleKeys default-active rule). + const clamps5 = await evl(`(() => { + const lr = DUPRE.ledRow(document.createElement('div')); + lr.set(99); const lrHi = lr.get(); + lr.set(-3); const lrLo = lr.get(); + const lrDef = DUPRE.ledRow(document.createElement('div')).get(); + const lrOwn = DUPRE.ledRow(document.createElement('div'), { items: ['A', 'B', 'C'] }).get(); + const ps = DUPRE.pillSlide(document.createElement('div')); + ps.set(9); const psHi = ps.get(); + ps.set(-1); const psLo = ps.get(); + let dsHi; + try { const ds = DUPRE.discSwitch(document.createElement('div')); ds.set(9); dsHi = ds.get(); } + catch (e) { dsHi = 'threw'; } + const wr = DUPRE.waveRegion(document.createElement('div')); + wr.set(80, 20); const { s: wrS, e: wrE } = wr.get(); + wr.set(-10, 200); const { s: wrS2, e: wrE2 } = wr.get(); + return JSON.stringify({ lrHi, lrLo, lrDef, lrOwn, psHi, psLo, dsHi, wrS, wrE, wrS2, wrE2 }); + })()`); + ok('batch-5 set() clamps: ledRow/pillSlide/discSwitch index, waveRegion S/E order', + clamps5 === JSON.stringify({ lrHi: 7, lrLo: 0, lrDef: 5, lrOwn: 0, psHi: 2, psLo: 0, dsHi: 2, wrS: 80, wrE: 84, wrS2: 0, wrE2: 100 }), clamps5); + + // entryKeypad buffer round-trip on a detached host: digits accumulate, the + // buffer caps at 6, keys outside the contract's set are ignored, ✗ clears, + // ✓ commits and clears. + const b5dom = await evl(`(() => { + const kp = DUPRE.entryKeypad(document.createElement('div')); + ['1', '2', '3'].forEach(k => kp.press(k)); const buf3 = kp.get(); + kp.press('X'); const stray = kp.get(); + ['4', '5', '6', '7'].forEach(k => kp.press(k)); const cap = kp.get(); + kp.press('✗'); const clr = kp.get(); + kp.press('1'); kp.press('✓'); const commit = kp.get(); + return JSON.stringify({ buf3, stray, cap, clr, commit }); + })()`); + ok('entryKeypad buffers digits only, caps at 6, clears and commits', + b5dom === JSON.stringify({ buf3: '123', stray: '123', cap: '123456', clr: '', commit: '' }), b5dom); + + // batch-6 domain gates on detached hosts: fourWayToggle falls back to C on a + // bad initial position and ignores invalid quadrants in set(), camTimer + // clamps set() to the ring (12 steps → 0..11, fractions floor), pinMatrix + // drops initial pins that name no intersection. + const clamps6 = await evl(`(() => { + const fw = DUPRE.fourWayToggle(document.createElement('div'), { position: 'Z' }); + const fwDef = fw.get(); fw.set('B'); const fwB = fw.get(); fw.set('Q'); const fwQ = fw.get(); + fw.set('toString'); const fwProto = fw.get(); + const ct = DUPRE.camTimer(document.createElement('div'), { position: 99 }); + const ctHi = ct.get(); ct.set(-5); const ctLo = ct.get(); ct.set(2.7); const ctInt = ct.get(); + const pm = DUPRE.pinMatrix(document.createElement('div'), { pins: ['OSC1>VCF', 'BAD>KEY', 'LFO>NOPE', 'OSC2>VCA>X'] }); + return JSON.stringify({ fwDef, fwB, fwQ, fwProto, ctHi, ctLo, ctInt, pmN: pm.get().length }); + })()`); + ok('batch-6 domain gates: fourWayToggle quadrant, camTimer step clamp, pinMatrix pin filter', + clamps6 === JSON.stringify({ fwDef: 'C', fwB: 'B', fwQ: 'B', fwProto: 'B', ctHi: 11, ctLo: 0, ctInt: 2, pmN: 1 }), clamps6); + + // dsky verb/noun grammar through the prefixed DOM (catches rename stragglers): + // VERB arms exactly one hot window, V16 N36 commits PROG 16 and disarms, + // V35 lights all six status lamps. + const b6dom = await evl(`(() => { + const d = DUPRE.dsky(document.createElement('div')); + const K = {}; + d.el.querySelectorAll('.dupre-dsky-pad .dupre-key').forEach(b => K[b.textContent] = b); + K.VERB.click(); + const hot1 = d.el.querySelectorAll('.dupre-dsky-win.dupre-hot').length; + K['1'].click(); K['6'].click(); K.NOUN.click(); K['3'].click(); K['6'].click(); K.ENTR.click(); + const prog = d.get().prog; + const hot0 = d.el.querySelectorAll('.dupre-dsky-win.dupre-hot').length; + K.VERB.click(); K['3'].click(); K['5'].click(); K.ENTR.click(); + const lit = d.el.querySelectorAll('.dupre-dsky-sl.dupre-on').length; + return JSON.stringify({ hot1, prog, hot0, lit }); + })()`); + ok('dsky grammar drives prefixed DOM: hot window, V16 N36 sets PROG, V35 lights all lamps', + b6dom === JSON.stringify({ hot1: 1, prog: '16', hot0: 0, lit: 6 }), b6dom); + + // batch-7 domain gates on detached hosts: decadeBox coerces and clamps its + // digits, twoHandSafety ignores sides outside L/R, the meters clamp their + // set()/push() domains, sparkline floors its history at two points, scope + // clears the trace below two samples, eqBars reads missing bands as 0. + const clamps7 = await evl(`(() => { + const db = DUPRE.decadeBox(document.createElement('div'), { digits: [12, -3, undefined, 7] }); + const dbTot = db.get(); + const thsLog = []; + const ths = DUPRE.twoHandSafety(document.createElement('div'), { onChange: st => thsLog.push(st) }); + ths.press('X'); ths.press('L'); ths.press('R'); + const ms = DUPRE.miniSig(document.createElement('div')); + ms.set(2); const msHi = ms.get(); ms.set(-1); const msLo = ms.get(); + const fb = DUPRE.fuelBar(document.createElement('div')); + fb.set(150); const fbHi = fb.get(); + const rr = DUPRE.radialRing(document.createElement('div')); + rr.set(150); const rrHi = rr.get(); + const sp = DUPRE.sparkline(document.createElement('div'), { samples: 1 }); + sp.push(5); const spHi = sp.get(); + const spPts = sp.el.querySelector('polyline').getAttribute('points'); + const ws = DUPRE.waveStrip(document.createElement('div')); + ws.set([0, 5, -5], 0.5); + const wsD = ws.el.querySelector('path').getAttribute('d'); + const sc = DUPRE.scope(document.createElement('div')); + sc.set([0.5], 1); + const scEmpty = sc.el.querySelector('polyline').getAttribute('points'); + sc.set([5, -5], 1); + const scPts = sc.el.querySelector('polyline').getAttribute('points'); + let eqTxt; const eq = DUPRE.eqBars(document.createElement('div'), { bands: 3, cells: 4, onChange: (v, t) => eqTxt = t }); + eq.set([2, -1]); + const eqB0 = eq.el.children[0].querySelectorAll('.dupre-on,.dupre-hot,.dupre-clip').length; + const eqRest = eq.el.children[1].querySelectorAll('.dupre-on,.dupre-hot,.dupre-clip').length + + eq.el.children[2].querySelectorAll('.dupre-on,.dupre-hot,.dupre-clip').length; + return JSON.stringify({ dbTot, ths: thsLog.join('|'), msHi, msLo, fbHi, rrHi, spHi, + spOk: !spPts.includes('NaN'), wsOk: !wsD.includes('NaN') && wsD.includes(' 33.0') && wsD.includes(' 5.0'), + scEmpty, scPts, eqTxt, eqB0, eqRest }); + })()`); + ok('batch-7 domain gates: decadeBox digits, twoHandSafety side, meter clamps, trace guards', + clamps7 === JSON.stringify({ dbTot: 9007, ths: 'ready|armed|running', msHi: 1, msLo: 0, fbHi: 100, + rrHi: 100, spHi: 1, spOk: true, wsOk: true, scEmpty: '', scPts: '0.0,61.0 176.0,17.0', + eqTxt: 'peak 100%', eqB0: 4, eqRest: 0 }), clamps7); + + // voiceLoop grammar through the prefixed DOM (catches rename stragglers): + // the default set opens FD/GNC/A-G engaged with GNC talking, click cycles + // idle → monitored → talking → idle with talk exclusive, and a caller's own + // loop set starts all-idle (the demo default is the stock set's alone). + const b7dom = await evl(`(() => { + const vl = DUPRE.voiceLoop(document.createElement('div')); + const ks = vl.el.querySelectorAll('.dupre-vk'); + const nKeys = ks.length; + const mon0 = vl.el.querySelectorAll('.dupre-mon').length; + const tlk0 = vl.el.querySelectorAll('.dupre-tlk').length; + ks[2].click(); + const mon1 = vl.el.querySelectorAll('.dupre-mon').length; + ks[0].click(); + const tlk1 = vl.el.querySelectorAll('.dupre-tlk').length; + const fdTalks = ks[0].classList.contains('dupre-tlk') && !ks[1].classList.contains('dupre-tlk'); + ks[0].click(); + const fdIdle = vl.get()[0] === '0' && !ks[0].classList.contains('dupre-mon'); + const own = DUPRE.voiceLoop(document.createElement('div'), { loops: ['A', 'B', 'C'] }); + const ownIdle = own.get().join('') === '000' && own.el.querySelectorAll('.dupre-mon,.dupre-tlk').length === 0; + return JSON.stringify({ nKeys, mon0, tlk0, mon1, tlk1, fdTalks, fdIdle, ownIdle }); + })()`); + ok('voiceLoop cycles through prefixed DOM: demo default, exclusive talk, own set idle', + b7dom === JSON.stringify({ nKeys: 8, mon0: 3, tlk0: 1, mon1: 4, tlk1: 1, fdTalks: true, fdIdle: true, ownIdle: true }), b7dom); + + // batch-8 domain gates on detached hosts: the needle meters clamp set() to + // their documented 0-100, stripChart floors its history at two samples and + // survives a non-array set(), battCells floors its cell count at one. + const clamps8 = await evl(`(() => { + const xn = DUPRE.crossNeedle(document.createElement('div')); + xn.set(150); const xnHi = xn.get(); xn.set(-10); const xnLo = xn.get(); + const th = DUPRE.thermometer(document.createElement('div')); + th.set(-5); const thLo = th.get(); + const bd = DUPRE.bourdon(document.createElement('div')); + bd.set(200); const bdHi = bd.get(); + const st = DUPRE.stripChart(document.createElement('div'), { samples: 1 }); + st.set(null, 0.5); + const stPts = st.el.querySelector('polyline').getAttribute('points'); + let cmTxt; const cm = DUPRE.corrMeter(document.createElement('div'), { onChange: (v, t) => cmTxt = t }); + cm.set(200); const cmHi = cm.get(); + const bc = DUPRE.battCells(document.createElement('div'), { cells: -3 }); + const bcCells = bc.el.querySelectorAll('.dupre-cell').length; + bc.set(-10); const bcLo = bc.get(); + const vu = DUPRE.mcVu(document.createElement('div'), { value: 5 }); + const vuInit = vu.get(); + const vuNdl = vu.el.querySelector('line[stroke-width="1.6"]').getAttribute('transform'); + return JSON.stringify({ xnHi, xnLo, thLo, bdHi, stOk: !stPts.includes('NaN'), cmHi, cmTxt, bcCells, bcLo, vuInit, vuNdl }); + })()`); + ok('batch-8 domain gates: needle-meter clamps, stripChart array/floor guards, battCells cell floor, mcVu init', + clamps8 === JSON.stringify({ xnHi: 100, xnLo: 0, thLo: 0, bdHi: 100, stOk: true, + cmHi: 100, cmTxt: '+1.00', bcCells: 1, bcLo: 0, vuInit: 1.02, vuNdl: 'rotate(43,75,112)' }), clamps8); + + // batch-8 state through the prefixed DOM (catches rename stragglers): the + // battery lights dupre-on cells and tints dupre-warn under the threshold, + // the crossed needles and mercury column track set() through their renamed + // internals. + const b8dom = await evl(`(() => { + const bc = DUPRE.battCells(document.createElement('div')); + const nCells = bc.el.querySelectorAll('.dupre-cell').length; + const on62 = bc.el.querySelectorAll('.dupre-cell.dupre-on').length; + const warn62 = bc.el.querySelectorAll('.dupre-warn').length; + bc.set(20); + const on20 = bc.el.querySelectorAll('.dupre-cell.dupre-on').length; + const warn20 = bc.el.querySelectorAll('.dupre-cell.dupre-warn.dupre-on').length; + const xn = DUPRE.crossNeedle(document.createElement('div')); + xn.set(0); + const fwdT = xn.el.querySelector('.dupre-fwd').style.transform; + const rflT = xn.el.querySelector('.dupre-rfl').style.transform; + const th = DUPRE.thermometer(document.createElement('div')); + th.set(75); + const thH = th.el.querySelector('.dupre-thermo-fill').style.height; + const bd = DUPRE.bourdon(document.createElement('div')); + bd.set(50); + const bdT = bd.el.querySelector('.dupre-bourdon-ndl').style.transform; + const cm = DUPRE.corrMeter(document.createElement('div')); + cm.set(50); + const cmT = cm.el.querySelector('.dupre-corr-ndl').style.transform; + const stc = DUPRE.stripChart(document.createElement('div')); + stc.push(1); + const penT = stc.el.querySelector('.dupre-pen').style.top; + return JSON.stringify({ nCells, on62, warn62, on20, warn20, fwdT, rflT, thH, bdT, cmT, penT }); + })()`); + ok('batch-8 renamed DOM tracks state: battery on/warn cells, needles and pen move', + b8dom === JSON.stringify({ nCells: 8, on62: 5, warn62: 0, on20: 2, warn20: 2, + fwdT: 'rotate(-42deg)', rflT: 'rotate(42deg)', thH: '75%', bdT: 'rotate(0deg)', + cmT: 'rotate(0deg)', penT: '3px' }), b8dom); + + // batch-9 domain gates on detached hosts: the flight instruments clamp (or + // normalize) both set() and their build opts, badges coerces junk variants, + // the readout wraps its clock. + const clamps9 = await evl(`(() => { + const ai = DUPRE.attitudeIndicator(document.createElement('div'), { bank: -999, pitch: 5 }); + const aiB = ai.get().bank; + ai.set(999, 999); const aiHi = ai.get(); + const hb = DUPRE.headingBug(document.createElement('div'), { value: 450 }); + const hbB = hb.get().cmd; + const hbNdl = hb.el.querySelector('g polygon').parentNode.getAttribute('transform'); + hb.set(-30); const hbSet = hb.get().cmd; + const vt = DUPRE.verticalTape(document.createElement('div'), { value: 99 }); + const vtB = vt.get(); vt.set(0); const vtLo = vt.get(); + const vtT = vt.el.querySelector('g g').getAttribute('transform'); + const tn = DUPRE.twinNeedle(document.createElement('div'), { fuel: 99, oil: -5 }); + const tnB = tn.get(); + let cmTxt; const cm = DUPRE.comfortMeter(document.createElement('div'), + { temp: 999, humidity: -40, onChange: (v, t) => cmTxt = t }); + const cmB = cm.get(); + const bg = DUPRE.badges(document.createElement('div'), { items: [['X', 7], ['Y', 'junk']] }); + const bgVars = bg.get(); + const tr = DUPRE.tabularReadout(document.createElement('div'), { secs: -10 }); + const trB = tr.get(); + return JSON.stringify({ aiB, aiHi, hbB, hbNdl, hbSet, vtB, vtLo, vtT, tnB, cmB, cmTxt, bgVars, trB }); + })()`); + ok('batch-9 domain gates: flight-instrument clamps at build and set, badges variant coercion, readout wrap', + clamps9 === JSON.stringify({ aiB: -60, aiHi: { bank: 60, pitch: 20 }, hbB: 90, + hbNdl: 'rotate(90.0,65,65)', hbSet: 330, vtB: 35, vtLo: 5, vtT: 'translate(0,92.5)', + tnB: [4, 0], cmB: [100, 0], cmTxt: '100F · 0% RH · TOO WARM', bgVars: [1, 0], trB: 3590 }), clamps9); + + // batch-9 state through the renamed DOM (catches rename stragglers): badges + // swap variant classes, the readout counts down through dupre-readout and + // carries a styled dupre-unit (the old .readout .u rule was dead — .u is a + // sibling, so the caption rendered unstyled), the engraved label counts + // through dupre-cnt, and the SVG flight instruments track set(). + const b9dom = await evl(`(() => { + const bg = DUPRE.badges(document.createElement('div')); + const nBadge = bg.el.querySelectorAll('.dupre-badge').length; + const red0 = bg.el.querySelectorAll('.dupre-badge.dupre-red').length; + const ghost0 = bg.el.querySelectorAll('.dupre-badge.dupre-ghost').length; + bg.set(0, 1); + const red1 = bg.el.querySelectorAll('.dupre-badge.dupre-red').length; + const trHost = document.createElement('div'); document.body.appendChild(trHost); + const tr = DUPRE.tabularReadout(trHost); + const trTxt = tr.el.querySelector('.dupre-readout').textContent; + const unit = tr.el.querySelector('.dupre-unit'); + const unitTxt = unit.textContent; + const unitDisp = getComputedStyle(unit).display; + tr.tick(); const trTick = tr.el.querySelector('.dupre-readout').textContent; + tr.el.querySelector('.dupre-readout').click(); tr.tick(); + const trPaused = tr.el.querySelector('.dupre-readout').textContent; + trHost.remove(); + const el = DUPRE.engravedLabel(document.createElement('div')); + const cnt0 = el.el.querySelector('.dupre-cnt').textContent; + el.set(7); const cnt7 = el.el.querySelector('.dupre-cnt').textContent; + el.el.click(); const cnt8 = el.el.querySelector('.dupre-cnt').textContent; + const tn = DUPRE.twinNeedle(document.createElement('div')); + tn.set(0, 4); + const tnF = tn.el.querySelector('polygon[fill="#e0523a"]').parentNode.getAttribute('transform'); + const tnO = tn.el.querySelector('polygon[fill="#bfc4d0"]').parentNode.getAttribute('transform'); + const cm = DUPRE.comfortMeter(document.createElement('div')); + cm.set(70, 80); + const humid = [...cm.el.querySelectorAll('text')].find(t => t.textContent === 'TOO HUMID').getAttribute('fill'); + const ai = DUPRE.attitudeIndicator(document.createElement('div')); + ai.set(30, -10); + const aiT = ai.el.querySelector('g g').getAttribute('transform'); + const rc = DUPRE.roundCrt(document.createElement('div')); + const p1 = rc.el.querySelector('polyline[stroke="#f4fcf4"]').getAttribute('points'); + rc.tick(); + const p2 = rc.el.querySelector('polyline[stroke="#f4fcf4"]').getAttribute('points'); + const cr = DUPRE.chartRecorder(document.createElement('div')); + cr.reset(); + const crH = cr.get(); + const crD = cr.el.querySelector('path[stroke="#8f2416"]').getAttribute('d'); + return JSON.stringify({ nBadge, red0, ghost0, red1, trTxt, unitTxt, unitDisp, trTick, trPaused, + cnt0, cnt7, cnt8, tnF, tnO, humid, aiT, crtMoves: p1 !== p2 && !p2.includes('NaN'), crH, crD }); + })()`); + ok('batch-9 renamed DOM tracks state: badge variants, readout clock + styled unit, engraved count, instruments follow set()', + b9dom === JSON.stringify({ nBadge: 3, red0: 1, ghost0: 1, red1: 2, trTxt: '24:10', + unitTxt: 'timer', unitDisp: 'block', trTick: '24:09', trPaused: '24:09', + cnt0: '· 3', cnt7: '· 7', cnt8: '· 8', tnF: 'rotate(-170.0,60,62)', tnO: 'rotate(10.0,60,62)', + humid: '#c23a28', aiT: 'rotate(-30.0,65,65) translate(0,-16.0)', + crtMoves: true, crH: 0, crD: '' }), b9dom); + + // batch-10 domain gate: the annunciator documents a 0..2 state per cell, so + // an out-of-range initial state or set() must clamp rather than paint an + // undefined class (the mcVu build-enforcement precedent). Without the clamp, + // CLS[9] is undefined and get() reads back 0 — this fails RED on that. + const clamps10 = await evl(`(() => { + const an = DUPRE.annunciator(document.createElement('div'), { cells: [['A', 9], ['B', -3], ['C', 1]] }); + const anB = an.get(); + an.set(0, 9); an.set(1, -5); + const anSet = an.get(); + return JSON.stringify({ anB, anSet }); + })()`); + ok('batch-10 domain gate: annunciator state clamps to 0..2 at build and set', + clamps10 === JSON.stringify({ anB: [2, 0, 1], anSet: [2, 0, 1] }), clamps10); + + // batch-10 state through the renamed DOM (catches rename stragglers): the six + // extracted DOM builders each expose their handle and paint the dupre- classes + // — a JS/CSS name mismatch would render unstyled without throwing, so assert + // the class names directly. patchBay lights hot jacks and freqScale carries + // the compound dupre-fs-band unit label (dupre-band is the EQ's). + const b10dom = await evl(`(() => { + const ow = DUPRE.outputWell(document.createElement('div')); + const owRoot = ow.el.className, owSeed = ow.el.querySelectorAll('.dupre-ostep').length; + ow.push(['red', 'Err', 'boom']); + const owN = ow.el.querySelectorAll('.dupre-ostep').length; + const owEv = ow.el.querySelector('.dupre-ostep:last-child .dupre-ev').textContent; + const owRed = ow.el.querySelector('.dupre-ostep:last-child .dupre-lamp').classList.contains('dupre-red'); + const to = DUPRE.toast(document.createElement('div')); + const toRoot = to.el.className; to.fire('ping'); const toTxt = to.el.textContent; + const tc = DUPRE.tapeCounter(document.createElement('div')); + const tcRoot = tc.el.className; + const tcWheels = tc.el.querySelectorAll('.dupre-cwheel').length; + const tcRed = tc.el.querySelectorAll('.dupre-redw').length; + tc.set(120); const tcV = tc.get(); + const fq = DUPRE.freqScale(document.createElement('div')); + const fqBand = fq.el.querySelector('.dupre-fs-band').textContent; + const fqTicks = fq.el.querySelectorAll('.dupre-tick').length > 0; + const fqPtr = !!fq.el.querySelector('.dupre-fptr'); + fq.set(50); const fqV = fq.get(); + const pb = DUPRE.patchBay(document.createElement('div')); + const pbJacks = pb.el.querySelectorAll('.dupre-jack').length; + const pbHot = pb.el.querySelectorAll('.dupre-jack.dupre-hot').length; + const pbConns = pb.get(); + const an = DUPRE.annunciator(document.createElement('div')); + const anCells = an.el.querySelectorAll('.dupre-acell').length; + const anWarn = an.el.querySelectorAll('.dupre-warn').length; + const anFault = an.el.querySelectorAll('.dupre-fault').length; + const anMcOn = an.el.querySelector('.dupre-mc').classList.contains('dupre-on'); + return JSON.stringify({ owRoot, owSeed, owN, owEv, owRed, toRoot, toTxt, + tcRoot, tcWheels, tcRed, tcV, fqBand, fqTicks, fqPtr, fqV, + pbJacks, pbHot, pbConns, anCells, anWarn, anFault, anMcOn }); + })()`); + ok('batch-10 renamed DOM tracks state: well/toast/counter/freqscale/patchbay/annunciator handles + dupre- classes', + b10dom === JSON.stringify({ owRoot: 'dupre-owell', owSeed: 2, owN: 3, owEv: 'boom', owRed: true, + toRoot: 'dupre-toastw', toTxt: 'ping', tcRoot: 'dupre-counter', tcWheels: 6, tcRed: 2, tcV: 120, + fqBand: 'MHz', fqTicks: true, fqPtr: true, fqV: 50, pbJacks: 8, pbHot: 4, pbConns: ['0-5', '2-7'], + anCells: 6, anWarn: 1, anFault: 1, anMcOn: true }), b10dom); + + // late exceptions from interactions + const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); + ok('no exceptions after interaction', errs2.length === 0); + + if (shotPath) { + await evl(`window.scrollTo(0,0)`); + await sleep(300); + const shot = await send('Page.captureScreenshot', { format: 'png' }); + writeFileSync(shotPath, Buffer.from(shot.result.data, 'base64')); + console.log('shot: ' + shotPath); + } +} catch (e) { + console.error('PROBE ERROR: ' + e.message); + fails.push('probe-error'); +} finally { + chrome.kill(); +} +process.exit(fails.length ? 1 : 0); diff --git a/tests/gallery-tokens/test_gen_tokens.py b/tests/gallery-tokens/test_gen_tokens.py new file mode 100644 index 0000000..b9fbb50 --- /dev/null +++ b/tests/gallery-tokens/test_gen_tokens.py @@ -0,0 +1,278 @@ +"""Tests for docs/prototypes/gen_tokens.py. + +gen_tokens.py is the single-source token generator for the panel widget +gallery. It reads docs/prototypes/tokens.json (the neutral source of truth +for the design tokens) and emits three target representations from it: + + - web CSS custom properties (:root { --gold:#e2a038; ... }) + - waybar GTK CSS (@define-color gold #e2a038; ...) + - Emacs elisp (an alist for the future svg.el renderer) + +The three differ on purpose: CSS uses hyphenated --vars and stores glow +colors as bare "r,g,b" triples (so rgba(var(--glow-hi),.5) works); GTK CSS +has no custom properties, so it uses @define-color with underscore names +and resolves glows to their source hex (GTK uses alpha(@color,a)); elisp +uses a hyphenated-symbol alist of hex strings. One source, three emitters +is the whole reason the generator earns its keep across the three targets. + +These tests import the REAL gen_tokens module from docs/prototypes/ (not a +copy) and exercise its functions directly against fixture token dicts, plus +one integration test that runs main() against temp copies. + +Run from repo root: + make test-unit + (or python3 -m unittest tests.gallery-tokens.test_gen_tokens) +""" + +import importlib.util +import json +import os +import tempfile +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +GEN = os.path.join(REPO_ROOT, "docs", "prototypes", "gen_tokens.py") +TOKENS_JSON = os.path.join(REPO_ROOT, "docs", "prototypes", "tokens.json") + + +def _load_module(): + """Import the real gen_tokens.py by path (no importable package name).""" + spec = importlib.util.spec_from_file_location("gen_tokens", GEN) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +gt = _load_module() + + +# A small deterministic fixture — not the real tokens.json, so these tests +# don't drift when the palette is retuned. +FIXTURE = { + "palette": {"ground": "#151311", "wash": "#2c2f32", "slate-hi": "#54677d"}, + "amber": {"gold": "#e2a038", "gold-hi": "#ffbe54", "amber-grad-top": "#f2c76a"}, + "glow": {"glow-hi": "gold-hi", "glow-lo": "gold"}, + "font": {"mono": '"Berkeley Mono",monospace'}, + "timing": {"pulse-rate": "1s"}, +} + + +class HexToTriple(unittest.TestCase): + def test_normal_values(self): + self.assertEqual(gt.hex_to_triple("#ffbe54"), "255,190,84") + self.assertEqual(gt.hex_to_triple("#e2a038"), "226,160,56") + + def test_boundary_black_and_white(self): + self.assertEqual(gt.hex_to_triple("#000000"), "0,0,0") + self.assertEqual(gt.hex_to_triple("#ffffff"), "255,255,255") + + def test_boundary_three_digit_shorthand(self): + self.assertEqual(gt.hex_to_triple("#fff"), "255,255,255") + self.assertEqual(gt.hex_to_triple("#0f0"), "0,255,0") + + def test_boundary_no_leading_hash(self): + self.assertEqual(gt.hex_to_triple("e2a038"), "226,160,56") + + def test_error_bad_chars(self): + with self.assertRaises(ValueError): + gt.hex_to_triple("#gggggg") + + def test_error_bad_length(self): + with self.assertRaises(ValueError): + gt.hex_to_triple("#12") + + +class ResolveColor(unittest.TestCase): + def test_finds_in_amber(self): + self.assertEqual(gt.resolve_color(FIXTURE, "gold"), "#e2a038") + + def test_finds_in_palette(self): + self.assertEqual(gt.resolve_color(FIXTURE, "wash"), "#2c2f32") + + def test_missing_raises(self): + with self.assertRaises(KeyError): + gt.resolve_color(FIXTURE, "nonexistent") + + +class EmitWebCss(unittest.TestCase): + def setUp(self): + self.css = gt.emit_web_css(FIXTURE) + + def test_solid_colors_are_hyphenated_vars(self): + self.assertIn("--gold:#e2a038;", self.css) + self.assertIn("--slate-hi:#54677d;", self.css) + self.assertIn("--amber-grad-top:#f2c76a;", self.css) + + def test_glow_stored_as_rgb_triple(self): + # so rgba(var(--glow-hi),.5) resolves; derived from the source hex + self.assertIn("--glow-hi:255,190,84;", self.css) + self.assertIn("--glow-lo:226,160,56;", self.css) + + def test_font_and_timing(self): + self.assertIn("--pulse-rate:1s;", self.css) + self.assertIn('--mono:"Berkeley Mono",monospace;', self.css) + + +class EmitWaybarGtk(unittest.TestCase): + def setUp(self): + self.gtk = gt.emit_waybar_gtk(FIXTURE) + + def test_uses_define_color_not_custom_props(self): + self.assertIn("@define-color gold #e2a038;", self.gtk) + self.assertNotIn("--gold", self.gtk) + self.assertNotIn(":root", self.gtk) + + def test_glow_resolved_to_source_hex(self): + # GTK has no bare triples; it uses alpha(@color,a), so glow -> hex + self.assertIn("@define-color glow_hi #ffbe54;", self.gtk) + self.assertIn("@define-color glow_lo #e2a038;", self.gtk) + + def test_names_use_underscores_not_hyphens(self): + self.assertIn("@define-color slate_hi #54677d;", self.gtk) + self.assertNotIn("slate-hi", self.gtk) + self.assertNotIn("amber-grad-top", self.gtk) + + +class EmitElisp(unittest.TestCase): + def setUp(self): + self.el = gt.emit_elisp(FIXTURE) + + def test_alist_of_hex_with_hyphenated_symbols(self): + self.assertIn('(gold . "#e2a038")', self.el) + self.assertIn('(slate-hi . "#54677d")', self.el) + + def test_glow_is_hex_for_svg(self): + # svg.el / librsvg wants a real color, not a bare triple + self.assertIn('(glow-hi . "#ffbe54")', self.el) + + def test_timing_included(self): + self.assertIn('(pulse-rate . "1s")', self.el) + + +class ReplaceBetweenMarkers(unittest.TestCase): + START = "/* @tokens:start */" + END = "/* @tokens:end */" + + def _wrap(self, inner): + return f"a\n{self.START}\n{inner}\n{self.END}\nb\n" + + def test_replaces_inner_block(self): + src = self._wrap("OLD") + out = gt.replace_between_markers(src, self.START, self.END, "NEW") + self.assertIn("NEW", out) + self.assertNotIn("OLD", out) + # markers and surrounding text survive + self.assertIn(self.START, out) + self.assertIn(self.END, out) + self.assertTrue(out.startswith("a\n")) + self.assertTrue(out.rstrip().endswith("b")) + + def test_idempotent(self): + src = self._wrap("OLD") + once = gt.replace_between_markers(src, self.START, self.END, "NEW") + twice = gt.replace_between_markers(once, self.START, self.END, "NEW") + self.assertEqual(once, twice) + + def test_missing_marker_raises(self): + with self.assertRaises(ValueError): + gt.replace_between_markers("no markers here", self.START, self.END, "NEW") + + def test_start_marker_on_final_line_without_newline(self): + # Defensive branch: no newline after the start marker. The guard must + # not let text[:si-of-newline+1] collapse to "" and wipe the prefix. + src = f"keep\n{self.START} {self.END}" + out = gt.replace_between_markers(src, self.START, self.END, "X") + self.assertTrue(out.startswith("keep\n")) + self.assertIn(self.END, out) + self.assertIn("X", out) + + +class RealTokensJson(unittest.TestCase): + """The committed tokens.json must stay well-formed and complete.""" + + def setUp(self): + with open(TOKENS_JSON) as f: + self.tokens = json.load(f) + + def test_has_required_sections(self): + for section in ("palette", "amber", "glow", "font", "timing"): + self.assertIn(section, self.tokens) + + def test_amber_defines_gold_and_glow_sources_resolve(self): + self.assertIn("gold", self.tokens["amber"]) + self.assertIn("gold-hi", self.tokens["amber"]) + for _name, source in self.tokens["glow"].items(): + # every glow source must resolve to a real color + gt.resolve_color(self.tokens, source) + + def test_pulse_rate_present(self): + self.assertIn("pulse-rate", self.tokens["timing"]) + + +class DefaultElispFilename(unittest.TestCase): + """The default elisp target must be gallery-tokens.el so that the file's + (provide 'gallery-tokens) matches its name and `require` can resolve it + from a load-path. A tokens.el/gallery-tokens feature mismatch is a trap.""" + + def test_default_elisp_path_matches_provided_feature(self): + self.assertEqual( + os.path.basename(gt.DEFAULT_ELISP_NAME), "gallery-tokens.el") + + +class MainIntegration(unittest.TestCase): + """main() rewrites the html :root and writes the waybar + elisp files.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="gen-tokens-test-") + self.html = os.path.join(self.tmp, "gallery.html") + self.tokens_path = os.path.join(self.tmp, "tokens.json") + self.waybar = os.path.join(self.tmp, "tokens-waybar.css") + self.elisp = os.path.join(self.tmp, "gallery-tokens.el") + with open(self.tokens_path, "w") as f: + json.dump(FIXTURE, f) + with open(self.html, "w") as f: + f.write( + "<style>\n:root{\n" + "/* @tokens:start */\n" + "STALE\n" + "/* @tokens:end */\n" + "}\n</style>\n" + ) + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_main_writes_all_three_targets(self): + gt.main( + tokens_path=self.tokens_path, + html_path=self.html, + waybar_path=self.waybar, + elisp_path=self.elisp, + ) + with open(self.html) as f: + html = f.read() + self.assertIn("--gold:#e2a038;", html) + self.assertNotIn("STALE", html) + self.assertTrue(os.path.exists(self.waybar)) + self.assertTrue(os.path.exists(self.elisp)) + with open(self.waybar) as f: + self.assertIn("@define-color gold #e2a038;", f.read()) + with open(self.elisp) as f: + self.assertIn('(gold . "#e2a038")', f.read()) + + def test_main_is_idempotent(self): + gt.main(tokens_path=self.tokens_path, html_path=self.html, + waybar_path=self.waybar, elisp_path=self.elisp) + with open(self.html) as f: + first = f.read() + gt.main(tokens_path=self.tokens_path, html_path=self.html, + waybar_path=self.waybar, elisp_path=self.elisp) + with open(self.html) as f: + second = f.read() + self.assertEqual(first, second) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/gallery-widgets/test-gallery-widget.el b/tests/gallery-widgets/test-gallery-widget.el new file mode 100644 index 0000000..166cd59 --- /dev/null +++ b/tests/gallery-widgets/test-gallery-widget.el @@ -0,0 +1,114 @@ +;;; test-gallery-widget.el --- ERT tests for gallery-widget.el -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the svg.el proof-of-concept renderer in docs/prototypes/. +;; gallery-widget.el reads the generated gallery-tokens.el (same source of truth as +;; the web gallery and the waybar CSS) and renders gallery widgets as SVG. +;; These tests exercise the REAL module (loaded by path, not a copy): +;; token resolution, needle-angle math (normal/boundary/error), and the +;; rendered SVG document's structure. +;; +;; Run from repo root: +;; make test-elisp +;; (or emacs --batch -l ert -l tests/gallery-widgets/test-gallery-widget.el \ +;; -f ert-run-tests-batch-and-exit) + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(defvar test-gallery-widget--root + (expand-file-name "../.." (file-name-directory (or load-file-name buffer-file-name))) + "Repo root, derived from this test file's location.") + +(load (expand-file-name "docs/prototypes/gallery-widget.el" test-gallery-widget--root)) + +;;; --- token access --- + +(ert-deftest gallery-widget-token-resolves-hex () + "Known tokens resolve to hex color strings from the generated alist." + (should (string-match-p "\\`#[0-9a-f]\\{6\\}\\'" (gallery-widget-token 'gold))) + (should (string-match-p "\\`#[0-9a-f]\\{6\\}\\'" (gallery-widget-token 'glow-hi))) + (should (string-match-p "\\`#[0-9a-f]\\{6\\}\\'" (gallery-widget-token 'wash)))) + +(ert-deftest gallery-widget-token-missing-errors () + "An unknown token name signals an error rather than returning nil." + (should-error (gallery-widget-token 'no-such-token))) + +;;; --- needle angle math --- + +(ert-deftest gallery-widget-needle-angle-normal () + "0..100 maps linearly onto -60..+60 degrees." + (should (= (gallery-widget--needle-angle 0) -60.0)) + (should (= (gallery-widget--needle-angle 50) 0.0)) + (should (= (gallery-widget--needle-angle 100) 60.0))) + +(ert-deftest gallery-widget-needle-angle-boundary-clamps () + "Out-of-range values clamp to the dial's ends instead of overswinging." + (should (= (gallery-widget--needle-angle -5) -60.0)) + (should (= (gallery-widget--needle-angle 150) 60.0))) + +(ert-deftest gallery-widget-needle-angle-error-non-number () + "A non-numeric value signals an error." + (should-error (gallery-widget--needle-angle "fifty")) + (should-error (gallery-widget--needle-angle nil))) + +;;; --- rendered SVG structure --- + +(defun test-gallery-widget--svg-string (value) + "Render the needle gauge at VALUE and return its XML string." + (gallery-widget-svg-string (gallery-widget-needle-gauge value))) + +(ert-deftest gallery-widget-gauge-renders-svg-document () + "The gauge renders to a parseable SVG document." + (let ((xml (test-gallery-widget--svg-string 42))) + (should (string-match-p "\\`<svg" xml)) + (should (with-temp-buffer + (insert xml) + (libxml-parse-xml-region (point-min) (point-max)))))) + +(ert-deftest gallery-widget-gauge-has-expected-parts () + "Arc, three ticks, needle, hub, and value text are all present." + (let ((xml (test-gallery-widget--svg-string 42))) + ;; arc path stroked in the wash token + (should (string-match-p (format "path[^>]*stroke=\"%s\"" (gallery-widget-token 'wash)) xml)) + ;; exactly three ticks + (should (= 3 (cl-count-if (lambda (_) t) + (split-string xml "class=\"tick\"" t) + :start 1))) + ;; needle + hub in the amber tokens + (should (string-match-p (format "class=\"needle\"[^>]*stroke=\"%s\"" + (gallery-widget-token 'gold-hi)) + xml)) + ;; hub is a half-dome sitting on the dial's bottom edge, like the web card + (should (string-match-p (format "class=\"hub\"[^>]*fill=\"%s\"" + (gallery-widget-token 'gold)) + xml)) + (should (string-match-p "class=\"hub\"[^>]*d=\"M 44 48 A 4 4 0 0 1 52 48 Z\"" xml)) + ;; value readout + (should (string-match-p ">42%<" xml)))) + +(ert-deftest gallery-widget-gauge-has-glow-filter () + "The needle glow is a real SVG blur filter, not a dropped effect." + (let ((xml (test-gallery-widget--svg-string 42))) + (should (string-match-p "feGaussianBlur" xml)) + (should (string-match-p "filter=\"url(#" xml)))) + +(ert-deftest gallery-widget-gauge-needle-tracks-value () + "The needle endpoint lands where the angle math says: left at 0, up at 50, right at 100." + ;; value 50 -> vertical: x2 = pivot x (48), y2 = 48 - 40 = 8 + (let ((xml (test-gallery-widget--svg-string 50))) + (should (string-match-p "class=\"needle\"[^>]*x2=\"48.0+\"" xml)) + (should (string-match-p "class=\"needle\"[^>]*y2=\"8.0+\"" xml))) + ;; value 100 -> +60 deg: x2 = 48 + 40*sin60 ~ 82.64 + (should (string-match-p "x2=\"82.6" (test-gallery-widget--svg-string 100))) + ;; value 0 -> -60 deg: x2 = 48 - 40*sin60 ~ 13.36 + (should (string-match-p "x2=\"13.3" (test-gallery-widget--svg-string 0)))) + +(ert-deftest gallery-widget-gauge-integer-percent-in-readout () + "The readout shows a rounded integer percent, matching the web card." + (should (string-match-p ">67%<" (test-gallery-widget--svg-string 66.6)))) + +(provide 'test-gallery-widget) +;;; test-gallery-widget.el ends here diff --git a/tests/installer-steps/test_pacman_hook_order.py b/tests/installer-steps/test_pacman_hook_order.py new file mode 100644 index 0000000..49acdbe --- /dev/null +++ b/tests/installer-steps/test_pacman_hook_order.py @@ -0,0 +1,27 @@ +"""Regression tests for the pacman hook ordering safety invariant.""" + +import os +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +class PacmanHookOrderTests(unittest.TestCase): + def test_safety_hooks_precede_mkinitcpio_removal(self): + with open(ARCHSETUP, encoding="utf-8") as source: + text = source.read() + + self.assertIn( + "cat << 'EOF' > /etc/pacman.d/hooks/05-zfs-snapshot.hook", text) + self.assertIn( + "cat > /etc/pacman.d/hooks/10-hypr-live-update-guard.hook", text) + self.assertLess("05-zfs-snapshot.hook", "60-mkinitcpio-remove.hook") + self.assertLess("10-hypr-live-update-guard.hook", "60-mkinitcpio-remove.hook") + self.assertIn("rm -f /etc/pacman.d/hooks/zfs-snapshot.hook", text) + self.assertIn("rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook", text) + + +if __name__ == "__main__": + unittest.main() |
