waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests gesperrt-sichtbar.test.ts

9,6 KB Rohdatei
app/tests/gesperrt-sichtbar.test.ts — 231 Zeilen
1 // @vitest-environment node
2 /**
3 * „Gesperrt sieht gesperrt aus“ – auch ohne Farbe.
4 *
5 * **Der Anlass.** Die Projektregel `aria-disabled` statt `disabled` ist
6 * richtig: Ein natives `disabled` nähme dem Element den Fokus, und der
7 * erklärende Satz daneben würde nie vorgelesen. Der Zustand wurde aber
8 * **ausschließlich über Farbtoken** gezeichnet – heller Text auf versetztem
9 * Grund. Im eigenen Hochkontrast-Thema ist `--farbe-text-leise` mit
10 * `--farbe-text` identisch (beides `#ffffff`) und `--farbe-grund-versetzt`
11 * mit dem Grund (`#000000`): Ein gesperrter Knopf sah dort **pixelgleich**
12 * aus wie ein aktiver. Unter erzwungenen Farben fallen alle beteiligten Token
13 * ohnehin auf `CanvasText` und `Canvas` zusammen.
14 *
15 * Wer Hochkontrast braucht, drückte also auf scheinbar bereite Knöpfe, ohne
16 * dass etwas geschah – genau die Verwirrung, die der Kommentar in `lernen.css`
17 * zu verhindern verspricht.
18 *
19 * Geprüft wird hier am Stilblatt, nicht im Browser: Ob eine gestrichelte
20 * Linie im echten Fenster auffällt, sagt keine Messung – das ist Sichtprüfung
21 * H8. Was sich hier feststellen lässt, ist, dass der Zustand überhaupt an
22 * etwas anderem hängt als an einer Farbe, die in zwei von vier Darstellungen
23 * mit ihrem Gegenstück zusammenfällt.
24 */
25
26 import { readdirSync, readFileSync } from 'node:fs';
27 import { join } from 'node:path';
28 import { fileURLToPath } from 'node:url';
29
30 import { describe, expect, it } from 'vitest';
31
32 const wurzel = fileURLToPath(new URL('..', import.meta.url));
33
34 function stil(datei: string): string {
35 return readFileSync(join(wurzel, 'src', 'renderer', 'src', 'styles', datei), 'utf8');
36 }
37
38 /** Alle Dateien mit dieser Endung unterhalb eines Verzeichnisses. */
39 function dateien(verzeichnis: string, endung: string): string[] {
40 const gefunden: string[] = [];
41 for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) {
42 const pfad = join(verzeichnis, eintrag.name);
43 if (eintrag.isDirectory()) {
44 gefunden.push(...dateien(pfad, endung));
45 } else if (eintrag.name.endsWith(endung)) {
46 gefunden.push(pfad);
47 }
48 }
49 return gefunden;
50 }
51
52 const LERNEN_CSS = stil('lernen.css');
53 const TOKENS_CSS = stil('tokens.css');
54
55 /** Der Regelblock ab einem Selektor bis zur schließenden Klammer. */
56 function block(css: string, selektor: string): string {
57 const anfang = css.indexOf(selektor);
58 expect(anfang, `Selektor nicht gefunden: ${selektor}`).toBeGreaterThan(-1);
59 const auf = css.indexOf('{', anfang);
60 const zu = css.indexOf('}', auf);
61 return css.slice(auf, zu);
62 }
63
64 describe('Der Gesperrt-Zustand hängt nicht allein an der Farbe', () => {
65 it('gibt gesperrten Knöpfen ein farbunabhängiges Merkmal', () => {
66 const regel = block(LERNEN_CSS, ".knopf[aria-disabled='true']");
67
68 expect(regel).toMatch(/border-style:\s*dashed/u);
69 });
70
71 it('belegt, warum die Farbe allein nicht trägt', () => {
72 /* Die Gegenprobe zur Behebung: Wären die beiden Token im
73 Hochkontrast-Thema verschieden, bräuchte es das zusätzliche Merkmal
74 nicht – und dieser Test dürfte gelöscht werden. Solange sie
75 zusammenfallen, ist er der Beleg für den Befund. */
76 const hochkontrast = block(TOKENS_CSS, ":root[data-thema='hochkontrast']");
77 const werte = new Map(
78 [...hochkontrast.matchAll(/--([a-z-]+):\s*(#[0-9a-f]{6})\b/gu)].map((t) => [t[1], t[2]]),
79 );
80
81 expect(werte.get('farbe-text-leise')).toBe(werte.get('farbe-text'));
82 expect(werte.get('farbe-grund-versetzt')).toBe(werte.get('farbe-grund'));
83 });
84 });
85
86 describe('Erzwungene Farben (Windows-Kontrastdesign)', () => {
87 it('kennzeichnet gesperrte Knöpfe mit der Systemfarbe GrayText', () => {
88 /* Die einzige Farbe, die unter erzwungenen Farben einen Zustand
89 ausdrücken darf – und die einzige, die Windows-Nutzer dafür kennen.
90 Sie muss ausdrücklich gesetzt werden: Die Token fallen dort alle auf
91 CanvasText zusammen. */
92 const bereich = LERNEN_CSS.slice(LERNEN_CSS.indexOf('@media (forced-colors: active)'));
93 const regel = block(bereich, ".knopf[aria-disabled='true']");
94
95 expect(regel).toMatch(/color:\s*GrayText/u);
96 expect(regel).toMatch(/border-color:\s*GrayText/u);
97 });
98
99 it('kennzeichnet auch gesperrte Schalter', () => {
100 const bereich = LERNEN_CSS.slice(LERNEN_CSS.indexOf('@media (forced-colors: active)'));
101 const regel = block(bereich, ".schalter__feld[aria-disabled='true'] + .schalter__beschriftung");
102
103 expect(regel).toMatch(/color:\s*GrayText/u);
104 });
105 });
106
107 describe('Das gewählte Gesetz ist von den übrigen zu unterscheiden', () => {
108 /*
109 Befund der Prüfrunde zu 0.27.2. Der Kommentar über der Regel sagt zu: „Der
110 Rahmen bleibt sichtbar, damit die Wahl auch im Windows-Kontrastdesign
111 erkennbar ist.“ Die Regel setzte aber genau die zwei Werte, die `.knopf`
112 ohnehin trägt — `border-width: var(--rahmenbreite-stark)` und
113 `font-weight: var(--schriftstaerke-mittel)`. Zwischen dem gewählten Gesetz
114 und den sechs anderen war deshalb kein einziger Bildpunkt Unterschied;
115 getragen wurde die Auskunft allein von `aria-current`, also nur für
116 Bildschirmleser.
117 */
118 /**
119 * Die Erklärungen eines Regelblocks als Paare.
120 *
121 * Die Kurzform `border` wird dabei aufgelöst. Ohne das ginge der Vergleich
122 * ins Leere: `.knopf` schreibt `border: <breite> solid <farbe>`, die
123 * geprüfte Regel `border-width` — zwei Namen für denselben Wert, und die
124 * Wache hätte den Befund nicht gesehen.
125 */
126 function erklaerungen(regel: string): Map<string, string> {
127 const paare = new Map<string, string>();
128 for (const teil of regel.replace('{', '').split(';')) {
129 const stelle = teil.indexOf(':');
130 if (stelle < 0) {
131 continue;
132 }
133 const name = teil.slice(0, stelle).trim();
134 const wert = teil.slice(stelle + 1).trim();
135 if (name === 'border') {
136 const stuecke = wert.split(/\s+(?![^(]*\))/u);
137 paare.set('border-width', stuecke[0] ?? '');
138 paare.set('border-style', stuecke[1] ?? '');
139 paare.set('border-color', stuecke[2] ?? '');
140 continue;
141 }
142 paare.set(name, wert);
143 }
144 return paare;
145 }
146
147 it('setzt mindestens einen Wert, den der Knopf nicht ohnehin hat', () => {
148 const grund = erklaerungen(block(LERNEN_CSS, '.knopf {'));
149 const aktiv = erklaerungen(block(LERNEN_CSS, '.gesetze__knopf--aktiv'));
150
151 expect(aktiv.size, 'Die Regel ist leer').toBeGreaterThan(0);
152 const wirksam = [...aktiv].filter(([name, wert]) => grund.get(name) !== wert);
153 expect(
154 wirksam.map(([name]) => name),
155 'Jede Erklärung wiederholt nur, was `.knopf` schon sagt',
156 ).not.toHaveLength(0);
157 });
158
159 it('trägt die Auskunft nicht allein über die Farbe (WCAG 1.4.1)', () => {
160 /* Unter erzwungenen Farben kommt keine eigene Hintergrundfarbe durch.
161 Es braucht deshalb ein Merkmal, das dort erhalten bleibt. */
162 const aktiv = block(LERNEN_CSS, '.gesetze__knopf--aktiv');
163
164 expect(aktiv).toMatch(/text-decoration:|font-weight:\s*var\(--schriftstaerke-fett\)/u);
165 });
166 });
167
168 describe('Eine leere Live-Region bleibt im Barrierefreiheitsbaum', () => {
169 /*
170 Befund der Prüfrunde zu 0.27.2, zweimal gefunden. Über der ersten Regel
171 stand: „Leer nimmt die Region keinen Platz weg; sie bleibt trotzdem im
172 DOM, sonst verschluckt der Screenreader die erste Meldung.“ Darunter stand
173 `display: none` — und ein ausgeblendetes Element ist für einen
174 Bildschirmleser nicht vorhanden. Region und Inhalt entstehen dann im
175 selben Aufbau, also genau der Fall, den der Kommentar ausschließen will.
176
177 Betroffen waren fünf Regionen: die Ausgabe des Lernberichts, „Neu
178 anfangen“, die Profilverwaltung, der Fehlermeldeblock und die
179 Datensicherung. Beim Fehlermeldeblock traf es nicht nur die erste
180 Meldung — dort wird die Region vor jedem Kopierversuch geleert.
181
182 Geprüft wird nicht gegen eine gepflegte Liste, sondern gegen das Markup:
183 Jede Klasse, die in einer `.tsx` an einem Knoten mit `role="status"` oder
184 `aria-live` steht, darf in keinem Stilblatt eine Regel haben, die sie
185 ausblendet. Eine neue Live-Region ist damit von selbst mitbewacht.
186 */
187 /** Klassen aller Knoten, die eine Live-Region sind. */
188 function livebereiche(): Set<string> {
189 const klassen = new Set<string>();
190 for (const datei of dateien(join(wurzel, 'src', 'renderer', 'src'), '.tsx')) {
191 const inhalt = readFileSync(datei, 'utf8');
192 for (const [tag] of inhalt.matchAll(/<[a-zA-Z][^>]*>/gsu)) {
193 if (!/role="status"|role="alert"|aria-live=/u.test(tag)) {
194 continue;
195 }
196 const marke = /className="([^"{]*)"/u.exec(tag);
197 for (const name of (marke?.[1] ?? '').split(/\s+/u)) {
198 if (name !== '' && name !== 'nur-screenreader') {
199 klassen.add(name);
200 }
201 }
202 }
203 }
204 return klassen;
205 }
206
207 it('blendet keine Live-Region im leeren Zustand aus', () => {
208 const klassen = livebereiche();
209 expect(klassen.size, 'Keine Live-Region gefunden – der Test misst nichts').toBeGreaterThan(5);
210
211 const stilblaetter = dateien(join(wurzel, 'src', 'renderer', 'src', 'styles'), '.css').map(
212 (datei) => readFileSync(datei, 'utf8'),
213 );
214
215 const versteckt: string[] = [];
216 for (const css of stilblaetter) {
217 for (const [, selektor, inhalt] of css.matchAll(/([^{}]+)\{([^}]*)\}/gu)) {
218 if (!/display:\s*none/u.test(inhalt ?? '')) {
219 continue;
220 }
221 for (const klasse of klassen) {
222 if ((selektor ?? '').includes(`.${klasse}`)) {
223 versteckt.push(`${klasse} über „${(selektor ?? '').trim()}“`);
224 }
225 }
226 }
227 }
228
229 expect(versteckt, 'Eine leere Live-Region darf nicht verschwinden').toEqual([]);
230 });
231 });