waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests token-kontraste.test.ts
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Die Kontrastzusicherungen des Token-Systems. |
| 4 | * |
| 5 | * tokens.css nennt in seinen Kommentaren „Gemessene Kontraste“. Bis 0.20.0 |
| 6 | * hat diese Messung niemand wiederholt – und die Zahlen drifteten: #6fc3d6 |
| 7 | * war mit 9,2:1 kommentiert und liefert 9,0:1, #b9bcc2 mit ≈2,0:1 und |
| 8 | * liefert 1,8:1. --farbe-warnung verfehlte im hellen Thema mit 6,97:1 sogar |
| 9 | * das 7:1-Ziel der Datei. Dieser Test rechnet nach, wie es |
| 10 | * `tests/druck.test.ts` für die Druckfarben vormacht: Die Kommentare bleiben |
| 11 | * Angaben und verkommen nicht zu Behauptungen. |
| 12 | */ |
| 13 | |
| 14 | import { readdirSync, readFileSync } from 'node:fs'; |
| 15 | import { join } from 'node:path'; |
| 16 | import { fileURLToPath } from 'node:url'; |
| 17 | |
| 18 | import { describe, expect, it } from 'vitest'; |
| 19 | |
| 20 | import { startfarbeFuer, type SystemAnzeigeSignale } from '../src/shared/theme'; |
| 21 | |
| 22 | const wurzel = fileURLToPath(new URL('..', import.meta.url)); |
| 23 | const TOKENS_CSS = readFileSync( |
| 24 | join(wurzel, 'src', 'renderer', 'src', 'styles', 'tokens.css'), |
| 25 | 'utf8', |
| 26 | ); |
| 27 | |
| 28 | // ─── Die WCAG-Rechnung – Formel im Test, keine neue Abhängigkeit ──────── |
| 29 | |
| 30 | /** Relative Leuchtdichte nach WCAG 2.x. */ |
| 31 | function leuchtdichte(hex: string): number { |
| 32 | const kanal = (n: number): number => { |
| 33 | const s = n / 255; |
| 34 | return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; |
| 35 | }; |
| 36 | const r = Number.parseInt(hex.slice(1, 3), 16); |
| 37 | const g = Number.parseInt(hex.slice(3, 5), 16); |
| 38 | const b = Number.parseInt(hex.slice(5, 7), 16); |
| 39 | return 0.2126 * kanal(r) + 0.7152 * kanal(g) + 0.0722 * kanal(b); |
| 40 | } |
| 41 | |
| 42 | function kontrast(vorne: string, hinten: string): number { |
| 43 | const a = leuchtdichte(vorne); |
| 44 | const b = leuchtdichte(hinten); |
| 45 | return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); |
| 46 | } |
| 47 | |
| 48 | // ─── tokens.css lesen: Themenblöcke und Kommentartabellen ─────────────── |
| 49 | |
| 50 | /** Alle `--variable: #rrggbb`-Paare eines Regelblocks. */ |
| 51 | function farbenImBlock(block: string): ReadonlyMap<string, string> { |
| 52 | return new Map( |
| 53 | [...block.matchAll(/--([a-z-]+):\s*(#[0-9a-f]{6})\b/gu)].map((t) => [t[1] ?? '', t[2] ?? '']), |
| 54 | ); |
| 55 | } |
| 56 | |
| 57 | function block(muster: RegExp): string { |
| 58 | const treffer = muster.exec(TOKENS_CSS); |
| 59 | if (treffer?.[1] === undefined) { |
| 60 | throw new Error(`Regelblock nicht gefunden: ${String(muster)}`); |
| 61 | } |
| 62 | return treffer[1]; |
| 63 | } |
| 64 | |
| 65 | const HELL = farbenImBlock(block(/:root,\s*:root\[data-thema='hell'\]\s*\{([^}]*)\}/u)); |
| 66 | const DUNKEL = farbenImBlock(block(/:root\[data-thema='dunkel'\]\s*\{([^}]*)\}/u)); |
| 67 | const HOCHKONTRAST = farbenImBlock(block(/:root\[data-thema='hochkontrast'\]\s*\{([^}]*)\}/u)); |
| 68 | const DUNKEL_SYSTEM = farbenImBlock( |
| 69 | block(/@media \(prefers-color-scheme: dark\)\s*\{\s*:root[^{]*\{([^}]*)\}/u), |
| 70 | ); |
| 71 | |
| 72 | function pflicht(farben: ReadonlyMap<string, string>, variable: string): string { |
| 73 | const wert = farben.get(variable); |
| 74 | if (wert === undefined) { |
| 75 | throw new Error(`--${variable} fehlt im Themenblock oder ist keine Hex-Farbe.`); |
| 76 | } |
| 77 | return wert; |
| 78 | } |
| 79 | |
| 80 | interface Kommentarzeile { |
| 81 | readonly variable: string; |
| 82 | readonly hex: string; |
| 83 | readonly wert: number; |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Die Tabelle „Gemessene Kontraste gegen --farbe-grund (#…)“ über einem |
| 88 | * Themenblock. Die Grundfarbe identifiziert die Tabelle – so bleibt die |
| 89 | * Zuordnung auch dann richtig, wenn Blöcke umsortiert werden. |
| 90 | */ |
| 91 | function kommentartabelle(grundHex: string): readonly Kommentarzeile[] { |
| 92 | const kopf = TOKENS_CSS.indexOf(`Gemessene Kontraste gegen --farbe-grund (${grundHex})`); |
| 93 | if (kopf < 0) { |
| 94 | throw new Error(`Keine Kommentartabelle für ${grundHex} gefunden.`); |
| 95 | } |
| 96 | const ausschnitt = TOKENS_CSS.slice(kopf, TOKENS_CSS.indexOf('====', kopf)); |
| 97 | return [...ausschnitt.matchAll(/--([a-z-]+)\s+(#[0-9a-f]{6})\s+≈\s*(\d+(?:,\d+)?):1/gu)].map( |
| 98 | (t) => ({ |
| 99 | variable: t[1] ?? '', |
| 100 | hex: t[2] ?? '', |
| 101 | wert: Number((t[3] ?? '').replace(',', '.')), |
| 102 | }), |
| 103 | ); |
| 104 | } |
| 105 | |
| 106 | const THEMEN = [ |
| 107 | ['hell', '#f7f7f5', HELL], |
| 108 | ['dunkel', '#14161a', DUNKEL], |
| 109 | ] as const; |
| 110 | |
| 111 | // ─── Der Block für erzwungene Farben muss auch greifen ────────────────── |
| 112 | |
| 113 | describe('Das Windows-Kontrastdesign in tokens.css', () => { |
| 114 | /* |
| 115 | Bei erzwungenen Farben setzt `themeAufloesen` das Thema unbedingt auf |
| 116 | `hochkontrast`, und `:root[data-thema='hochkontrast']` hat die höhere |
| 117 | Spezifität. Bis 0.24.1 stand im `forced-colors`-Block nur `:root` – der |
| 118 | ganze Block war damit wirkungslos, und die Anwendung behielt ihre eigenen |
| 119 | Farben, wo sie die des Systems nehmen sollte. Nichts hat es gemeldet, |
| 120 | weil die zwei Regeln mit `forced-color-adjust: none` ihre Systemfarben |
| 121 | von Hand setzen. |
| 122 | */ |
| 123 | it('gewinnt gegen die Regel des eigenen Hochkontrast-Themas', () => { |
| 124 | const block = /@media \(forced-colors: active\)\s*\{\s*([^{]+)\{/u.exec(TOKENS_CSS); |
| 125 | |
| 126 | expect(block, 'Der Block für erzwungene Farben fehlt in tokens.css.').not.toBeNull(); |
| 127 | const selektoren = (block?.[1] ?? '').split(',').map((teil) => teil.trim()); |
| 128 | |
| 129 | /* Ein blosses `:root` (0,0,1) verliert gegen |
| 130 | `:root[data-thema='hochkontrast']` (0,1,1). Es braucht mindestens einen |
| 131 | Selektor mit Attribut. */ |
| 132 | expect( |
| 133 | selektoren.some((teil) => teil.startsWith(':root[data-thema')), |
| 134 | `Selektoren im forced-colors-Block: ${selektoren.join(' | ')} – ` + |
| 135 | 'ohne Attributselektor überstimmt ihn das eigene Hochkontrast-Thema.', |
| 136 | ).toBe(true); |
| 137 | }); |
| 138 | |
| 139 | it('bildet dieselben Token ab wie das eigene Hochkontrast-Thema', () => { |
| 140 | /* Sonst bliebe bei erzwungenen Farben ein Token auf dem hausgemachten |
| 141 | Wert stehen, ohne dass es jemandem auffiele. */ |
| 142 | const block = |
| 143 | /@media \(forced-colors: active\)\s*\{\s*[^{]+\{([\s\S]*?)\n {2}\}/u.exec(TOKENS_CSS)?.[1] ?? |
| 144 | ''; |
| 145 | const gesetzt = new Set([...block.matchAll(/--([\w-]+)\s*:/gu)].map((treffer) => treffer[1])); |
| 146 | |
| 147 | const fehlend = [...HOCHKONTRAST.keys()].filter( |
| 148 | (name) => name.startsWith('farbe-') && !gesetzt.has(name), |
| 149 | ); |
| 150 | |
| 151 | expect(fehlend, 'Diese Farbtoken fehlen im Block für erzwungene Farben.').toEqual([]); |
| 152 | }); |
| 153 | }); |
| 154 | |
| 155 | // ─── Die Kommentare stimmen mit dem Code überein ──────────────────────── |
| 156 | |
| 157 | describe('Die Kommentartabellen in tokens.css', () => { |
| 158 | it.each(THEMEN)('Thema %s: die Tabelle nennt die Farben des Codes', (_thema, grund, farben) => { |
| 159 | expect(pflicht(farben, 'farbe-grund')).toBe(grund); |
| 160 | |
| 161 | const tabelle = kommentartabelle(grund); |
| 162 | /* Wer eine Zeile löscht, statt sie zu messen, fällt hier auf. */ |
| 163 | for (const variable of [ |
| 164 | 'farbe-text', |
| 165 | 'farbe-text-leise', |
| 166 | 'farbe-akzent', |
| 167 | 'farbe-erfolg', |
| 168 | 'farbe-warnung', |
| 169 | 'farbe-fehler', |
| 170 | 'farbe-rahmen-kraeftig', |
| 171 | ]) { |
| 172 | expect( |
| 173 | tabelle.map((z) => z.variable), |
| 174 | `Tabellenzeile für --${variable}`, |
| 175 | ).toContain(variable); |
| 176 | } |
| 177 | for (const zeile of tabelle) { |
| 178 | expect(pflicht(farben, zeile.variable), `--${zeile.variable}`).toBe(zeile.hex); |
| 179 | } |
| 180 | }); |
| 181 | |
| 182 | it.each(THEMEN)( |
| 183 | 'Thema %s: die genannten Werte halten der Nachrechnung stand', |
| 184 | (_thema, grund) => { |
| 185 | for (const zeile of kommentartabelle(grund)) { |
| 186 | const gemessen = kontrast(zeile.hex, grund); |
| 187 | expect( |
| 188 | Math.abs(gemessen - zeile.wert), |
| 189 | `--${zeile.variable}: gemessen ${gemessen.toFixed(3)}, kommentiert ${String(zeile.wert)}`, |
| 190 | ).toBeLessThanOrEqual(0.05); |
| 191 | } |
| 192 | }, |
| 193 | ); |
| 194 | |
| 195 | it('Hochkontrast: das bezifferte Bernsteingelb stimmt', () => { |
| 196 | /* Der Prosa-Kommentar über dem Block nennt „(#ffd400 ≈ 14,7:1)“ – |
| 197 | dieselbe Rechnung, nur ein anderes Format. */ |
| 198 | const treffer = /\(#([0-9a-f]{6}) ≈ (\d+,\d+):1\)/u.exec(TOKENS_CSS); |
| 199 | if (treffer?.[1] === undefined || treffer[2] === undefined) { |
| 200 | throw new Error('Der Hochkontrast-Kommentar beziffert die Akzentfarbe nicht mehr.'); |
| 201 | } |
| 202 | |
| 203 | const hex = `#${treffer[1]}`; |
| 204 | expect(hex).toBe(pflicht(HOCHKONTRAST, 'farbe-akzent')); |
| 205 | expect( |
| 206 | Math.abs( |
| 207 | kontrast(hex, pflicht(HOCHKONTRAST, 'farbe-grund')) - Number(treffer[2].replace(',', '.')), |
| 208 | ), |
| 209 | ).toBeLessThanOrEqual(0.05); |
| 210 | }); |
| 211 | }); |
| 212 | |
| 213 | // ─── Die Ziele aus dem Dateikopf gelten in jedem Thema ────────────────── |
| 214 | |
| 215 | describe('Die Kontrastziele des Token-Systems', () => { |
| 216 | const ALLE_THEMEN = [ |
| 217 | ['hell', HELL], |
| 218 | ['dunkel', DUNKEL], |
| 219 | ['hochkontrast', HOCHKONTRAST], |
| 220 | ] as const; |
| 221 | |
| 222 | it.each(ALLE_THEMEN)('Thema %s: Fließtextfarben erreichen 7:1 (AAA)', (_thema, farben) => { |
| 223 | const grund = pflicht(farben, 'farbe-grund'); |
| 224 | for (const variable of ['farbe-text', 'farbe-text-leise', 'farbe-akzent']) { |
| 225 | expect(kontrast(pflicht(farben, variable), grund), `--${variable}`).toBeGreaterThanOrEqual(7); |
| 226 | } |
| 227 | }); |
| 228 | |
| 229 | it.each(ALLE_THEMEN)( |
| 230 | 'Thema %s: Statusfarben erreichen 7:1 – sie tragen Text', |
| 231 | (_thema, farben) => { |
| 232 | /* Erfolg, Warnung und Fehler stehen als Schriftfarbe im Fließtext |
| 233 | (lernen.css, pruefung.css), nicht nur auf Abzeichen – für sie gilt |
| 234 | deshalb das Fließtextziel, nicht das UI-Minimum. */ |
| 235 | const grund = pflicht(farben, 'farbe-grund'); |
| 236 | for (const variable of ['farbe-erfolg', 'farbe-warnung', 'farbe-fehler']) { |
| 237 | expect(kontrast(pflicht(farben, variable), grund), `--${variable}`).toBeGreaterThanOrEqual( |
| 238 | 7, |
| 239 | ); |
| 240 | } |
| 241 | }, |
| 242 | ); |
| 243 | |
| 244 | it.each(THEMEN)( |
| 245 | 'Thema %s: der kräftige Rahmen erreicht 3:1 (Non-text Contrast)', |
| 246 | (_thema, _grundHex, farben) => { |
| 247 | expect( |
| 248 | kontrast(pflicht(farben, 'farbe-rahmen-kraeftig'), pflicht(farben, 'farbe-grund')), |
| 249 | ).toBeGreaterThanOrEqual(3); |
| 250 | }, |
| 251 | ); |
| 252 | |
| 253 | it('nennt in shared/theme.ts dieselben Grundfarben wie hier', () => { |
| 254 | /* |
| 255 | `startfarbeFuer` liefert die Fensterfarbe für den Augenblick vor dem |
| 256 | ersten Frame. Sie steht zwangsläufig ein zweites Mal in TypeScript: Der |
| 257 | Hauptprozess setzt sie, bevor es ein Dokument gibt, das dieses |
| 258 | Stilblatt laden könnte. Eine Kopie, die niemand vergleicht, läuft |
| 259 | auseinander – dann blitzte beim Start ein Farbton auf, den es in der |
| 260 | Anwendung gar nicht gibt. |
| 261 | */ |
| 262 | const ohneSystemsignale: SystemAnzeigeSignale = { |
| 263 | bevorzugtDunkel: false, |
| 264 | erzwungeneFarben: false, |
| 265 | bevorzugtMehrKontrast: false, |
| 266 | }; |
| 267 | |
| 268 | for (const [thema, farben] of [ |
| 269 | ['hell', HELL], |
| 270 | ['dunkel', DUNKEL], |
| 271 | ['hochkontrast', HOCHKONTRAST], |
| 272 | ] as const) { |
| 273 | expect(startfarbeFuer(thema, ohneSystemsignale), `Startfarbe für ${thema}`).toBe( |
| 274 | pflicht(farben, 'farbe-grund'), |
| 275 | ); |
| 276 | } |
| 277 | }); |
| 278 | |
| 279 | it('führt die Systemvorgabe „dunkel“ mit derselben Palette wie das manuelle Thema', () => { |
| 280 | /* Die dunkle Palette steht zweimal in der Datei – einmal hinter |
| 281 | `data-thema='dunkel'`, einmal hinter `prefers-color-scheme`. Läuft |
| 282 | eine Kopie davon, gälten alle Messungen nur noch für die andere. */ |
| 283 | expect(Object.fromEntries(DUNKEL_SYSTEM)).toEqual(Object.fromEntries(DUNKEL)); |
| 284 | }); |
| 285 | }); |
| 286 | |
| 287 | /* ======================================================================= |
| 288 | Die eine Zusicherung, die keine Zahl ist: „nur dekorativ“ |
| 289 | |
| 290 | `--farbe-rahmen` misst 1,77:1 gegen den hellen Grund. Das ist erlaubt, |
| 291 | solange der Token Karten begrenzt, Abschnitte trennt und Tabellenzeilen |
| 292 | unterlegt – Zierrat im Sinne von WCAG 1.4.11, für den kein Mindestwert |
| 293 | gilt. Zeichnete er dagegen die sichtbare Grenze eines BEDIENELEMENTS, |
| 294 | verlangte dieselbe Erfolgsbedingung dort 3:1, und der Klammerzusatz in |
| 295 | tokens.css wäre schlicht falsch. |
| 296 | |
| 297 | Die vorige Welle hat das von Hand stichprobenartig durchgesehen. Eine |
| 298 | Stichprobe ist keine Zusicherung: Der nächste Rahmen, der an einem Knopf |
| 299 | landet, fiele niemandem auf. Deshalb wird hier gerechnet statt geglaubt – |
| 300 | über alle Stilblätter und gegen die Elemente, auf denen die betroffenen |
| 301 | Klassen im JSX tatsächlich sitzen. |
| 302 | |
| 303 | Gefunden hat diese Rechnung beim ersten Lauf `.profilfrage__wahl`: ein |
| 304 | `label`, das den Radioknopf der Startfrage als anklickbare Karte umfasst |
| 305 | (`cursor: pointer`, die ganze Fläche schaltet). Sein Rahmen IST die Grenze |
| 306 | des Bedienelements. Behoben durch --farbe-rahmen-kraeftig, nicht durch |
| 307 | eine mildere Regel. |
| 308 | ======================================================================= */ |
| 309 | |
| 310 | const STILE = join(wurzel, 'src', 'renderer', 'src', 'styles'); |
| 311 | const RENDERER = join(wurzel, 'src', 'renderer', 'src'); |
| 312 | |
| 313 | function dateienUnter(verzeichnis: string, endung: string): readonly string[] { |
| 314 | const gefunden: string[] = []; |
| 315 | for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) { |
| 316 | const pfad = join(verzeichnis, eintrag.name); |
| 317 | if (eintrag.isDirectory()) { |
| 318 | gefunden.push(...dateienUnter(pfad, endung)); |
| 319 | } else if (eintrag.name.endsWith(endung)) { |
| 320 | gefunden.push(pfad); |
| 321 | } |
| 322 | } |
| 323 | return gefunden; |
| 324 | } |
| 325 | |
| 326 | // ─── Was im JSX ein Bedienelement ist ────────────────────────────────── |
| 327 | |
| 328 | /** |
| 329 | * Elemente, deren sichtbare Grenze WCAG 1.4.11 unterstellt ist. |
| 330 | * |
| 331 | * `label` steht bewusst dabei: Ein Beschriftungselement mit eigenem Rahmen |
| 332 | * ist die Trefffläche seines Feldes – ein Klick darauf schaltet. Nicht dabei |
| 333 | * sind `fieldset` und `details`: Sie fassen zusammen, statt zu schalten; das |
| 334 | * Schaltbare darin (`summary`, die Felder) trägt seine eigene Grenze. |
| 335 | */ |
| 336 | const BEDIENELEMENTE: ReadonlySet<string> = new Set([ |
| 337 | 'a', |
| 338 | 'button', |
| 339 | 'input', |
| 340 | 'label', |
| 341 | 'option', |
| 342 | 'select', |
| 343 | 'summary', |
| 344 | 'textarea', |
| 345 | ]); |
| 346 | |
| 347 | const BEDIENROLLEN: ReadonlySet<string> = new Set([ |
| 348 | 'button', |
| 349 | 'checkbox', |
| 350 | 'combobox', |
| 351 | 'link', |
| 352 | 'menuitem', |
| 353 | 'menuitemcheckbox', |
| 354 | 'menuitemradio', |
| 355 | 'option', |
| 356 | 'radio', |
| 357 | 'searchbox', |
| 358 | 'slider', |
| 359 | 'spinbutton', |
| 360 | 'switch', |
| 361 | 'tab', |
| 362 | 'textbox', |
| 363 | 'treeitem', |
| 364 | ]); |
| 365 | |
| 366 | interface Marke { |
| 367 | /** Kleingeschriebener HTML-Elementname, etwa `label`. */ |
| 368 | readonly tag: string; |
| 369 | /** Der Attributtext des öffnenden Tags, roh. */ |
| 370 | readonly attribute: string; |
| 371 | readonly datei: string; |
| 372 | } |
| 373 | |
| 374 | /** |
| 375 | * Findet die öffnenden HTML-Tags einer TSX-Datei mit ihrem Attributtext. |
| 376 | * |
| 377 | * Kein Parser, aber auch keine Regex über das ganze Tag: Attributwerte |
| 378 | * enthalten Pfeilfunktionen (`() => …`), und ein `>` darin beendet kein Tag. |
| 379 | * Deshalb wird ab dem Tagnamen zeichenweise gezählt – ein `>` schließt nur |
| 380 | * außerhalb von `{}` und außerhalb von Zeichenketten. |
| 381 | */ |
| 382 | function markenIn(quelle: string, datei: string): readonly Marke[] { |
| 383 | const gefunden: Marke[] = []; |
| 384 | for (const anfang of quelle.matchAll(/<([a-z][a-z0-9]*)(?=[\s/>])/gu)) { |
| 385 | const tag = anfang[1] ?? ''; |
| 386 | const nachDemNamen = anfang.index + anfang[0].length; |
| 387 | let i = nachDemNamen; |
| 388 | let tiefe = 0; |
| 389 | let anfuehrung: string | null = null; |
| 390 | for (; i < quelle.length; i += 1) { |
| 391 | const z = quelle[i] ?? ''; |
| 392 | if (anfuehrung !== null) { |
| 393 | if (z === anfuehrung) { |
| 394 | anfuehrung = null; |
| 395 | } |
| 396 | continue; |
| 397 | } |
| 398 | if (z === '"' || z === "'" || z === '`') { |
| 399 | anfuehrung = z; |
| 400 | } else if (z === '{') { |
| 401 | tiefe += 1; |
| 402 | } else if (z === '}') { |
| 403 | tiefe -= 1; |
| 404 | } else if (z === '>' && tiefe === 0) { |
| 405 | break; |
| 406 | } |
| 407 | } |
| 408 | gefunden.push({ tag, attribute: quelle.slice(nachDemNamen, i), datei }); |
| 409 | } |
| 410 | return gefunden; |
| 411 | } |
| 412 | |
| 413 | /** Die Klassennamen aus `className="…"` bzw. `className={…}` eines Tags. */ |
| 414 | function klassenIn(attribute: string): readonly string[] { |
| 415 | const start = /className\s*=\s*/u.exec(attribute); |
| 416 | if (!start) { |
| 417 | return []; |
| 418 | } |
| 419 | const ab = attribute.slice(start.index + start[0].length); |
| 420 | let ausdruck: string; |
| 421 | if (ab.startsWith('{')) { |
| 422 | let tiefe = 0; |
| 423 | let bis = 0; |
| 424 | for (; bis < ab.length; bis += 1) { |
| 425 | if (ab[bis] === '{') { |
| 426 | tiefe += 1; |
| 427 | } else if (ab[bis] === '}') { |
| 428 | tiefe -= 1; |
| 429 | if (tiefe === 0) { |
| 430 | break; |
| 431 | } |
| 432 | } |
| 433 | } |
| 434 | ausdruck = ab.slice(0, bis + 1); |
| 435 | } else { |
| 436 | ausdruck = /^(["'])(?:(?!\1).)*\1/su.exec(ab)?.[0] ?? ''; |
| 437 | } |
| 438 | /* Aus dem Ausdruck zählt nur, was in Anführungszeichen steht – Bedingungen |
| 439 | und Variablennamen nicht. */ |
| 440 | return zeichenketten(ausdruck) |
| 441 | .flatMap((stueck) => stueck.split(/\s+/u)) |
| 442 | .filter((name) => /^[A-Za-z][\w-]*$/u.test(name)); |
| 443 | } |
| 444 | |
| 445 | /** |
| 446 | * Die Zeichenketten eines JSX-Ausdrucks, Schablonenliterale eingeschlossen. |
| 447 | * |
| 448 | * Ohne diesen Durchgang bliebe `` `fussbereich${knapp ? ' …--knapp' : ''}` `` |
| 449 | * unlesbar, und die Klasse fiele stillschweigend aus der Prüfung – genau die |
| 450 | * Lücke, die die Zuordnungszusicherung oben aufdeckt. |
| 451 | */ |
| 452 | function zeichenketten(ausdruck: string): readonly string[] { |
| 453 | const stuecke: string[] = []; |
| 454 | let i = 0; |
| 455 | while (i < ausdruck.length) { |
| 456 | const z = ausdruck[i]; |
| 457 | if (z === '"' || z === "'") { |
| 458 | const ende = ausdruck.indexOf(z, i + 1); |
| 459 | if (ende < 0) { |
| 460 | break; |
| 461 | } |
| 462 | stuecke.push(ausdruck.slice(i + 1, ende)); |
| 463 | i = ende + 1; |
| 464 | continue; |
| 465 | } |
| 466 | if (z !== '`') { |
| 467 | i += 1; |
| 468 | continue; |
| 469 | } |
| 470 | let j = i + 1; |
| 471 | let fest = ''; |
| 472 | while (j < ausdruck.length && ausdruck[j] !== '`') { |
| 473 | if (ausdruck[j] === '$' && ausdruck[j + 1] === '{') { |
| 474 | stuecke.push(fest); |
| 475 | fest = ''; |
| 476 | let tiefe = 1; |
| 477 | let k = j + 2; |
| 478 | for (; k < ausdruck.length && tiefe > 0; k += 1) { |
| 479 | if (ausdruck[k] === '{') { |
| 480 | tiefe += 1; |
| 481 | } else if (ausdruck[k] === '}') { |
| 482 | tiefe -= 1; |
| 483 | } |
| 484 | } |
| 485 | /* Die Einsetzung ist wieder ein Ausdruck – dieselbe Behandlung. */ |
| 486 | stuecke.push(...zeichenketten(ausdruck.slice(j + 2, k - 1))); |
| 487 | j = k; |
| 488 | continue; |
| 489 | } |
| 490 | fest += ausdruck[j] ?? ''; |
| 491 | j += 1; |
| 492 | } |
| 493 | stuecke.push(fest); |
| 494 | i = j + 1; |
| 495 | } |
| 496 | return stuecke; |
| 497 | } |
| 498 | |
| 499 | const KLASSENVERZEICHNIS: ReadonlyMap<string, readonly Marke[]> = (() => { |
| 500 | const verzeichnis = new Map<string, Marke[]>(); |
| 501 | for (const datei of dateienUnter(RENDERER, '.tsx')) { |
| 502 | for (const marke of markenIn(readFileSync(datei, 'utf8'), datei)) { |
| 503 | for (const klasse of klassenIn(marke.attribute)) { |
| 504 | const bisher = verzeichnis.get(klasse); |
| 505 | if (bisher) { |
| 506 | bisher.push(marke); |
| 507 | } else { |
| 508 | verzeichnis.set(klasse, [marke]); |
| 509 | } |
| 510 | } |
| 511 | } |
| 512 | } |
| 513 | return verzeichnis; |
| 514 | })(); |
| 515 | |
| 516 | function istBedienelement(marke: Marke): boolean { |
| 517 | if (BEDIENELEMENTE.has(marke.tag)) { |
| 518 | return true; |
| 519 | } |
| 520 | const rolle = /\brole\s*=\s*["']([a-z]+)["']/u.exec(marke.attribute)?.[1]; |
| 521 | if (rolle !== undefined && BEDIENROLLEN.has(rolle)) { |
| 522 | return true; |
| 523 | } |
| 524 | if (/\bonClick\s*=/u.test(marke.attribute)) { |
| 525 | return true; |
| 526 | } |
| 527 | /* `tabIndex={-1}` macht eine Überschrift anspringbar, kein Bedienelement; |
| 528 | eine 0 dagegen setzt das Element in die Tabulatorreihenfolge. */ |
| 529 | return /\btabIndex\s*=\s*\{\s*0\s*\}/u.test(marke.attribute); |
| 530 | } |
| 531 | |
| 532 | // ─── Wo --farbe-rahmen im Stilblatt eine Grenze zeichnet ──────────────── |
| 533 | |
| 534 | interface Fundstelle { |
| 535 | readonly datei: string; |
| 536 | readonly selektor: string; |
| 537 | readonly deklaration: string; |
| 538 | } |
| 539 | |
| 540 | /** Eigenschaften, die eine sichtbare Grenze zeichnen. */ |
| 541 | const GRENZEIGENSCHAFT = |
| 542 | /^(?:border|outline|column-rule)(?:-[a-z-]+)?$|^box-shadow$|^(?:-webkit-)?text-decoration-color$/u; |
| 543 | |
| 544 | function fundstellen(): readonly Fundstelle[] { |
| 545 | const gefunden: Fundstelle[] = []; |
| 546 | for (const datei of dateienUnter(STILE, '.css')) { |
| 547 | const quelle = readFileSync(datei, 'utf8').replace(/\/\*[\s\S]*?\*\//gu, ''); |
| 548 | /* Blattregeln: ein Rumpf ohne weitere geschweifte Klammern. Verschachtelte |
| 549 | At-Regeln (`@media`) scheitern an diesem Muster und geben dabei ihre |
| 550 | inneren Regeln mit dem richtigen Selektor frei. */ |
| 551 | for (const regel of quelle.matchAll(/([^{}]+)\{([^{}]*)\}/gu)) { |
| 552 | const rumpf = regel[2] ?? ''; |
| 553 | for (const deklaration of rumpf.split(';')) { |
| 554 | const doppelpunkt = deklaration.indexOf(':'); |
| 555 | if (doppelpunkt < 0) { |
| 556 | continue; |
| 557 | } |
| 558 | const eigenschaft = deklaration.slice(0, doppelpunkt).trim(); |
| 559 | const wert = deklaration.slice(doppelpunkt + 1); |
| 560 | if (!GRENZEIGENSCHAFT.test(eigenschaft) || !wert.includes('var(--farbe-rahmen)')) { |
| 561 | continue; |
| 562 | } |
| 563 | for (const selektor of (regel[1] ?? '').split(',')) { |
| 564 | if (selektor.trim() !== '') { |
| 565 | gefunden.push({ |
| 566 | datei, |
| 567 | selektor: selektor.trim().replace(/\s+/gu, ' '), |
| 568 | deklaration: deklaration.trim().replace(/\s+/gu, ' '), |
| 569 | }); |
| 570 | } |
| 571 | } |
| 572 | } |
| 573 | } |
| 574 | } |
| 575 | return gefunden; |
| 576 | } |
| 577 | |
| 578 | /** |
| 579 | * Der rechte Teil eines Selektors – das Element, das die Regel bemalt. |
| 580 | * `.a .b:hover` betrifft `.b`, nicht `.a`. |
| 581 | */ |
| 582 | function schluesselteil(selektor: string): string { |
| 583 | const teile = selektor.split(/[\s>+~]+/u).filter((t) => t !== ''); |
| 584 | const letzter = teile[teile.length - 1] ?? ''; |
| 585 | return letzter.split(':')[0] ?? ''; |
| 586 | } |
| 587 | |
| 588 | const FUNDSTELLEN = fundstellen(); |
| 589 | |
| 590 | describe('Die Zusicherung „nur dekorativ“ an --farbe-rahmen', () => { |
| 591 | it('findet den Token überhaupt in den Stilblättern', () => { |
| 592 | /* Eine leere Prüfmenge bestünde jede Zusicherung. Der Token wird in |
| 593 | mehreren Dateien benutzt; sinkt die Zahl auf null, ist nicht der |
| 594 | Rahmen sauber, sondern dieses Muster kaputt. */ |
| 595 | expect(FUNDSTELLEN.length).toBeGreaterThan(10); |
| 596 | expect(new Set(FUNDSTELLEN.map((f) => f.datei)).size).toBeGreaterThan(1); |
| 597 | }); |
| 598 | |
| 599 | it('ordnet jede betroffene Klasse einem Element im JSX zu', () => { |
| 600 | /* Ohne diese Zusicherung liefe die eigentliche Prüfung ins Leere, sobald |
| 601 | eine Klasse umbenannt wird: Unbekannt hieße dann stillschweigend |
| 602 | „unbedenklich“. */ |
| 603 | const unbekannt = new Set<string>(); |
| 604 | for (const stelle of FUNDSTELLEN) { |
| 605 | for (const klasse of schluesselteil(stelle.selektor).matchAll(/\.([A-Za-z][\w-]*)/gu)) { |
| 606 | const name = klasse[1] ?? ''; |
| 607 | if (!KLASSENVERZEICHNIS.has(name)) { |
| 608 | unbekannt.add(`${name} (${stelle.selektor} in ${stelle.datei})`); |
| 609 | } |
| 610 | } |
| 611 | } |
| 612 | expect([...unbekannt]).toEqual([]); |
| 613 | }); |
| 614 | |
| 615 | it('zeichnet an keiner Stelle die Grenze eines Bedienelements', () => { |
| 616 | const verstoesse: string[] = []; |
| 617 | for (const stelle of FUNDSTELLEN) { |
| 618 | const kern = schluesselteil(stelle.selektor); |
| 619 | |
| 620 | const element = /^[a-z][a-z0-9]*/u.exec(kern)?.[0]; |
| 621 | if (element !== undefined && BEDIENELEMENTE.has(element)) { |
| 622 | verstoesse.push( |
| 623 | `${stelle.selektor} { ${stelle.deklaration} } – <${element}> in ${stelle.datei}`, |
| 624 | ); |
| 625 | continue; |
| 626 | } |
| 627 | |
| 628 | for (const klasse of kern.matchAll(/\.([A-Za-z][\w-]*)/gu)) { |
| 629 | const name = klasse[1] ?? ''; |
| 630 | for (const marke of KLASSENVERZEICHNIS.get(name) ?? []) { |
| 631 | if (istBedienelement(marke)) { |
| 632 | verstoesse.push( |
| 633 | `${stelle.selektor} { ${stelle.deklaration} } in ${stelle.datei} – ` + |
| 634 | `.${name} sitzt auf <${marke.tag}> in ${marke.datei}`, |
| 635 | ); |
| 636 | } |
| 637 | } |
| 638 | } |
| 639 | } |
| 640 | |
| 641 | expect( |
| 642 | verstoesse, |
| 643 | 'WCAG 1.4.11 verlangt an Bedienelementen 3:1 – hier gehört --farbe-rahmen-kraeftig hin.', |
| 644 | ).toEqual([]); |
| 645 | }); |
| 646 | }); |