// @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, 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('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('