// @vitest-environment node /** * Die Dokumentation muss zur Fassung passen. * * Anlass ist ein wiederkehrendes Muster: Der Stand der Software stand an vier * Stellen – im Plan, in der Liesmich, im Prüfplan und in Gesprächen – und * lief auseinander. Der Plan führte das fertige Glossar als offen, die * Liesmich behauptete, es gebe keine Veröffentlichung, obwohl drei Etiketten * gesetzt waren. * * Geprüft wird deshalb genau der eine Fehler, der wirklich passiert: die * Nummer erhöhen und die Dokumentation vergessen. Ob der Inhalt stimmt, kann * kein Test wissen – dafür ist die Liste in `docs/veroeffentlichen.md` da. */ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; /** Verzeichnis `app/`. */ const app = join(fileURLToPath(new URL('..', import.meta.url))); /** Projektwurzel, eine Ebene darüber. */ const wurzel = join(app, '..'); function lesen(...teile: string[]): string { return readFileSync(join(...teile), 'utf8'); } const fassung = (JSON.parse(lesen(app, 'package.json')) as { version: string }).version; describe('Fassungsnummer', () => { it('ist eine gültige Nummer nach Semantic Versioning', () => { expect(fassung).toMatch(/^\d+\.\d+\.\d+$/u); }); it('steht auch in der Sperrdatei', () => { /* Die Lücke, die genau einmal zwei Fassungen lang offen stand: Beim Sprung auf 0.22.0 blieb `package-lock.json` bei 0.21.0 stehen, und niemandem fiel es auf – die Wachen darunter sehen Changelog, stand.md und README an, nicht die Sperrdatei. Sie ist kein Nebenschauplatz: `npm ci` baut daraus, und ein Quelltextarchiv trägt sie mit. Eine Datei, die eine andere Fassung behauptet als die Anwendung, ist eine falsche Angabe – auch wenn sie nichts kaputt macht. Geprüft wird beides: der Wurzeleintrag und der Eintrag des eigenen Pakets. npm schreibt beide, wer von Hand ändert, vergisst leicht einen. */ const sperre = JSON.parse(lesen(app, 'package-lock.json')) as { version: string; packages: Record; }; expect(sperre.version, 'package-lock.json: Wurzeleintrag').toBe(fassung); expect(sperre.packages['']?.version, 'package-lock.json: eigener Paketeintrag').toBe(fassung); }); }); describe('CHANGELOG.md', () => { const pfad = join(wurzel, 'CHANGELOG.md'); it('ist vorhanden', () => { expect(existsSync(pfad)).toBe(true); }); it('führt die Fassung aus der package.json', () => { /* Der eigentliche Wächter. Wer die Nummer erhöht und den Verlauf vergisst, bekommt hier eine Meldung, die sagt, was zu tun ist – nicht erst ein Anwender, der wissen will, was sich geändert hat. */ const text = lesen(pfad); expect( text.includes(`## [${fassung}]`), `CHANGELOG.md hat keinen Abschnitt "## [${fassung}]". ` + 'Beim Anheben der Fassungsnummer den Abschnitt [Unveröffentlicht] ' + 'umbenennen und einen neuen leeren darüber anlegen ' + '(siehe docs/veroeffentlichen.md).', ).toBe(true); }); it('hält einen Abschnitt für Unveröffentlichtes offen', () => { /* Ohne ihn hat die nächste Änderung keinen Platz und landet unter der zuletzt veröffentlichten Nummer – dort ist sie falsch. */ expect(lesen(pfad)).toContain('## [Unveröffentlicht]'); }); it('nennt zu jeder Fassung ein Datum', () => { const text = lesen(pfad); const ueberschriften = [...text.matchAll(/^## \[(?!Unveröffentlicht)([^\]]+)\](.*)$/gmu)]; expect(ueberschriften.length).toBeGreaterThan(0); for (const [, nummer, rest] of ueberschriften) { expect(rest, `Der Fassung ${String(nummer)} fehlt das Datum.`).toMatch( /—\s*\d{4}-\d{2}-\d{2}/u, ); } }); }); describe('docs/stand.md', () => { const pfad = join(wurzel, 'docs', 'stand.md'); it('ist vorhanden', () => { expect(existsSync(pfad)).toBe(true); }); it('gilt für die Fassung aus der package.json', () => { /* Ein Standdokument, das eine ältere Fassung nennt, ist schlimmer als keines: Es sagt mit der Autorität eines Dokuments etwas Falsches. */ const text = lesen(pfad); expect( text.includes(`Fassung ${fassung}`), `docs/stand.md nennt nicht "Fassung ${fassung}". ` + 'Beim Anheben der Fassungsnummer den Kopf des Dokuments nachführen ' + 'und die Tabellen durchsehen (siehe docs/veroeffentlichen.md).', ).toBe(true); }); it('verweist auf die übrigen Dokumente, statt sie zu wiederholen', () => { /* Der Umsetzungsstand soll an genau einer Stelle stehen. Die Wegweiser oben im Dokument sind das, was Leser dorthin führt. */ const text = lesen(pfad); for (const ziel of ['PLAN.md', 'CHANGELOG.md', 'veroeffentlichen.md']) { expect(text, `docs/stand.md verweist nicht auf ${ziel}.`).toContain(ziel); } }); }); describe('README.md', () => { const pfad = join(wurzel, 'README.md'); it('ist vorhanden', () => { expect(existsSync(pfad)).toBe(true); }); it('nennt die Fassung aus der package.json', () => { /* Diese Wache fehlte, und sie hat gefehlt: Das README führte „Fassung 0.9.0“, während die Anwendung bei 0.22.0 stand – dreizehn Nummern Rückstand, sichtbar in der ersten Bildschirmseite, die ein Besucher eines öffentlichen Archivs zu sehen bekäme. CHANGELOG und stand.md waren seit je bewacht, das Aushängeschild nicht. */ const text = lesen(pfad); expect( text.includes(`Fassung ${fassung}`), `README.md nennt nicht "Fassung ${fassung}". ` + 'Beim Anheben der Fassungsnummer den Standblock oben nachführen ' + '(siehe docs/veroeffentlichen.md).', ).toBe(true); }); it('nennt einen Rückmeldeweg', () => { /* Wer eine Barriere findet, muss sie melden können, ohne die Anwendung erst zu installieren. Ein Archiv ohne Kontakt ist eine Sackgasse. */ const text = lesen(pfad); expect(text).toContain('Olaf@olaf-willerding.de'); }); it('behauptet keine macOS-Fassung', () => { /* Es existiert kein macOS-Paket, und nichts davon lief je auf einem Mac (docs/stand.md, Abschnitt 5). docs/store-eintrag.md 9.1 führt „Auch für macOS“ deshalb unter „Nicht belegt – deshalb nicht behauptet“; für das README gilt derselbe Maßstab. Erlaubt bleibt, die Fassung als geplant oder als fehlend zu benennen – verboten ist die Behauptung, sie laufe. */ const text = lesen(pfad); expect(text).not.toMatch(/läuft[^.]*\bmacOS\b/iu); expect(text).not.toMatch(/\bWindows und macOS\b/u); }); }); describe('docs/veroeffentlichen.md', () => { it('ist vorhanden und nennt die Schritte, die der Test nicht prüfen kann', () => { const text = lesen(wurzel, 'docs', 'veroeffentlichen.md'); expect(text).toContain('CHANGELOG.md'); expect(text).toContain('stand.md'); // Ohne gebautes Paket sagt ein grüner E2E-Lauf nichts über das Paket. expect(text).toContain('dist:win'); }); }); /* Der Updatebericht. Er ist **erzeugt** (`python tools/updatebericht.py`) und nicht von Hand gepflegt — der Mittelteil stammt Wort für Wort aus dem Changelog-Abschnitt der Fassung. Genau deshalb braucht er eine Wache: Ein erzeugtes Dokument, das niemand neu erzeugt, ist stiller falsch als ein handgeschriebenes, weil niemand mehr hinsieht. */ describe('docs/updatebericht.md', () => { const pfad = join(wurzel, 'docs', 'updatebericht.md'); /** * Entfernt die Verweisziele, behält den sichtbaren Text. * * Der Bericht liegt in `docs/`, der Changelog im Wurzelverzeichnis; das * Werkzeug rechnet die relativen Ziele deshalb um. Verglichen wird der * Wortlaut, nicht der Pfad — sonst prüfte dieser Test die Umrechnung statt * der Aktualität. */ function ohneVerweisziele(text: string): string { return text.replace(/\]\([^)]*\)/gu, ']()'); } /** Der Changelog-Abschnitt einer Fassung, ohne Überschrift und Trennlinie. */ function changelogAbschnitt(nummer: string): string { const text = lesen(wurzel, 'CHANGELOG.md'); const kopf = new RegExp(`^## \\[${nummer.replace(/\./gu, '\\.')}\\]`, 'mu'); const start = text.search(kopf); if (start === -1) { return ''; } const rest = text.slice(start); const naechste = rest.slice(1).search(/^## \[/mu); const roh = naechste === -1 ? rest : rest.slice(0, naechste + 1); return roh .split('\n') .slice(1) .join('\n') .replace(/\n---\s*$/u, '') .trim(); } it('ist vorhanden', () => { expect(existsSync(pfad), 'docs/updatebericht.md fehlt.').toBe(true); }); it('gilt für die Fassung aus der package.json', () => { expect( lesen(pfad).includes(`Updatebericht zur Fassung ${fassung}`), `docs/updatebericht.md gilt nicht für Fassung ${fassung}. ` + 'Neu erzeugen mit: python tools/updatebericht.py', ).toBe(true); }); it('gibt den Changelog-Abschnitt der Fassung unverändert wieder', () => { /* Die eigentliche Zusage. Ohne sie stünde im Bericht irgendwann etwas anderes als im Änderungsverlauf — und niemand wüsste, welches von beiden gilt. */ const bericht = lesen(pfad); const anfang = bericht.indexOf('## Was sich geändert hat'); const ende = bericht.indexOf('## Wie Sie aktualisieren'); expect(anfang, 'Der Bericht hat keinen Abschnitt „Was sich geändert hat".').toBeGreaterThan(-1); expect(ende, 'Der Bericht hat keinen Abschnitt „Wie Sie aktualisieren".').toBeGreaterThan(-1); const imBericht = bericht .slice(anfang + '## Was sich geändert hat'.length, ende) .replace(/\n---\s*$/u, '') .trim(); expect( ohneVerweisziele(imBericht), 'docs/updatebericht.md gibt den Changelog-Abschnitt nicht mehr wieder. ' + 'Neu erzeugen mit: python tools/updatebericht.py', ).toBe(ohneVerweisziele(changelogAbschnitt(fassung))); }); it('führt keinen toten Verweis', () => { /* Ein erzeugtes Dokument bekommt seine Verweise umgerechnet. Rechnet die Umrechnung falsch, merkt es beim Lesen niemand — beim Klicken schon. */ const bericht = lesen(pfad); const tot = [...bericht.matchAll(/\]\(([^)#]+)\)/gu)] .map((treffer) => treffer[1] ?? '') .filter((ziel) => !/^(https?:|mailto:)/u.test(ziel)) .filter((ziel) => !existsSync(join(wurzel, 'docs', ziel))); expect([...new Set(tot)]).toEqual([]); }); it('nennt die stehenden Vorbehalte', () => { /* Sie sind der Grund, warum der Bericht mehr ist als eine Kopie des Changelogs: Wer die Datei in die Hand bekommt, muss ohne Nachfrage wissen, was die Fassung nicht leistet. */ const text = lesen(pfad); for (const zusage of ['nicht signiert', 'macOS', 'Prüfungsausschuss', 'Ihr Lernstand bleibt']) { expect(text, `docs/updatebericht.md nennt „${zusage}" nicht.`).toContain(zusage); } }); }); /* Die öffentlichen Versionshinweise. Jede Fassung braucht einen Text, der veröffentlicht werden darf – auch eine Unterfassung, die nur einen Fehler behebt. Er steht im Changelog unter `### Für die Öffentlichkeit`, dem Gegenstück zu `### Für die Werkbank`, und `tools/store_notiz.py` macht daraus `docs/store-notiz.md`. Warum ein Test und nicht nur ein Haken auf einer Liste: Der Text entsteht am Ende einer Fassung, wenn alles andere fertig ist und niemand mehr Lust hat. Genau dort wird er vergessen. Ein Haken erinnert daran, ein roter Test hält an. Arbeitsteilung mit dem Werkzeug: Hier steht, was **strukturell** stimmen muss – der Block ist da, er steht auch in der erzeugten Datei, und er trägt nichts offensichtlich Internes. Die vollständige Liste der Öffentlichkeitsregeln führt `tools/store_notiz.py`; sie hier zu wiederholen hieße, zwei Listen auseinanderlaufen zu lassen. Wer eine Regel dort verletzt, bekommt kein Dokument mehr erzeugt – und fällt spätestens über die Prüfung auf, dass die erzeugte Datei nicht mehr zum Changelog passt. */ describe('Der Updatebericht widerspricht sich nicht selbst', () => { /* Die Wache, die gefehlt hat. `docs/updatebericht.md` ist das eine Dokument, das dem Anwender mit der Installationsdatei in die Hand gegeben wird. Sein Mittelteil kommt aus dem Changelog und wird hier oben Wort für Wort verglichen; der Rahmen bleibt von Hand — und genau dort stand bis Fassung 0.27.2 „ein öffentliches Archiv gibt es bislang nicht“, während 83 Zeilen weiter oben im selben Blatt „Der Quelltext dieser Software ist jetzt öffentlich einsehbar“ zu lesen war. Bei der EUPL ist die Verfügbarkeit des Quelltextes kein Nebensatz; wer den Rahmen las, ging den Anfrageweg statt der Adresse. Geprüft wird gegen `README.md` als Quelle der Wahrheit über das Archiv, nicht gegen eine hier wiederholte Adresse. */ const bericht = lesen(wurzel, 'docs', 'updatebericht.md'); const liesmich = lesen(wurzel, 'README.md'); const klonadresse = /`git clone (https:\/\/[^`]+)`/u.exec(liesmich)?.[1]; it('nennt dieselbe Klonadresse wie die Liesmich', () => { expect(klonadresse, 'README.md nennt keine Klonadresse mehr').toBeDefined(); expect(bericht).toContain(klonadresse); }); it('bestreitet das Archiv nicht, das die Liesmich nennt', () => { /* Geprüft wird der **Rahmen**, nicht der Mittelteil: Der Mittelteil kommt aus dem Changelog und darf den alten Wortlaut zitieren, wenn er dessen Behebung beschreibt. Der Rahmen ist die Stelle, an der der Satz als Zusage stand. */ const rahmen = bericht.slice(bericht.indexOf('## Woran Sie die Angaben nachprüfen können')); expect(rahmen.length, 'der Schlussrahmen fehlt im Bericht').toBeGreaterThan(100); expect(rahmen).not.toMatch(/öffentliches Archiv gibt es (bislang )?nicht/u); expect(rahmen).not.toMatch(/nur auf Anfrage erhältlich/u); }); }); describe('Die Zahl der Abnahmebilder ist gezählt', () => { /* Zwei Dokumente nannten „dreizehn“, im Verzeichnis lagen vierzehn – seit `14-kapitelwahl.png` mit Fassung 0.27.1 dazukam. Abschnitt 5 der Veröffentlichungsliste verlangt, die Bilder nach dem Bauen neu zu ziehen; wer das Ergebnis gegen die Beschreibung hält, sucht sonst ein Bild zu wenig und weiß nicht, ob eines fehlt oder die Zahl alt ist. */ const ZAHLWORT: Record = { 10: 'zehn', 11: 'elf', 12: 'zwölf', 13: 'dreizehn', 14: 'vierzehn', 15: 'fünfzehn', 16: 'sechzehn', 17: 'siebzehn', 18: 'achtzehn', 19: 'neunzehn', 20: 'zwanzig', }; const bilder = readdirSync(join(wurzel, 'docs', 'bildschirmfotos')).filter((n) => n.endsWith('.png'), ); it('steht so in docs/stand.md und in docs/store-bilder/README.md', () => { const wort = ZAHLWORT[bilder.length]; expect(wort, `Für ${String(bilder.length)} fehlt das Zahlwort in dieser Wache`).toBeDefined(); expect(lesen(wurzel, 'docs', 'stand.md')).toContain( `Die ${String(wort)} Abnahmebilder unter \`docs/bildschirmfotos/\``, ); expect(lesen(wurzel, 'docs', 'store-bilder', 'README.md')).toContain( `Die ${String(wort)} dort sind Abnahmedokumente`, ); }); }); describe('docs/veroeffentlichen.md nennt nur Befehle, die es gibt', () => { /* Die Liste sagte im Kopf „Alle Befehle laufen in `app/`.“ – und keiner der acht Python-Aufrufe darunter läuft dort: `tools/` und `data-pipeline/` liegen im Wurzelverzeichnis. Aus `app/` heraus bricht jeder mit „No such file or directory“ ab, ausgerechnet bei den Schritten, deren Auslassen die Liste selbst „den wahrscheinlichsten Fehler“ nennt. Geprüft wird nicht die Ortsangabe im Text, sondern die Wirklichkeit dahinter: Jedes genannte Skript muss vom Wurzelverzeichnis aus dasein. */ const liste = lesen(wurzel, 'docs', 'veroeffentlichen.md'); it('nennt zu jedem Python-Aufruf ein Skript, das vom Wurzelverzeichnis aus dasteht', () => { const skripte = [...liste.matchAll(/python\s+(\S+\.py)/gu)].map((t) => t[1] ?? ''); expect(skripte.length, 'keine Python-Aufrufe in der Liste gefunden').toBeGreaterThan(5); const fehlend = skripte.filter((skript) => !existsSync(join(wurzel, skript))); expect(fehlend, 'genannt, aber vom Wurzelverzeichnis aus nicht vorhanden').toEqual([]); }); it('behauptet nicht, die Befehle liefen in app/', () => { /* Die eine Formulierung, die alle acht Aufrufe falsch verortet. */ expect(liste).not.toMatch(/Alle Befehle laufen in `app\/`\./u); }); }); describe('Der Commit-Haken verspricht nichts, was die Projektregel verbietet', () => { /* `.githooks/pre-commit` schrieb: „`git commit --no-verify` bleibt möglich und soll es bleiben.“ CLAUDE.md sagt „Kein `--no-verify`“, und `.claude/settings.json` sperrt den Aufruf. Die eine Datei, die bei jedem Commit tatsächlich läuft, versprach damit das Gegenteil der Regel, unter der sie steht. */ const haken = lesen(wurzel, '.githooks', 'pre-commit'); const regeln = lesen(wurzel, 'CLAUDE.md'); it('lädt nicht zum Umgehen ein, solange CLAUDE.md es verbietet', () => { expect(regeln, 'CLAUDE.md nennt die Regel nicht mehr').toContain('Kein `--no-verify`'); expect(haken).not.toMatch(/--no-verify` bleibt möglich/u); expect(haken).toContain('NICHT vorgesehen'); }); }); describe('docs/store-notiz.md', () => { const werkzeug = lesen(wurzel, 'tools', 'store_notiz.py'); /** * Holt eine Festlegung aus dem Werkzeug, statt sie zu wiederholen. * * Die Feldgrenze und der Name des Abschnitts sind Zahlen und Zeichenketten, * die an genau einer Stelle stehen sollen. Stünden sie hier ein zweites Mal, * wäre die nächste Änderung eine halbe. */ function ausWerkzeug(name: string, muster: RegExp): string { const treffer = muster.exec(werkzeug); expect(treffer?.[1], `tools/store_notiz.py: ${name} nicht gefunden.`).toBeDefined(); return treffer?.[1] ?? ''; } const ueberschrift = ausWerkzeug('OEFFENTLICH', /^OEFFENTLICH = '(.+)'$/mu); const feldgrenze = Number(ausWerkzeug('FELDGRENZE', /^FELDGRENZE = (\d+)$/mu)); /** Eine Fassung des Changelogs mit ihrem öffentlichen Block. */ interface Fassungsabschnitt { nummer: string; datum: string; /** `null`, wenn der Abschnitt fehlt. */ block: string[] | null; /** Alle `###`-Überschriften der Fassung. */ unterabschnitte: string[]; } function changelogFassungen(): Fassungsabschnitt[] { const zeilen = lesen(wurzel, 'CHANGELOG.md').split('\n'); const kopf = /^## \[([^\]]+)\](?:\s*—\s*(\S+))?\s*$/u; const koepfe: { i: number; nummer: string; datum: string }[] = []; zeilen.forEach((zeile, i) => { const treffer = kopf.exec(zeile); if (treffer) { koepfe.push({ i, nummer: treffer[1] ?? '', datum: treffer[2] ?? '' }); } }); // Hinter der ältesten Fassung steht ein Kommentar, der zu keiner gehört. let schluss = zeilen.length; for (let i = (koepfe.at(-1)?.i ?? 0) + 1; i < zeilen.length; i += 1) { if (zeilen[i]?.startsWith('