waffensachkunde

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

/ app tests token-kontraste.test.ts

22,7 KB Rohdatei
app/tests/token-kontraste.test.ts — 646 Zeilen
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 });