// @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 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; 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 { 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('sagt in der Sache dasselbe wie die Oberfläche', () => { /* 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.text], ) .join(' '); expect(gesamt).toContain('entstehen Daten'); expect(gesamt).not.toMatch(/sammelt\s+kein/u); }); });