Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_announce.mjs

6.2 KiB, 1 run

created by r2519314175:231, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_announce.mjs — a screen reader is told when an answer arrives, once.
2//
3// dev/a11y_report.md §8: nothing that changes on its own is announced. The chat
4// was the worst of it -- press Send and a screen-reader user hears nothing, ever,
5// neither the arrival of an answer nor its end.
6//
7// The obvious fix is the wrong one, which is why this file exists to pin the
8// right one. `aria-live` on the thread reads EVERY change, and an answer arrives
9// a few characters at a time, so the reader would hear it re-read at them in
10// fragments for as long as the model kept typing. What is checked here is
11// therefore two things at once:
12//
13// 1. The answer IS announced, once per turn, with the word count.
14// 2. The thread itself is NOT live -- because a future change that "improves"
15// this by marking the thread live would pass a naive test and make the app
16// unusable for exactly the person it was meant to help.
17//
18// And the two popovers, from the same round: both are role="dialog", both cover
19// the app, and Tab used to walk out of them into the page behind.
20import { open, newChat, chat as sendChat } from './harness.mjs';
21
22const ok = [], bad = [];
23const check = (name, pass, detail) => {
24 (pass ? ok : bad).push(name);
25 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
26 return pass;
27};
28
29const s = await open({ name: 'announce' });
30const p = s.page;
31await p.waitForTimeout(1500);
32await newChat(s);
33
34// ── 1. The thread is silent ─────────────────────────────────────────
35const thread = await p.evaluate(() => {
36 const el = document.getElementById('chat-output');
37 return el ? {
38 live: el.getAttribute('aria-live'),
39 role: el.getAttribute('role'),
40 } : null;
41});
42check('the streaming thread is NOT a live region', !!thread && !thread.live && thread.role !== 'log',
43 JSON.stringify(thread));
44
45// ── 2. There is a status line, and it starts empty ──────────────────
46const before = await p.evaluate(() => {
47 const el = document.getElementById('chat-say');
48 return el ? { live: el.getAttribute('aria-live'), role: el.getAttribute('role'),
49 text: (el.textContent || '').trim(),
50 painted: el.getClientRects().length > 0 && el.clientWidth > 1 } : null;
51});
52check('there is a polite status line for the chat', !!before && before.live === 'polite',
53 JSON.stringify(before && before.live));
54check('it is a status role', !!before && before.role === 'status');
55check('it says nothing before a turn', !!before && before.text === '',
56 JSON.stringify(before && before.text));
57check('and it is not drawn on screen', !!before && before.painted === false);
58
59// ── 3. One turn, one announcement, with the count ───────────────────
60await sendChat(s, '@text One two three four five');
61await p.waitForTimeout(1200);
62
63const said = await p.evaluate(() =>
64 (document.getElementById('chat-say').textContent || '').trim());
65check('the answer is announced when it lands', /answered/i.test(said), JSON.stringify(said));
66const n = (said.match(/(\d+)/) || [])[1];
67check('the announcement carries a word count', !!n && Number(n) > 0, String(n));
68
69// It must match what was actually said, not a guess.
70const words = await p.evaluate(() => {
71 const msgs = [...document.querySelectorAll('.chat-msg-content')];
72 const last = msgs[msgs.length - 1];
73 return last ? (last.textContent || '').trim().split(/\s+/).filter(Boolean).length : 0;
74});
75check('the count is the answer\'s own length', Math.abs(Number(n) - words) <= 1,
76 `announced ${n}, on screen ${words}`);
77
78// ── 4. A second turn of the same length still announces ─────────────
79//
80// The trap in a status line: writing the identical string is not a change, so the
81// second announcement would be silent. This is the case that catches it.
82await sendChat(s, '@text One two three four five');
83await p.waitForTimeout(1200);
84const second = await p.evaluate(() => {
85 const el = document.getElementById('chat-say');
86 return { text: (el.textContent || '').trim() };
87});
88check('a second answer of the same length is still announced',
89 /answered/i.test(second.text), JSON.stringify(second.text));
90
91// ── 5. The two popovers hold Tab ────────────────────────────────────
92//
93// Walked, not read: open the popover, press Tab past its last stop, and ask
94// whether the focus is still inside it.
95async function trapped(openSel, popSel, label) {
96 await p.evaluate((sel) => {
97 const b = document.querySelector(sel);
98 if (b) b.click();
99 }, openSel);
100 await p.waitForTimeout(500);
101 const stops = await p.evaluate((sel) => {
102 const pop = document.querySelector(sel);
103 if (!pop || pop.hidden) return 0;
104 return pop.querySelectorAll('input,button,select,textarea,a[href],[tabindex]:not([tabindex="-1"])').length;
105 }, popSel);
106 if (!stops) { check(`${label} opened so its Tab can be walked`, false, '0 stops'); return; }
107 for (let i = 0; i < stops + 3; i++) await p.keyboard.press('Tab');
108 const inside = await p.evaluate((sel) => {
109 const pop = document.querySelector(sel);
110 return !!pop && pop.contains(document.activeElement);
111 }, popSel);
112 check(`${label} keeps Tab inside it (${stops} stops, walked ${stops + 3})`, inside === true);
113 await p.keyboard.press('Escape');
114 await p.waitForTimeout(300);
115}
116
117await trapped('#settings-menu-btn', '#settings-menu', 'the appearance menu');
118// The ⋯ that opens the gallery exists only when the chip row has had to hide
119// something, so the window is narrowed until it does. Opening the gallery by
120// reaching into the module would test a door nobody walks through.
121await p.setViewportSize({ width: 900, height: 800 });
122await p.waitForTimeout(600);
123const haveMore = await p.evaluate(() => !!document.getElementById('panel-more'));
124check('the chip row hid something, so the gallery has an opener', haveMore === true);
125if (haveMore) await trapped('#panel-more', '#panel-gallery', 'the panel gallery');
126
127console.log('');
128console.log(bad.length
129 ? `${bad.length} FAILED of ${ok.length + bad.length}:\n - ${bad.join('\n - ')}`
130 : `announce: all ${ok.length} checks pass — one announcement per answer, and the popovers hold Tab.`);
131await s.close();
132process.exit(bad.length ? 1 : 0);