waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests textabstand.test.ts
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Einstellbare Textabstände (WCAG 1.4.12). |
| 4 | * |
| 5 | * **Der Anlass.** Das Kriterium verlangt, dass sich Text mit weiterem Zeilen-, |
| 6 | * Wort- und Zeichenabstand darstellen lässt, ohne dass Inhalt verlorengeht. Im |
| 7 | * Browser erfüllt man das, indem nutzereigene Stilblätter toleriert werden; |
| 8 | * der eigene Prüfplan (Z. 177) stellt den Fall mit einem |
| 9 | * Entwicklerwerkzeug-Stilblatt nach. |
| 10 | * |
| 11 | * In der ausgelieferten Anwendung gibt es beides nicht – weder Erweiterungen |
| 12 | * noch Entwicklerwerkzeuge, die sollen laut `docs/stand.md` sogar aus dem |
| 13 | * Auslieferungsmenü verschwinden. Der Einzige, der die Abstände ändern kann, |
| 14 | * ist die Anwendung selbst, und sie bot es bis 0.22.0 nicht an. Formal ist das |
| 15 | * kein Verstoß (in einer geschlossenen Umgebung misst 1.4.12 die Toleranz |
| 16 | * gegenüber Overrides, die es hier nicht geben kann) – praktisch ist es eine |
| 17 | * Lücke bei genau denen, für die das Kriterium geschrieben wurde. |
| 18 | */ |
| 19 | |
| 20 | import { readFileSync } from 'node:fs'; |
| 21 | import { join } from 'node:path'; |
| 22 | import { fileURLToPath } from 'node:url'; |
| 23 | |
| 24 | import { describe, expect, it } from 'vitest'; |
| 25 | |
| 26 | import { |
| 27 | TEXTABSTAENDE, |
| 28 | TEXTABSTAND_BEZEICHNUNG, |
| 29 | TEXTABSTAND_ERKLAERUNG, |
| 30 | TEXTABSTAND_STANDARD, |
| 31 | istTextabstand, |
| 32 | textabstandAnsage, |
| 33 | textabstandBereinigen, |
| 34 | } from '../src/shared/textabstand'; |
| 35 | |
| 36 | const wurzel = fileURLToPath(new URL('..', import.meta.url)); |
| 37 | |
| 38 | function stil(datei: string): string { |
| 39 | return readFileSync(join(wurzel, 'src', 'renderer', 'src', 'styles', datei), 'utf8'); |
| 40 | } |
| 41 | |
| 42 | const TOKENS_CSS = stil('tokens.css'); |
| 43 | const GLOBAL_CSS = stil('global.css'); |
| 44 | |
| 45 | describe('Die Stufen', () => { |
| 46 | it('beginnt bei der gewohnten Darstellung', () => { |
| 47 | /* Wer nichts einstellt, soll nichts merken. Eine Anwendung, die von sich |
| 48 | aus weiter setzt, hätte eine Meinung, die ihr nicht zusteht. */ |
| 49 | expect(TEXTABSTAND_STANDARD).toBe('normal'); |
| 50 | expect(TEXTABSTAENDE[0]).toBe('normal'); |
| 51 | }); |
| 52 | |
| 53 | it('benennt und erklärt jede Stufe', () => { |
| 54 | for (const stufe of TEXTABSTAENDE) { |
| 55 | expect(TEXTABSTAND_BEZEICHNUNG[stufe].trim().length).toBeGreaterThan(0); |
| 56 | expect(TEXTABSTAND_ERKLAERUNG[stufe].trim().length).toBeGreaterThan(0); |
| 57 | } |
| 58 | }); |
| 59 | |
| 60 | it('macht aus Unbrauchbarem die Vorgabe', () => { |
| 61 | /* Ein Wert aus einer früheren Fassung oder von Hand in die Datei |
| 62 | geschrieben darf die Darstellung nicht in einen Zustand bringen, den |
| 63 | es nicht gibt. */ |
| 64 | expect(textabstandBereinigen('gibt-es-nicht')).toBe(TEXTABSTAND_STANDARD); |
| 65 | expect(textabstandBereinigen(undefined)).toBe(TEXTABSTAND_STANDARD); |
| 66 | expect(textabstandBereinigen(42)).toBe(TEXTABSTAND_STANDARD); |
| 67 | expect(textabstandBereinigen('weit')).toBe('weit'); |
| 68 | }); |
| 69 | |
| 70 | it('erkennt gültige Stufen', () => { |
| 71 | expect(istTextabstand('sehr-weit')).toBe(true); |
| 72 | expect(istTextabstand('mittel')).toBe(false); |
| 73 | }); |
| 74 | |
| 75 | it('sagt die gewählte Stufe an', () => { |
| 76 | expect(textabstandAnsage('weit')).toContain('Weit'); |
| 77 | }); |
| 78 | }); |
| 79 | |
| 80 | describe('Das Stilblatt setzt die Stufen wirklich um', () => { |
| 81 | it('führt für jede Stufe außer der Vorgabe einen eigenen Block', () => { |
| 82 | for (const stufe of TEXTABSTAENDE.filter((s) => s !== TEXTABSTAND_STANDARD)) { |
| 83 | expect(TOKENS_CSS, `Stufe ohne Regelblock: ${stufe}`).toContain( |
| 84 | `:root[data-textabstand='${stufe}']`, |
| 85 | ); |
| 86 | } |
| 87 | }); |
| 88 | |
| 89 | it('vergrößert Zeilen-, Wort- und Zeichenabstand mit jeder Stufe', () => { |
| 90 | /* Drei Stufen, die sich nur im Namen unterscheiden, wären eine Attrappe |
| 91 | einer Einstellung. */ |
| 92 | const werte = (stufe: string): Record<string, string> => { |
| 93 | const anfang = TOKENS_CSS.indexOf(`:root[data-textabstand='${stufe}']`); |
| 94 | const block = TOKENS_CSS.slice(anfang, TOKENS_CSS.indexOf('}', anfang)); |
| 95 | return Object.fromEntries( |
| 96 | [...block.matchAll(/--([a-z-]+):\s*([\d.]+)(em|rem)?/gu)].map((t) => [ |
| 97 | t[1] ?? '', |
| 98 | t[2] ?? '', |
| 99 | ]), |
| 100 | ); |
| 101 | }; |
| 102 | |
| 103 | const weit = werte('weit'); |
| 104 | const sehrWeit = werte('sehr-weit'); |
| 105 | |
| 106 | for (const eigenschaft of ['zeilenhoehe-text', 'wortabstand', 'zeichenabstand']) { |
| 107 | const a = Number(weit[eigenschaft]); |
| 108 | const b = Number(sehrWeit[eigenschaft]); |
| 109 | expect(a, `${eigenschaft} fehlt in „weit“`).toBeGreaterThan(0); |
| 110 | expect(b, `${eigenschaft}: „sehr weit“ ist nicht weiter als „weit“`).toBeGreaterThan(a); |
| 111 | } |
| 112 | }); |
| 113 | |
| 114 | it('erreicht die Messwerte des Kriteriums', () => { |
| 115 | /* 1.4.12 nennt Wortabstand 0,16 em und Zeichenabstand 0,12 em als |
| 116 | Mindestmaß. Die weiteste Stufe muss dort ankommen – sonst wäre die |
| 117 | Einstellung eine Geste. */ |
| 118 | const block = TOKENS_CSS.slice(TOKENS_CSS.indexOf(":root[data-textabstand='sehr-weit']")); |
| 119 | const wort = /--wortabstand:\s*([\d.]+)em/u.exec(block); |
| 120 | const zeichen = /--zeichenabstand:\s*([\d.]+)em/u.exec(block); |
| 121 | |
| 122 | expect(Number(wort?.[1])).toBeGreaterThanOrEqual(0.16); |
| 123 | expect(Number(zeichen?.[1])).toBeGreaterThanOrEqual(0.12); |
| 124 | }); |
| 125 | |
| 126 | it('reicht Wort- und Zeichenabstand an den Textkörper durch', () => { |
| 127 | /* Ohne diese beiden Zeilen wären die Variablen gesetzt und wirkungslos. */ |
| 128 | const body = GLOBAL_CSS.slice( |
| 129 | GLOBAL_CSS.indexOf('body {'), |
| 130 | GLOBAL_CSS.indexOf('}', GLOBAL_CSS.indexOf('body {')), |
| 131 | ); |
| 132 | expect(body).toMatch(/word-spacing:\s*var\(--wortabstand\)/u); |
| 133 | expect(body).toMatch(/letter-spacing:\s*var\(--zeichenabstand\)/u); |
| 134 | }); |
| 135 | |
| 136 | it('lässt den Feinsatz der Überschriften nicht gegen die Einstellung stehen', () => { |
| 137 | /* Der negative Zeichenabstand von h1 und h2 steht hart im Stilblatt. |
| 138 | Bliebe er unangetastet, zöge die Einstellung überall – nur nicht in |
| 139 | den Überschriften, wo mehr Luft am meisten hilft. */ |
| 140 | for (const ueberschrift of ['h1 {', 'h2 {']) { |
| 141 | const anfang = GLOBAL_CSS.indexOf(ueberschrift); |
| 142 | const block = GLOBAL_CSS.slice(anfang, GLOBAL_CSS.indexOf('}', anfang)); |
| 143 | expect(block, `${ueberschrift} übersteuert den Zeichenabstand nicht`).toMatch( |
| 144 | /letter-spacing:\s*max\([^)]*var\(--zeichenabstand\)\)/u, |
| 145 | ); |
| 146 | } |
| 147 | }); |
| 148 | |
| 149 | it('gibt der Vorgabe eine rechenbare Länge statt „normal“', () => { |
| 150 | /* `max(-0.015em, normal)` wäre ungültig – die ganze Angabe fiele aus, |
| 151 | und der Feinsatz der Überschriften verschwände stillschweigend. */ |
| 152 | const wurzelblock = TOKENS_CSS.slice(TOKENS_CSS.indexOf(':root {')); |
| 153 | expect(wurzelblock).toMatch(/--zeichenabstand:\s*0em/u); |
| 154 | expect(wurzelblock).toMatch(/--wortabstand:\s*0em/u); |
| 155 | }); |
| 156 | }); |