|
| 1 | +import { test } from 'node:test'; |
| 2 | +import assert from 'node:assert/strict'; |
| 3 | + |
| 4 | +import { INDEX } from './fixtures/liftFrontend'; |
| 5 | + |
| 6 | +/** |
| 7 | + * What the map's match bar says, said aloud (review F2.2). |
| 8 | + * |
| 9 | + * The bar under the highlight box and the message box in it (#tmQueryErr) are hidden while there is nothing to |
| 10 | + * say, and `[hidden]` is `display: none`: tmSetQueryMessage put the words in the box and showed the box and the |
| 11 | + * bar in one task, so the box, a live region, appeared already filled — which a screen reader seldom announces |
| 12 | + * (sayWorking's own comment). The K03 sentence ("The search runs on a finished scan — it runs once the scan |
| 13 | + * finishes.") and a first parse error were then said only where an earlier message had left the box on screen. |
| 14 | + * The words are now said in #tmQuerySaid, an sr-only live region outside the bar, held by nothing hidden but the |
| 15 | + * Treemap view, a frame after the box shows them; the box itself is plain text, so where a reader does announce a box that appears filled the words |
| 16 | + * are not said twice, and the highlight box names it as its description. |
| 17 | + */ |
| 18 | + |
| 19 | +/** A page function by its declaration, brace-matched. */ |
| 20 | +function pageFn(decl: string): string { |
| 21 | + const start = INDEX.indexOf(decl); |
| 22 | + assert.notEqual(start, -1, `${decl} is in the page`); |
| 23 | + let depth = 0; |
| 24 | + for (let i = INDEX.indexOf('{', INDEX.indexOf(')', start)); i < INDEX.length; i++) { |
| 25 | + if (INDEX[i] === '{') depth++; |
| 26 | + else if (INDEX[i] === '}' && --depth === 0) return INDEX.slice(start, i + 1); |
| 27 | + } |
| 28 | + return assert.fail(`${decl} never closes`); |
| 29 | +} |
| 30 | + |
| 31 | +/** The opening tag of the element with this id in the markup. */ |
| 32 | +function tagOf(id: string): string { |
| 33 | + const at = INDEX.indexOf(` id="${id}"`); |
| 34 | + assert.notEqual(at, -1, `#${id} is in the markup`); |
| 35 | + const open = INDEX.lastIndexOf('<', at); |
| 36 | + return INDEX.slice(open, INDEX.indexOf('>', at) + 1); |
| 37 | +} |
| 38 | + |
| 39 | +const VOID = new Set(['input', 'br', 'img', 'hr', 'meta', 'link', 'source', 'wbr', 'area', 'col', 'embed', 'track']); |
| 40 | + |
| 41 | +/** The opening tags of the elements that hold the one with this id, outermost first, read from the markup. */ |
| 42 | +function ancestorsOf(id: string): string[] { |
| 43 | + const at = INDEX.indexOf(` id="${id}"`); |
| 44 | + const start = INDEX.lastIndexOf('<body', at); |
| 45 | + assert.notEqual(start, -1, 'control: the markup has a body'); |
| 46 | + const markup = INDEX.slice(start, INDEX.lastIndexOf('<', at)).replace(/<!--[\s\S]*?-->/g, ''); |
| 47 | + const open: Array<{ name: string; tag: string }> = []; |
| 48 | + for (const m of markup.matchAll(/<(\/?)([a-z][a-z0-9-]*)\b[^>]*>/gi)) { |
| 49 | + const name = m[2].toLowerCase(); |
| 50 | + if (m[1]) { |
| 51 | + assert.equal(open.pop()?.name, name, `the markup closes ${name} where it opened it`); |
| 52 | + } else if (!VOID.has(name) && !m[0].endsWith('/>')) { |
| 53 | + open.push({ name, tag: m[0] }); |
| 54 | + } |
| 55 | + } |
| 56 | + return open.map((o) => o.tag); |
| 57 | +} |
| 58 | + |
| 59 | +const SAID = '<div id="tmQuerySaid" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>'; |
| 60 | + |
| 61 | +test('the match bar\'s words have a live region of their own, empty in the page from the start, held by nothing that is hidden but the Treemap view itself — never in the bar, which is hidden while it has nothing to say (review F2.2)', () => { |
| 62 | + assert.equal(INDEX.split(SAID).length, 2, 'exactly one empty #tmQuerySaid region in the markup'); |
| 63 | + const holders = ancestorsOf('tmQuerySaid'); |
| 64 | + assert.ok(holders.some((t) => / id="treemapWrap"/.test(t)), `control: it stands in the map's frame, beside the bar: ${holders.join(' ')}`); |
| 65 | + assert.deepEqual(holders.filter((t) => /\shidden(?:[\s>=])/.test(t)).map((t) => / id="([^"]+)"/.exec(t)?.[1] ?? t), ['view-treemap'], |
| 66 | + 'nothing that holds it is hidden apart from the Treemap view, so it is on screen whenever the map is'); |
| 67 | + assert.ok(ancestorsOf('tmQueryErr').some((t) => / id="tmQueryBar"/.test(t)), 'control: the message box is in the bar, which comes and goes'); |
| 68 | +}); |
| 69 | + |
| 70 | +test('the message box is plain text, so its words are said once, and the highlight box names it as its description (review F2.2)', () => { |
| 71 | + const box = tagOf('tmQueryErr'); |
| 72 | + assert.doesNotMatch(box, /\srole="|\saria-live="/, `the box is no live region of its own: ${box}`); |
| 73 | + assert.match(tagOf('tmSearch'), /\saria-describedby="tmQueryErr"/, 'the highlight box is described by the message about its query'); |
| 74 | +}); |
| 75 | + |
| 76 | +/* ───────────────────────────── the page's own code, run ───────────────────────────── */ |
| 77 | + |
| 78 | +/** An element as far as tmSetQueryMessage and sayAloud reach; a region's text is recorded as it is set. */ |
| 79 | +class El { |
| 80 | + hidden = true; |
| 81 | + readonly said: string[] = []; |
| 82 | + private text = ''; |
| 83 | + private readonly attrs = new Map<string, string>(); |
| 84 | + private readonly classes = new Set<string>(); |
| 85 | + readonly classList = { |
| 86 | + toggle: (c: string, on?: boolean) => { if (on ?? !this.classes.has(c)) this.classes.add(c); else this.classes.delete(c); }, |
| 87 | + contains: (c: string) => this.classes.has(c), |
| 88 | + }; |
| 89 | + get textContent(): string { return this.text; } |
| 90 | + set textContent(v: string) { this.text = v; this.said.push(v); } |
| 91 | + setAttribute(k: string, v: string): void { this.attrs.set(k, v); } |
| 92 | + getAttribute(k: string): string | null { return this.attrs.get(k) ?? null; } |
| 93 | +} |
| 94 | + |
| 95 | +/** tmSetQueryMessage and sayAloud lifted from the page, over Els, with frames run by hand. */ |
| 96 | +function matchBar() { |
| 97 | + const els = new Map<string, El>(); |
| 98 | + const $ = (id: string) => { let e = els.get(id); if (!e) { e = new El(); els.set(id, e); } return e; }; |
| 99 | + const frames: Array<() => void> = []; |
| 100 | + let synced = 0; |
| 101 | + const lifted = ['function tmSetQueryMessage(', 'function sayAloud('].map(pageFn).join('\n'); |
| 102 | + const aloud = /\nconst (\w+) = new Map\(\);[^\n]*\nfunction sayAloud\(/.exec(INDEX); |
| 103 | + assert.ok(aloud, 'control: sayAloud keeps what each region says in a map declared right above it'); |
| 104 | + const set = new Function('$', 'requestAnimationFrame', 'tmSyncQueryBar', |
| 105 | + `const ${aloud[1]} = new Map();\n${lifted}\nreturn tmSetQueryMessage;`)( |
| 106 | + $, (fn: () => void) => frames.push(fn), () => { synced++; }) as (text: string, isError: boolean) => void; |
| 107 | + const frame = () => { for (const fn of frames.splice(0)) fn(); }; |
| 108 | + return { $, set, frame, synced: () => synced }; |
| 109 | +} |
| 110 | + |
| 111 | +const K03 = 'The search runs on a finished scan — it runs once the scan finishes.'; |
| 112 | +const PARSE = 'Unknown field "colour". Valid fields: name, size, ext (at character 1)'; |
| 113 | + |
| 114 | +test('the bar\'s words are said in its region a frame after the box shows them, words it already says are not said again, and words taken away before their frame are never said (review F2.2)', () => { |
| 115 | + assert.ok(INDEX.includes(`'${K03}'`), 'control: these are the words the bar says while the saved index\'s tree is up and its scan is to come (K03)'); |
| 116 | + const b = matchBar(); |
| 117 | + b.set(K03, false); |
| 118 | + assert.deepEqual([b.$('tmQueryErr').textContent, b.$('tmQueryErr').hidden, b.synced()], [K03, false, 1], 'control: the box shows the words, and the bar is told'); |
| 119 | + assert.equal(b.$('tmQuerySaid').textContent, '', 'nothing is said before the frame: the box and its bar come on screen in this task'); |
| 120 | + b.frame(); |
| 121 | + assert.equal(b.$('tmQuerySaid').textContent, K03, 'a frame later the region says the words, in its text'); |
| 122 | + |
| 123 | + b.$('tmQuerySaid').said.length = 0; |
| 124 | + b.set(K03, false); |
| 125 | + b.frame(); |
| 126 | + assert.deepEqual(b.$('tmQuerySaid').said, [], 'the same words again — a query run once more, to the same message — are no news, and are not said again'); |
| 127 | + |
| 128 | + b.set(PARSE, true); |
| 129 | + assert.equal(b.$('tmQuerySaid').textContent, '', 'new words: the region is emptied at once'); |
| 130 | + b.frame(); |
| 131 | + assert.equal(b.$('tmQuerySaid').textContent, PARSE, 'and says them a frame later'); |
| 132 | + |
| 133 | + b.$('tmQuerySaid').said.length = 0; |
| 134 | + b.set('', false); |
| 135 | + b.frame(); |
| 136 | + b.set(PARSE, true); |
| 137 | + b.frame(); |
| 138 | + assert.deepEqual(b.$('tmQuerySaid').said, ['', '', PARSE], 'once the bar has fallen silent, the same words as before are news again: emptied with the bar, then said'); |
| 139 | + |
| 140 | + b.set(K03, false); |
| 141 | + b.set('', false); |
| 142 | + b.frame(); |
| 143 | + assert.deepEqual([b.$('tmQueryErr').hidden, b.$('tmQuerySaid').textContent], [true, ''], 'words taken away before their frame are never said, and the region is left empty'); |
| 144 | +}); |
| 145 | + |
| 146 | +test('tmSetQueryMessage is the one place the box\'s words are set, so every word the bar shows is said aloud (review F2.2)', () => { |
| 147 | + assert.ok(pageFn('function tmSetQueryMessage(').includes("const box = $('tmQueryErr');"), 'control: tmSetQueryMessage holds the box'); |
| 148 | + const uses = [...INDEX.matchAll(/\$\('tmQueryErr'\)(.{0,12})/g)].map((m) => m[0]); |
| 149 | + assert.equal(uses.filter((u) => u.startsWith("$('tmQueryErr');")).length, 1, 'only tmSetQueryMessage holds the box'); |
| 150 | + assert.deepEqual(uses.filter((u) => !u.startsWith("$('tmQueryErr');") && !/^\$\('tmQueryErr'\)\.hidden(?!\s*=(?!=))/.test(u)), [], |
| 151 | + 'every other use of the box reads whether it is hidden'); |
| 152 | + assert.deepEqual([...INDEX.matchAll(/(?:getElementById\(\s*'tmQueryErr'|querySelector(?:All)?\(\s*'#tmQueryErr)[^\n]{0,40}/g)].map((m) => m[0]), [], |
| 153 | + 'and nothing reaches the box another way'); |
| 154 | +}); |
0 commit comments