waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests datenschutz.test.ts
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Die Datenschutzerklärung in der Anwendung. |
| 4 | * |
| 5 | * ## Warum diese Datei die wichtigste Wache dieses Bereichs trägt |
| 6 | * |
| 7 | * Der Fehler, der hier droht, ist nicht „die Erklärung fehlt“. Er ist: **es |
| 8 | * gibt sie zweimal, und die zweite ist die falsche.** Der Fall ist bereits |
| 9 | * eingetreten, ohne dass jemand es bemerkt hätte: Am 30.08.2026 stand die |
| 10 | * veröffentlichte Seite auf Fassung 1.1, während `docs/datenschutz.md` bei |
| 11 | * 1.5 war — vier Revisionen Abstand in vier Tagen, bei vorhandenem Erzeuger |
| 12 | * und vorhandener Prüfliste in `docs/veroeffentlichen.md`. Der Erzeuger hatte |
| 13 | * gehalten, was er sollte; ausgelaufen ist der Schritt, den nur ein Mensch |
| 14 | * tut. |
| 15 | * |
| 16 | * Daraus folgt die Regel, die dieser Datei zugrunde liegt und die |
| 17 | * `tests/dokumentation.test.ts` für den Updatebericht schon einmal |
| 18 | * aufgeschrieben hat: **Ein Erzeuger allein reicht nicht. Was zählt, ist der |
| 19 | * Test, der rot wird, wenn er nicht gelaufen ist.** |
| 20 | * |
| 21 | * Deshalb steht die Prüfsumme des Veröffentlichungsteils in der erzeugten |
| 22 | * Datei, und deshalb wird sie hier nachgerechnet. |
| 23 | */ |
| 24 | |
| 25 | import { createHash } from 'node:crypto'; |
| 26 | import { 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 | import { |
| 33 | abschnitte, |
| 34 | type Datenschutzblock, |
| 35 | type Datenschutzerklaerung, |
| 36 | } from '../src/shared/datenschutz'; |
| 37 | |
| 38 | const wurzel = join(fileURLToPath(new URL('..', import.meta.url)), '..'); |
| 39 | |
| 40 | const TRENNER = '---\n---\n'; |
| 41 | |
| 42 | function lies(pfad: string): string { |
| 43 | /* Zeilenenden vereinheitlicht: `.gitattributes` erzwingt LF, aber ein |
| 44 | Arbeitsbaum, der einmal mit CRLF ausgecheckt wird, machte diese Wache |
| 45 | sonst dauerhaft und grundlos rot — und eine grundlos rote Wache wird |
| 46 | abgeschaltet. */ |
| 47 | return readFileSync(join(wurzel, pfad), 'utf8').replace(/\r\n/gu, '\n'); |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Derselbe Schnitt wie in `tools/datenschutz_html.py`. |
| 52 | * |
| 53 | * Einschliesslich des abschliessenden Abschneidens der Leerzeilen dort |
| 54 | * (`strip`) – ohne das käme eine andere Prüfsumme heraus, und die Wache wäre |
| 55 | * dauerhaft rot, ohne dass etwas falsch wäre. |
| 56 | */ |
| 57 | function veroeffentlichungsteil(markdown: string): string { |
| 58 | const stelle = markdown.indexOf(TRENNER); |
| 59 | const teil = stelle === -1 ? markdown : markdown.slice(stelle + TRENNER.length); |
| 60 | return teil.replace(/^\n+/u, '').replace(/\n+$/u, ''); |
| 61 | } |
| 62 | |
| 63 | const quelle = lies('docs/datenschutz.md'); |
| 64 | const erzeugt = JSON.parse(lies('content/datenschutz.json')) as Datenschutzerklaerung; |
| 65 | const html = lies('docs/datenschutz.html'); |
| 66 | |
| 67 | describe('Datenschutzerklärung – die erzeugte Fassung', () => { |
| 68 | it('stammt aus der heutigen Quelle', () => { |
| 69 | const erwartet = createHash('sha256') |
| 70 | .update(veroeffentlichungsteil(quelle), 'utf8') |
| 71 | .digest('hex'); |
| 72 | |
| 73 | expect( |
| 74 | erzeugt.quellpruefsumme, |
| 75 | 'docs/datenschutz.md hat sich geändert, content/datenschutz.json nicht. ' + |
| 76 | 'Neu erzeugen mit: python tools/datenschutz_anwendung.py', |
| 77 | ).toBe(erwartet); |
| 78 | }); |
| 79 | |
| 80 | it('nennt dieselbe Fassung und denselben Stand wie die Quelle', () => { |
| 81 | /* Beide werden aus der Standtabelle gelesen und nicht eingetragen. Eine |
| 82 | Fassungsnummer an zwei Orten ist genau die zweite Wahrheit, um die es |
| 83 | hier geht. */ |
| 84 | const fassung = /^\|\s*\*\*Fassung der Erklärung\*\*\s*\|\s*([^|]+?)\s*\|/mu.exec(quelle); |
| 85 | const stand = /^\|\s*\*\*Stand\*\*\s*\|\s*([^|]+?)\s*\|/mu.exec(quelle); |
| 86 | |
| 87 | expect(fassung?.[1]).toBeDefined(); |
| 88 | expect(stand?.[1]).toBeDefined(); |
| 89 | expect(erzeugt.fassung).toBe(fassung?.[1]); |
| 90 | expect(erzeugt.stand).toBe(stand?.[1]); |
| 91 | }); |
| 92 | |
| 93 | it('enthält den ganzen Veröffentlichungsteil und nicht nur den Anfang', () => { |
| 94 | /* Gegenprobe gegen eine stillschweigend abgeschnittene Umsetzung: Der |
| 95 | Erzeuger bricht bei unbekannten Auszeichnungen ab, aber ein Fehler in |
| 96 | der Blockbildung könnte hinten etwas verlieren, ohne dass jemand es |
| 97 | sähe. Verglichen wird die Zahl der Überschriften. */ |
| 98 | const ueberschriften = veroeffentlichungsteil(quelle) |
| 99 | .split('\n') |
| 100 | .filter((zeile) => /^#{1,3} /u.test(zeile)).length; |
| 101 | |
| 102 | const gebaut = erzeugt.bloecke.filter((block) => block.art === 'ueberschrift').length; |
| 103 | |
| 104 | expect(gebaut, 'Es fehlen Überschriften in der erzeugten Fassung.').toBe(ueberschriften); |
| 105 | expect(gebaut).toBeGreaterThan(20); |
| 106 | }); |
| 107 | |
| 108 | it('führt für jeden Abschnitt ein eindeutiges Sprungziel', () => { |
| 109 | const ziele = abschnitte(erzeugt); |
| 110 | expect(ziele.length).toBeGreaterThan(10); |
| 111 | expect(new Set(ziele.map((ziel) => ziel.kennung)).size).toBe(ziele.length); |
| 112 | for (const ziel of ziele) { |
| 113 | expect(ziel.kennung, `Leeres Sprungziel bei „${ziel.text}“`).not.toBe(''); |
| 114 | } |
| 115 | }); |
| 116 | |
| 117 | it('enthält kein Markup in den Texten', () => { |
| 118 | /* Dieselbe Zusage wie beim Handbuch: Die Blöcke werden als reiner Text |
| 119 | gerendert. Stünde hier eine spitze Klammer oder ein Sternchenpaar, |
| 120 | erschiene es wörtlich auf dem Bildschirm. */ |
| 121 | /* |
| 122 | Kennzeichnungen bleiben ausgenommen. Sie sind der Ort, an dem das |
| 123 | Dokument über Auszeichnung **spricht**: Abschnitt 8 erklärt, die Adresse |
| 124 | stehe „technisch in einem `<code>`-Element, nicht in einem Link“. Der |
| 125 | Satz ist richtig und soll genau so dastehen; ihn als Markup-Rückstand zu |
| 126 | werten wäre der erste Fehlalarm, nach dem eine Wache abgeschaltet wird. |
| 127 | */ |
| 128 | const sichtbar = (teile: readonly { art: string; text: string }[]): string[] => |
| 129 | teile.filter((teil) => teil.art !== 'kennzeichnung').map((teil) => teil.text); |
| 130 | |
| 131 | const texte: string[] = []; |
| 132 | for (const block of erzeugt.bloecke) { |
| 133 | if (block.art === 'ueberschrift' || block.art === 'code') { |
| 134 | texte.push(block.text); |
| 135 | } else if (block.art === 'absatz') { |
| 136 | texte.push(...sichtbar(block.teile)); |
| 137 | } else if (block.art === 'tabelle') { |
| 138 | texte.push(...block.kopf); |
| 139 | texte.push(...sichtbar(block.zeilen.flat(2))); |
| 140 | } else { |
| 141 | texte.push(...sichtbar(block.punkte.flat())); |
| 142 | } |
| 143 | } |
| 144 | const gesamt = texte.join('\n'); |
| 145 | |
| 146 | /* |
| 147 | Gesucht werden die Elemente, die der Umsetzer erzeugt – nicht jede |
| 148 | spitze Klammer. Das Dokument enthält echte Platzhalter in spitzen |
| 149 | Klammern („C:\\Users\\<Ihr Name>\\AppData“, |
| 150 | „Lernstand-selbsttaetig-<Zeitstempel>.wsklernstand“), und die sollen |
| 151 | genau so dastehen. Eine Wache, die daran anschlägt, wird nach dem |
| 152 | zweiten Fehlalarm abgeschaltet. |
| 153 | */ |
| 154 | const tags = /<\/?(?:p|ul|li|h[1-6]|strong|code|pre|a|em|br|div|span|table)\b/iu; |
| 155 | |
| 156 | expect(gesamt, 'Ein Element ist als Text in die Blöcke geraten.').not.toMatch(tags); |
| 157 | expect(gesamt).not.toMatch(/\*\*[^*]+\*\*/u); |
| 158 | /* Gegenprobe: Der Text ist tatsächlich da, und die Wache trifft, wenn |
| 159 | etwas dasteht. */ |
| 160 | expect(gesamt.length).toBeGreaterThan(10_000); |
| 161 | expect(tags.test('Ein <strong>fetter</strong> Text.')).toBe(true); |
| 162 | expect(tags.test('Der Pfad C:\\Users\\<Ihr Name>\\AppData.')).toBe(false); |
| 163 | }); |
| 164 | |
| 165 | it('trägt die beiden Tabellen als Tabellen, nicht als Fließtext', () => { |
| 166 | /* |
| 167 | Der Anlass: Bis zum 01.09.2026 rechnete der Umsetzer Tabellen in |
| 168 | Aufzählungen um. Als die HTML-Fassung Tabellen lernte, brach er ab — |
| 169 | und weil die erzeugte Datei danach nicht neu geschrieben wurde, fiel |
| 170 | es erst auf, als die Erklärung wirklich nachzuführen war. |
| 171 | |
| 172 | Für einen Bildschirmleser ist der Unterschied nicht kosmetisch: Die |
| 173 | Nachprüfmatrix in Abschnitt 2 hat drei Spalten, und ohne |
| 174 | Spaltenzuordnung sind ihre achtzehn Zellen achtzehn Bruchstücke. |
| 175 | */ |
| 176 | const tabellen = erzeugt.bloecke.filter((block) => block.art === 'tabelle'); |
| 177 | expect(tabellen).toHaveLength(2); |
| 178 | |
| 179 | for (const tabelle of tabellen) { |
| 180 | expect(tabelle.zeilen.length).toBeGreaterThan(0); |
| 181 | /* Jede Zeile gleich breit – sonst zeigt eine Kopfzelle auf nichts. */ |
| 182 | const breiten = new Set(tabelle.zeilen.map((zeile) => zeile.length)); |
| 183 | expect(breiten.size, 'Die Zeilen sind verschieden breit.').toBe(1); |
| 184 | if (tabelle.kopf.length > 0) { |
| 185 | expect([...breiten][0]).toBe(tabelle.kopf.length); |
| 186 | /* Eine Kopfzelle ohne Text wäre eine Ansage ins Nichts. */ |
| 187 | for (const spalte of tabelle.kopf) { |
| 188 | expect(spalte.trim().length).toBeGreaterThan(0); |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /* Die Standtabelle führt die Fassung – und nur einmal, sonst gäbe es |
| 194 | zwei Wahrheiten darüber, welche gilt. */ |
| 195 | const stand = tabellen.find((tabelle) => tabelle.kopf.length === 0); |
| 196 | const zeilenkoepfe = stand?.zeilen.map((zeile) => zeile[0]?.map((t) => t.text).join('') ?? ''); |
| 197 | expect(zeilenkoepfe).toContain('Fassung der Erklärung'); |
| 198 | }); |
| 199 | |
| 200 | it('hat dieselben Bauformen wie die veröffentlichte HTML-Fassung', () => { |
| 201 | /* |
| 202 | Die Lücke, durch die der Fehler vom 01.09.2026 kam, und die keine |
| 203 | Prüfsumme schliesst: Beide Dateien entstehen aus **derselben** |
| 204 | Umsetzung in `tools/datenschutz_html.py`. Ändert sich die Umsetzung, |
| 205 | ohne dass die Quelle sich ändert, bleibt die Quellprüfsumme richtig — |
| 206 | und trotzdem sind die beiden Erzeugnisse verschieden. |
| 207 | |
| 208 | Genau so geschah es: Die HTML-Fassung lernte Tabellen, die Fassung in |
| 209 | der Anwendung behielt Aufzählungen. Zwei Wochen lang stimmte jede |
| 210 | Wache, und die Anwendung zeigte etwas anderes als die veröffentlichte |
| 211 | Seite. |
| 212 | |
| 213 | Gezählt werden deshalb die Bauformen gegeneinander. Wer eine von |
| 214 | beiden neu erzeugt und die andere vergisst, sieht es hier. |
| 215 | */ |
| 216 | const imHtml = (muster: RegExp): number => html.match(muster)?.length ?? 0; |
| 217 | const inBloecken = (art: Datenschutzblock['art']): number => |
| 218 | erzeugt.bloecke.filter((block) => block.art === art).length; |
| 219 | |
| 220 | expect(imHtml(/<table\b/gu), 'Tabellen').toBe(inBloecken('tabelle')); |
| 221 | expect(imHtml(/<ul\b/gu), 'Aufzählungen').toBe(inBloecken('liste')); |
| 222 | expect(imHtml(/<pre\b/gu), 'Codeblöcke').toBe(inBloecken('code')); |
| 223 | /* Gegenprobe: Es wird wirklich gezählt und nicht null gegen null. */ |
| 224 | expect(inBloecken('tabelle')).toBeGreaterThan(0); |
| 225 | }); |
| 226 | |
| 227 | it('sagt in der Sache dasselbe wie die Oberfläche', () => { |
| 228 | /* Der eigentliche Anlass dieses ganzen Bereichs: „Über diese Software“ |
| 229 | sagte bis 0.24.2 „sie sammelt keine Daten“, während Abschnitt 3 der |
| 230 | Erklärung ausdrücklich das Gegenteil sagt. Diese Zusicherung hält |
| 231 | fest, dass die Erklärung bei ihrer Aussage bleibt. */ |
| 232 | const gesamt = erzeugt.bloecke |
| 233 | .flatMap((block) => |
| 234 | block.art === 'absatz' |
| 235 | ? block.teile.map((teil) => teil.text) |
| 236 | : block.art === 'liste' |
| 237 | ? block.punkte.flat().map((teil) => teil.text) |
| 238 | : block.art === 'tabelle' |
| 239 | ? [...block.kopf, ...block.zeilen.flat(2).map((teil) => teil.text)] |
| 240 | : [block.text], |
| 241 | ) |
| 242 | .join(' '); |
| 243 | |
| 244 | expect(gesamt).toContain('entstehen Daten'); |
| 245 | expect(gesamt).not.toMatch(/sammelt\s+kein/u); |
| 246 | }); |
| 247 | }); |