waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests gesperrt-sichtbar.test.ts
| 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 | }); |