// @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 { 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); }); });