diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-16 15:12:23 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-16 15:12:23 -0500 |
| commit | c41412dc083fab137380cafcc4964887e83964ac (patch) | |
| tree | 190c5731bd6279943d099242ddf5efcac9022b73 /tests | |
| parent | 9320bb32cc5db4595fc007d8cc731623e26e20c0 (diff) | |
| download | archsetup-c41412dc083fab137380cafcc4964887e83964ac.tar.gz archsetup-c41412dc083fab137380cafcc4964887e83964ac.zip | |
feat(gallery): add the ABC entry keypad
The kit entered digits three ways and text no way at all, so the panels that need a WiFi password had nothing to reach for. R57 takes free alphanumeric text. A-Z is laid out alphabetically rather than QWERTY, the industrial convention wherever the operator isn't assumed to touch-type, and what makes this a faceplate instead of a keyboard.
I followed two reference plates and departed from both, arguing each departure at its site. Their letters are blue and the kit has no blue control colour, so letters take the standard pale cap and digits a darker one: the photos' two-tone grouping without the foreign hue. Letters sit left and digits right so the alphabet reads down one unbroken block. With the digits on the left it stops column-aligning with itself halfway down.
DEL is new, because without a backspace one mistyped character costs the whole entry. It sits beside the digits in amber while CLR is exiled to the corner in red. DEL is constant and costs a character, CLR is rare and costs everything, so the safe key gets the good spot and the colour grades the cost before the legend is read. CANCEL is gone: on a plate that is the whole terminal it has a transaction to abandon, but here it did exactly what CLR does.
The window draws spaces as ␣. SVG collapses whitespace, and past the truncation boundary there are no pad dots left for a space to displace, so SPACE was a keypress with no feedback at all. The buffer keeps the real character.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/gallery-probes/probe.mjs | 150 |
1 files changed, 149 insertions, 1 deletions
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs index d196377..316816e 100644 --- a/tests/gallery-probes/probe.mjs +++ b/tests/gallery-probes/probe.mjs @@ -68,7 +68,7 @@ try { // 1. defaults: size=2 (M), card count ok('default size 2', await evl(`document.body.dataset.size`) === '2'); const cards = await evl(`document.querySelectorAll('.card').length`); - ok('109 cards', cards === 109, `got ${cards}`); + ok('110 cards', cards === 110, `got ${cards}`); // 2. zoom actually scales: card visual width at 3x vs 1x await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`); @@ -240,6 +240,154 @@ try { })()`); 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 .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 .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 .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 .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 .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 .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 .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 .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 .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); + // late exceptions from interactions const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); ok('no exceptions after interaction', errs2.length === 0); |
