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 { abschnitte, type Datenschutzerklaerung } from '../src/shared/datenschutz'; |
| 33 | |
| 34 | const wurzel = join(fileURLToPath(new URL('..', import.meta.url)), '..'); |
| 35 | |
| 36 | const TRENNER = '---\n---\n'; |
| 37 | |
| 38 | function lies(pfad: string): string { |
| 39 | /* Zeilenenden vereinheitlicht: `.gitattributes` erzwingt LF, aber ein |
| 40 | Arbeitsbaum, der einmal mit CRLF ausgecheckt wird, machte diese Wache |
| 41 | sonst dauerhaft und grundlos rot — und eine grundlos rote Wache wird |
| 42 | abgeschaltet. */ |
| 43 | return readFileSync(join(wurzel, pfad), 'utf8').replace(/\r\n/gu, '\n'); |
| 44 | } |
| 45 | |
| 46 | /** |
| 47 | * Derselbe Schnitt wie in `tools/datenschutz_html.py`. |
| 48 | * |
| 49 | * Einschliesslich des abschliessenden Abschneidens der Leerzeilen dort |
| 50 | * (`strip`) – ohne das käme eine andere Prüfsumme heraus, und die Wache wäre |
| 51 | * dauerhaft rot, ohne dass etwas falsch wäre. |
| 52 | */ |
| 53 | function veroeffentlichungsteil(markdown: string): string { |
| 54 | const stelle = markdown.indexOf(TRENNER); |
| 55 | const teil = stelle === -1 ? markdown : markdown.slice(stelle + TRENNER.length); |
| 56 | return teil.replace(/^\n+/u, '').replace(/\n+$/u, ''); |
| 57 | } |
| 58 | |
| 59 | const quelle = lies('docs/datenschutz.md'); |
| 60 | const erzeugt = JSON.parse(lies('content/datenschutz.json')) as Datenschutzerklaerung; |
| 61 | |
| 62 | describe('Datenschutzerklärung – die erzeugte Fassung', () => { |
| 63 | it('stammt aus der heutigen Quelle', () => { |
| 64 | const erwartet = createHash('sha256') |
| 65 | .update(veroeffentlichungsteil(quelle), 'utf8') |
| 66 | .digest('hex'); |
| 67 | |
| 68 | expect( |
| 69 | erzeugt.quellpruefsumme, |
| 70 | 'docs/datenschutz.md hat sich geändert, content/datenschutz.json nicht. ' + |
| 71 | 'Neu erzeugen mit: python tools/datenschutz_anwendung.py', |
| 72 | ).toBe(erwartet); |
| 73 | }); |
| 74 | |
| 75 | it('nennt dieselbe Fassung und denselben Stand wie die Quelle', () => { |
| 76 | /* Beide werden aus der Standtabelle gelesen und nicht eingetragen. Eine |
| 77 | Fassungsnummer an zwei Orten ist genau die zweite Wahrheit, um die es |
| 78 | hier geht. */ |
| 79 | const fassung = /^\|\s*\*\*Fassung der Erklärung\*\*\s*\|\s*([^|]+?)\s*\|/mu.exec(quelle); |
| 80 | const stand = /^\|\s*\*\*Stand\*\*\s*\|\s*([^|]+?)\s*\|/mu.exec(quelle); |
| 81 | |
| 82 | expect(fassung?.[1]).toBeDefined(); |
| 83 | expect(stand?.[1]).toBeDefined(); |
| 84 | expect(erzeugt.fassung).toBe(fassung?.[1]); |
| 85 | expect(erzeugt.stand).toBe(stand?.[1]); |
| 86 | }); |
| 87 | |
| 88 | it('enthält den ganzen Veröffentlichungsteil und nicht nur den Anfang', () => { |
| 89 | /* Gegenprobe gegen eine stillschweigend abgeschnittene Umsetzung: Der |
| 90 | Erzeuger bricht bei unbekannten Auszeichnungen ab, aber ein Fehler in |
| 91 | der Blockbildung könnte hinten etwas verlieren, ohne dass jemand es |
| 92 | sähe. Verglichen wird die Zahl der Überschriften. */ |
| 93 | const ueberschriften = veroeffentlichungsteil(quelle) |
| 94 | .split('\n') |
| 95 | .filter((zeile) => /^#{1,3} /u.test(zeile)).length; |
| 96 | |
| 97 | const gebaut = erzeugt.bloecke.filter((block) => block.art === 'ueberschrift').length; |
| 98 | |
| 99 | expect(gebaut, 'Es fehlen Überschriften in der erzeugten Fassung.').toBe(ueberschriften); |
| 100 | expect(gebaut).toBeGreaterThan(20); |
| 101 | }); |
| 102 | |
| 103 | it('führt für jeden Abschnitt ein eindeutiges Sprungziel', () => { |
| 104 | const ziele = abschnitte(erzeugt); |
| 105 | expect(ziele.length).toBeGreaterThan(10); |
| 106 | expect(new Set(ziele.map((ziel) => ziel.kennung)).size).toBe(ziele.length); |
| 107 | for (const ziel of ziele) { |
| 108 | expect(ziel.kennung, `Leeres Sprungziel bei „${ziel.text}“`).not.toBe(''); |
| 109 | } |
| 110 | }); |
| 111 | |
| 112 | it('enthält kein Markup in den Texten', () => { |
| 113 | /* Dieselbe Zusage wie beim Handbuch: Die Blöcke werden als reiner Text |
| 114 | gerendert. Stünde hier eine spitze Klammer oder ein Sternchenpaar, |
| 115 | erschiene es wörtlich auf dem Bildschirm. */ |
| 116 | /* |
| 117 | Kennzeichnungen bleiben ausgenommen. Sie sind der Ort, an dem das |
| 118 | Dokument über Auszeichnung **spricht**: Abschnitt 8 erklärt, die Adresse |
| 119 | stehe „technisch in einem `<code>`-Element, nicht in einem Link“. Der |
| 120 | Satz ist richtig und soll genau so dastehen; ihn als Markup-Rückstand zu |
| 121 | werten wäre der erste Fehlalarm, nach dem eine Wache abgeschaltet wird. |
| 122 | */ |
| 123 | const sichtbar = (teile: readonly { art: string; text: string }[]): string[] => |
| 124 | teile.filter((teil) => teil.art !== 'kennzeichnung').map((teil) => teil.text); |
| 125 | |
| 126 | const texte: string[] = []; |
| 127 | for (const block of erzeugt.bloecke) { |
| 128 | if (block.art === 'ueberschrift' || block.art === 'code') { |
| 129 | texte.push(block.text); |
| 130 | } else if (block.art === 'absatz') { |
| 131 | texte.push(...sichtbar(block.teile)); |
| 132 | } else { |
| 133 | texte.push(...sichtbar(block.punkte.flat())); |
| 134 | } |
| 135 | } |
| 136 | const gesamt = texte.join('\n'); |
| 137 | |
| 138 | /* |
| 139 | Gesucht werden die Elemente, die der Umsetzer erzeugt – nicht jede |
| 140 | spitze Klammer. Das Dokument enthält echte Platzhalter in spitzen |
| 141 | Klammern („C:\\Users\\<Ihr Name>\\AppData“, |
| 142 | „Lernstand-selbsttaetig-<Zeitstempel>.wsklernstand“), und die sollen |
| 143 | genau so dastehen. Eine Wache, die daran anschlägt, wird nach dem |
| 144 | zweiten Fehlalarm abgeschaltet. |
| 145 | */ |
| 146 | const tags = /<\/?(?:p|ul|li|h[1-6]|strong|code|pre|a|em|br|div|span|table)\b/iu; |
| 147 | |
| 148 | expect(gesamt, 'Ein Element ist als Text in die Blöcke geraten.').not.toMatch(tags); |
| 149 | expect(gesamt).not.toMatch(/\*\*[^*]+\*\*/u); |
| 150 | /* Gegenprobe: Der Text ist tatsächlich da, und die Wache trifft, wenn |
| 151 | etwas dasteht. */ |
| 152 | expect(gesamt.length).toBeGreaterThan(10_000); |
| 153 | expect(tags.test('Ein <strong>fetter</strong> Text.')).toBe(true); |
| 154 | expect(tags.test('Der Pfad C:\\Users\\<Ihr Name>\\AppData.')).toBe(false); |
| 155 | }); |
| 156 | |
| 157 | it('sagt in der Sache dasselbe wie die Oberfläche', () => { |
| 158 | /* Der eigentliche Anlass dieses ganzen Bereichs: „Über diese Software“ |
| 159 | sagte bis 0.24.2 „sie sammelt keine Daten“, während Abschnitt 3 der |
| 160 | Erklärung ausdrücklich das Gegenteil sagt. Diese Zusicherung hält |
| 161 | fest, dass die Erklärung bei ihrer Aussage bleibt. */ |
| 162 | const gesamt = erzeugt.bloecke |
| 163 | .flatMap((block) => |
| 164 | block.art === 'absatz' |
| 165 | ? block.teile.map((teil) => teil.text) |
| 166 | : block.art === 'liste' |
| 167 | ? block.punkte.flat().map((teil) => teil.text) |
| 168 | : [block.text], |
| 169 | ) |
| 170 | .join(' '); |
| 171 | |
| 172 | expect(gesamt).toContain('entstehen Daten'); |
| 173 | expect(gesamt).not.toMatch(/sammelt\s+kein/u); |
| 174 | }); |
| 175 | }); |