// @vitest-environment node /** * Die Datenschutzerklärung in der Anwendung. * * ## Warum diese Datei die wichtigste Wache dieses Bereichs trägt * * Der Fehler, der hier droht, ist nicht „die Erklärung fehlt“. Er ist: **es * gibt sie zweimal, und die zweite ist die falsche.** Der Fall ist bereits * eingetreten, ohne dass jemand es bemerkt hätte: Am 30.08.2026 stand die * veröffentlichte Seite auf Fassung 1.1, während `docs/datenschutz.md` bei * 1.5 war — vier Revisionen Abstand in vier Tagen, bei vorhandenem Erzeuger * und vorhandener Prüfliste in `docs/veroeffentlichen.md`. Der Erzeuger hatte * gehalten, was er sollte; ausgelaufen ist der Schritt, den nur ein Mensch * tut. * * Daraus folgt die Regel, die dieser Datei zugrunde liegt und die * `tests/dokumentation.test.ts` für den Updatebericht schon einmal * aufgeschrieben hat: **Ein Erzeuger allein reicht nicht. Was zählt, ist der * Test, der rot wird, wenn er nicht gelaufen ist.** * * Deshalb steht die Prüfsumme des Veröffentlichungsteils in der erzeugten * Datei, und deshalb wird sie hier nachgerechnet. */ import { createHash } from 'node:crypto'; import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { abschnitte, type Datenschutzblock, type Datenschutzerklaerung, } from '../src/shared/datenschutz'; const wurzel = join(fileURLToPath(new URL('..', import.meta.url)), '..'); const TRENNER = '---\n---\n'; function lies(pfad: string): string { /* Zeilenenden vereinheitlicht: `.gitattributes` erzwingt LF, aber ein Arbeitsbaum, der einmal mit CRLF ausgecheckt wird, machte diese Wache sonst dauerhaft und grundlos rot — und eine grundlos rote Wache wird abgeschaltet. */ return readFileSync(join(wurzel, pfad), 'utf8').replace(/\r\n/gu, '\n'); } /** * Derselbe Schnitt wie in `tools/datenschutz_html.py`. * * Einschliesslich des abschliessenden Abschneidens der Leerzeilen dort * (`strip`) – ohne das käme eine andere Prüfsumme heraus, und die Wache wäre * dauerhaft rot, ohne dass etwas falsch wäre. */ function veroeffentlichungsteil(markdown: string): string { const stelle = markdown.indexOf(TRENNER); const teil = stelle === -1 ? markdown : markdown.slice(stelle + TRENNER.length); return teil.replace(/^\n+/u, '').replace(/\n+$/u, ''); } const quelle = lies('docs/datenschutz.md'); const erzeugt = JSON.parse(lies('content/datenschutz.json')) as Datenschutzerklaerung; const html = lies('docs/datenschutz.html'); describe('Datenschutzerklärung – die erzeugte Fassung', () => { it('stammt aus der heutigen Quelle', () => { const erwartet = createHash('sha256') .update(veroeffentlichungsteil(quelle), 'utf8') .digest('hex'); expect( erzeugt.quellpruefsumme, 'docs/datenschutz.md hat sich geändert, content/datenschutz.json nicht. ' + 'Neu erzeugen mit: python tools/datenschutz_anwendung.py', ).toBe(erwartet); }); it('nennt dieselbe Fassung und denselben Stand wie die Quelle', () => { /* Beide werden aus der Standtabelle gelesen und nicht eingetragen. Eine Fassungsnummer an zwei Orten ist genau die zweite Wahrheit, um die es hier geht. */ const fassung = /^\|\s*\*\*Fassung der Erklärung\*\*\s*\|\s*([^|]+?)\s*\|/mu.exec(quelle); const stand = /^\|\s*\*\*Stand\*\*\s*\|\s*([^|]+?)\s*\|/mu.exec(quelle); expect(fassung?.[1]).toBeDefined(); expect(stand?.[1]).toBeDefined(); expect(erzeugt.fassung).toBe(fassung?.[1]); expect(erzeugt.stand).toBe(stand?.[1]); }); it('enthält den ganzen Veröffentlichungsteil und nicht nur den Anfang', () => { /* Gegenprobe gegen eine stillschweigend abgeschnittene Umsetzung: Der Erzeuger bricht bei unbekannten Auszeichnungen ab, aber ein Fehler in der Blockbildung könnte hinten etwas verlieren, ohne dass jemand es sähe. Verglichen wird die Zahl der Überschriften. */ const ueberschriften = veroeffentlichungsteil(quelle) .split('\n') .filter((zeile) => /^#{1,3} /u.test(zeile)).length; const gebaut = erzeugt.bloecke.filter((block) => block.art === 'ueberschrift').length; expect(gebaut, 'Es fehlen Überschriften in der erzeugten Fassung.').toBe(ueberschriften); expect(gebaut).toBeGreaterThan(20); }); it('führt für jeden Abschnitt ein eindeutiges Sprungziel', () => { const ziele = abschnitte(erzeugt); expect(ziele.length).toBeGreaterThan(10); expect(new Set(ziele.map((ziel) => ziel.kennung)).size).toBe(ziele.length); for (const ziel of ziele) { expect(ziel.kennung, `Leeres Sprungziel bei „${ziel.text}“`).not.toBe(''); } }); it('enthält kein Markup in den Texten', () => { /* Dieselbe Zusage wie beim Handbuch: Die Blöcke werden als reiner Text gerendert. Stünde hier eine spitze Klammer oder ein Sternchenpaar, erschiene es wörtlich auf dem Bildschirm. */ /* Kennzeichnungen bleiben ausgenommen. Sie sind der Ort, an dem das Dokument über Auszeichnung **spricht**: Abschnitt 8 erklärt, die Adresse stehe „technisch in einem ``-Element, nicht in einem Link“. Der Satz ist richtig und soll genau so dastehen; ihn als Markup-Rückstand zu werten wäre der erste Fehlalarm, nach dem eine Wache abgeschaltet wird. */ const sichtbar = (teile: readonly { art: string; text: string }[]): string[] => teile.filter((teil) => teil.art !== 'kennzeichnung').map((teil) => teil.text); const texte: string[] = []; for (const block of erzeugt.bloecke) { if (block.art === 'ueberschrift' || block.art === 'code') { texte.push(block.text); } else if (block.art === 'absatz') { texte.push(...sichtbar(block.teile)); } else if (block.art === 'tabelle') { texte.push(...block.kopf); texte.push(...sichtbar(block.zeilen.flat(2))); } else { texte.push(...sichtbar(block.punkte.flat())); } } const gesamt = texte.join('\n'); /* Gesucht werden die Elemente, die der Umsetzer erzeugt – nicht jede spitze Klammer. Das Dokument enthält echte Platzhalter in spitzen Klammern („C:\\Users\\\\AppData“, „Lernstand-selbsttaetig-.wsklernstand“), und die sollen genau so dastehen. Eine Wache, die daran anschlägt, wird nach dem zweiten Fehlalarm abgeschaltet. */ const tags = /<\/?(?:p|ul|li|h[1-6]|strong|code|pre|a|em|br|div|span|table)\b/iu; expect(gesamt, 'Ein Element ist als Text in die Blöcke geraten.').not.toMatch(tags); expect(gesamt).not.toMatch(/\*\*[^*]+\*\*/u); /* Gegenprobe: Der Text ist tatsächlich da, und die Wache trifft, wenn etwas dasteht. */ expect(gesamt.length).toBeGreaterThan(10_000); expect(tags.test('Ein fetter Text.')).toBe(true); expect(tags.test('Der Pfad C:\\Users\\\\AppData.')).toBe(false); }); it('trägt die beiden Tabellen als Tabellen, nicht als Fließtext', () => { /* Der Anlass: Bis zum 01.09.2026 rechnete der Umsetzer Tabellen in Aufzählungen um. Als die HTML-Fassung Tabellen lernte, brach er ab — und weil die erzeugte Datei danach nicht neu geschrieben wurde, fiel es erst auf, als die Erklärung wirklich nachzuführen war. Für einen Bildschirmleser ist der Unterschied nicht kosmetisch: Die Nachprüfmatrix in Abschnitt 2 hat drei Spalten, und ohne Spaltenzuordnung sind ihre achtzehn Zellen achtzehn Bruchstücke. */ const tabellen = erzeugt.bloecke.filter((block) => block.art === 'tabelle'); expect(tabellen).toHaveLength(2); for (const tabelle of tabellen) { expect(tabelle.zeilen.length).toBeGreaterThan(0); /* Jede Zeile gleich breit – sonst zeigt eine Kopfzelle auf nichts. */ const breiten = new Set(tabelle.zeilen.map((zeile) => zeile.length)); expect(breiten.size, 'Die Zeilen sind verschieden breit.').toBe(1); if (tabelle.kopf.length > 0) { expect([...breiten][0]).toBe(tabelle.kopf.length); /* Eine Kopfzelle ohne Text wäre eine Ansage ins Nichts. */ for (const spalte of tabelle.kopf) { expect(spalte.trim().length).toBeGreaterThan(0); } } } /* Die Standtabelle führt die Fassung – und nur einmal, sonst gäbe es zwei Wahrheiten darüber, welche gilt. */ const stand = tabellen.find((tabelle) => tabelle.kopf.length === 0); const zeilenkoepfe = stand?.zeilen.map((zeile) => zeile[0]?.map((t) => t.text).join('') ?? ''); expect(zeilenkoepfe).toContain('Fassung der Erklärung'); }); it('hat dieselben Bauformen wie die veröffentlichte HTML-Fassung', () => { /* Die Lücke, durch die der Fehler vom 01.09.2026 kam, und die keine Prüfsumme schliesst: Beide Dateien entstehen aus **derselben** Umsetzung in `tools/datenschutz_html.py`. Ändert sich die Umsetzung, ohne dass die Quelle sich ändert, bleibt die Quellprüfsumme richtig — und trotzdem sind die beiden Erzeugnisse verschieden. Genau so geschah es: Die HTML-Fassung lernte Tabellen, die Fassung in der Anwendung behielt Aufzählungen. Zwei Wochen lang stimmte jede Wache, und die Anwendung zeigte etwas anderes als die veröffentlichte Seite. Gezählt werden deshalb die Bauformen gegeneinander. Wer eine von beiden neu erzeugt und die andere vergisst, sieht es hier. */ const imHtml = (muster: RegExp): number => html.match(muster)?.length ?? 0; const inBloecken = (art: Datenschutzblock['art']): number => erzeugt.bloecke.filter((block) => block.art === art).length; expect(imHtml(/ { /* Der eigentliche Anlass dieses ganzen Bereichs: „Über diese Software“ sagte bis 0.24.2 „sie sammelt keine Daten“, während Abschnitt 3 der Erklärung ausdrücklich das Gegenteil sagt. Diese Zusicherung hält fest, dass die Erklärung bei ihrer Aussage bleibt. */ const gesamt = erzeugt.bloecke .flatMap((block) => block.art === 'absatz' ? block.teile.map((teil) => teil.text) : block.art === 'liste' ? block.punkte.flat().map((teil) => teil.text) : block.art === 'tabelle' ? [...block.kopf, ...block.zeilen.flat(2).map((teil) => teil.text)] : [block.text], ) .join(' '); expect(gesamt).toContain('entstehen Daten'); expect(gesamt).not.toMatch(/sammelt\s+kein/u); }); }); describe('Der Tagvorrat im Kopf des Umsetzers ist vollständig', () => { /* Die Wache, die gefehlt hat. Der Modulkopf von `tools/datenschutz_anwendung.py` führt auf, welche Auszeichnungen die Anwendung darstellen kann, und schließt mit „Alles andere lässt dieses Werkzeug abbrechen“. Er ist damit die Stelle, an der jemand nachsieht, bevor er `docs/datenschutz.md` ändert. Seit `datenschutz_html.tabelle()` Pipe-Tabellen als echtes `` ausgibt, erzeugt der Umsetzer sechs Tags mehr, als der Kopf nennt: `table`, `thead`, `tbody`, `tr`, `th`, `td` – ausgerechnet die, die seinen aufwendigsten Zweig ausmachen. Der Kopf legte nahe, eine Tabelle im Dokument löse einen Abbruch aus; angenommen wird sie sehr wohl. Gezählt wird aus den Mengen im Quelltext selbst, nicht aus einer hier wiederholten Liste. */ const umsetzer = lies('tools/datenschutz_anwendung.py'); function menge(name: string): string[] { const roh = new RegExp(`^${name} = \\{([^}]*)\\}`, 'mu').exec(umsetzer)?.[1]; expect(roh, `${name} nicht gefunden`).toBeDefined(); return [...(roh ?? '').matchAll(/"([a-z0-9]+)"/gu)].map((t) => t[1] ?? ''); } it('nennt jeden Tag, den der Umsetzer annimmt', () => { /* `li` steht in keiner der Mengen – es entsteht innerhalb von `ul` und wird in `handle_starttag` gesondert behandelt. Es gehört trotzdem in den Vorrat, deshalb hier ergänzt. */ const angenommen = [ ...menge('BLOCKTAGS'), ...menge('TABELLENTAGS'), ...menge('TEILTAGS'), 'li', ]; expect(angenommen.length).toBeGreaterThan(10); const kopf = umsetzer.slice(0, umsetzer.indexOf('## Die Wache')); const fehlend = angenommen.filter((tag) => !kopf.includes(`\`${tag}\``)); expect(fehlend, 'Tags, die der Umsetzer annimmt, aber der Modulkopf nicht aufzählt').toEqual( [], ); }); }); describe('Die Erklärung kennt jede Kopie, die die Anwendung von selbst anlegt', () => { /* Befund der Prüfrunde zu 0.27.2. Abschnitt 4.4 sagte „in **drei** Fällen“ und zählte drei Muster auf; es sind vier. Die Kopie vor „Neu anfangen“ und vor dem Löschen eines Profils (`Lernstand-vor-dem-Verwerfen-*`) fehlte ganz — auch in Abschnitt 7, der sagt, was zu löschen ist, wenn man seinen Lernstand restlos loswerden will. Wer der Anleitung folgte, ließ Kopien seines gesamten Lernstands liegen. Geprüft wird gegen den Quelltext, nicht gegen eine gepflegte Liste: Jeder Dateivorsatz, der in `src/main` als Zeichenkette „Lernstand-vor-dem-…“ steht, muss in der Erklärung vorkommen — in Abschnitt 4.4 **und** in Abschnitt 7. Eine fünfte Kopienart ist damit von selbst mitbewacht. */ const MAIN = join(wurzel, 'app', 'src', 'main'); /** * Jeder Dateivorsatz, unter dem die Anwendung von selbst eine Kopie anlegt. * * Vier Stück: drei vor einem nicht rücknehmbaren Schritt, einer wöchentlich * beim Beenden. Gelesen aus dem Quelltext, damit eine fünfte Art nicht * unbemerkt dazukommen kann. */ function vorsaetze(): string[] { const gefunden = new Set(); for (const datei of readdirSync(MAIN).filter((name) => name.endsWith('.ts'))) { const inhalt = readFileSync(join(MAIN, datei), 'utf8'); for (const [, name] of inhalt.matchAll( /'(Lernstand-(?:vor-dem-[A-Za-z]+|selbsttaetig))'/gu, )) { if (name !== undefined) { gefunden.add(name); } } } return [...gefunden].sort(); } it('nennt jeden Dateivorsatz in Abschnitt 4.4 und in Abschnitt 7', () => { const alle = vorsaetze(); expect(alle.length, 'Keine Kopienart gefunden – der Test misst nichts').toBeGreaterThanOrEqual( 4, ); const text = readFileSync(join(wurzel, 'docs', 'datenschutz.md'), 'utf8'); const vierViervier = text.slice( text.indexOf('### 4.4 Sicherheitskopien'), text.indexOf('### 4.4a'), ); const sieben = text.slice(text.indexOf('### 7.3')); for (const name of alle) { expect(vierViervier, `${name} fehlt in Abschnitt 4.4`).toContain(name); /* Die wöchentliche Kopie nennt Abschnitt 7 über ihren Ordner, nicht über ihren Namen – der ganze Unterordner soll ja weg. */ const inSieben = name === 'Lernstand-selbsttaetig' ? 'Unterordner `sicherungen`' : name; expect(sieben, `${name} fehlt in der Löschanleitung`).toContain(inSieben); } }); it('nennt so viele Fälle, wie es Kopienarten gibt', () => { /* Die Zahl im Satz ist eine eigene Falle: Sie stand auf „drei“, während vier Muster im Quelltext lagen. */ const zahlwort = ['null', 'einem', 'zwei', 'drei', 'vier', 'fünf', 'sechs']; const text = readFileSync(join(wurzel, 'docs', 'datenschutz.md'), 'utf8'); expect(text).toContain(`in **${zahlwort[vorsaetze().length] ?? '?'}** Fällen ungefragt Kopien`); }); });