waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests dokumentation.test.ts

34,4 KB Rohdatei
app/tests/dokumentation.test.ts — 868 Zeilen
1 // @vitest-environment node
2 /**
3 * Die Dokumentation muss zur Fassung passen.
4 *
5 * Anlass ist ein wiederkehrendes Muster: Der Stand der Software stand an vier
6 * Stellen – im Plan, in der Liesmich, im Prüfplan und in Gesprächen – und
7 * lief auseinander. Der Plan führte das fertige Glossar als offen, die
8 * Liesmich behauptete, es gebe keine Veröffentlichung, obwohl drei Etiketten
9 * gesetzt waren.
10 *
11 * Geprüft wird deshalb genau der eine Fehler, der wirklich passiert: die
12 * Nummer erhöhen und die Dokumentation vergessen. Ob der Inhalt stimmt, kann
13 * kein Test wissen – dafür ist die Liste in `docs/veroeffentlichen.md` da.
14 */
15
16 import { existsSync, readdirSync, readFileSync } from 'node:fs';
17 import { join } from 'node:path';
18 import { fileURLToPath } from 'node:url';
19
20 import { describe, expect, it } from 'vitest';
21
22 /** Verzeichnis `app/`. */
23 const app = join(fileURLToPath(new URL('..', import.meta.url)));
24 /** Projektwurzel, eine Ebene darüber. */
25 const wurzel = join(app, '..');
26
27 function lesen(...teile: string[]): string {
28 return readFileSync(join(...teile), 'utf8');
29 }
30
31 const fassung = (JSON.parse(lesen(app, 'package.json')) as { version: string }).version;
32
33 describe('Fassungsnummer', () => {
34 it('ist eine gültige Nummer nach Semantic Versioning', () => {
35 expect(fassung).toMatch(/^\d+\.\d+\.\d+$/u);
36 });
37
38 it('steht auch in der Sperrdatei', () => {
39 /*
40 Die Lücke, die genau einmal zwei Fassungen lang offen stand: Beim Sprung
41 auf 0.22.0 blieb `package-lock.json` bei 0.21.0 stehen, und niemandem
42 fiel es auf – die Wachen darunter sehen Changelog, stand.md und README
43 an, nicht die Sperrdatei.
44
45 Sie ist kein Nebenschauplatz: `npm ci` baut daraus, und ein
46 Quelltextarchiv trägt sie mit. Eine Datei, die eine andere Fassung
47 behauptet als die Anwendung, ist eine falsche Angabe – auch wenn sie
48 nichts kaputt macht.
49
50 Geprüft wird beides: der Wurzeleintrag und der Eintrag des eigenen
51 Pakets. npm schreibt beide, wer von Hand ändert, vergisst leicht einen.
52 */
53 const sperre = JSON.parse(lesen(app, 'package-lock.json')) as {
54 version: string;
55 packages: Record<string, { version?: string }>;
56 };
57
58 expect(sperre.version, 'package-lock.json: Wurzeleintrag').toBe(fassung);
59 expect(sperre.packages['']?.version, 'package-lock.json: eigener Paketeintrag').toBe(fassung);
60 });
61 });
62
63 describe('CHANGELOG.md', () => {
64 const pfad = join(wurzel, 'CHANGELOG.md');
65
66 it('ist vorhanden', () => {
67 expect(existsSync(pfad)).toBe(true);
68 });
69
70 it('führt die Fassung aus der package.json', () => {
71 /*
72 Der eigentliche Wächter. Wer die Nummer erhöht und den Verlauf
73 vergisst, bekommt hier eine Meldung, die sagt, was zu tun ist – nicht
74 erst ein Anwender, der wissen will, was sich geändert hat.
75 */
76 const text = lesen(pfad);
77 expect(
78 text.includes(`## [${fassung}]`),
79 `CHANGELOG.md hat keinen Abschnitt "## [${fassung}]". ` +
80 'Beim Anheben der Fassungsnummer den Abschnitt [Unveröffentlicht] ' +
81 'umbenennen und einen neuen leeren darüber anlegen ' +
82 '(siehe docs/veroeffentlichen.md).',
83 ).toBe(true);
84 });
85
86 it('hält einen Abschnitt für Unveröffentlichtes offen', () => {
87 /* Ohne ihn hat die nächste Änderung keinen Platz und landet unter der
88 zuletzt veröffentlichten Nummer – dort ist sie falsch. */
89 expect(lesen(pfad)).toContain('## [Unveröffentlicht]');
90 });
91
92 it('nennt zu jeder Fassung ein Datum', () => {
93 const text = lesen(pfad);
94 const ueberschriften = [...text.matchAll(/^## \[(?!Unveröffentlicht)([^\]]+)\](.*)$/gmu)];
95
96 expect(ueberschriften.length).toBeGreaterThan(0);
97 for (const [, nummer, rest] of ueberschriften) {
98 expect(rest, `Der Fassung ${String(nummer)} fehlt das Datum.`).toMatch(
99 /—\s*\d{4}-\d{2}-\d{2}/u,
100 );
101 }
102 });
103 });
104
105 describe('docs/stand.md', () => {
106 const pfad = join(wurzel, 'docs', 'stand.md');
107
108 it('ist vorhanden', () => {
109 expect(existsSync(pfad)).toBe(true);
110 });
111
112 it('gilt für die Fassung aus der package.json', () => {
113 /*
114 Ein Standdokument, das eine ältere Fassung nennt, ist schlimmer als
115 keines: Es sagt mit der Autorität eines Dokuments etwas Falsches.
116 */
117 const text = lesen(pfad);
118 expect(
119 text.includes(`Fassung ${fassung}`),
120 `docs/stand.md nennt nicht "Fassung ${fassung}". ` +
121 'Beim Anheben der Fassungsnummer den Kopf des Dokuments nachführen ' +
122 'und die Tabellen durchsehen (siehe docs/veroeffentlichen.md).',
123 ).toBe(true);
124 });
125
126 it('verweist auf die übrigen Dokumente, statt sie zu wiederholen', () => {
127 /* Der Umsetzungsstand soll an genau einer Stelle stehen. Die Wegweiser
128 oben im Dokument sind das, was Leser dorthin führt. */
129 const text = lesen(pfad);
130 for (const ziel of ['PLAN.md', 'CHANGELOG.md', 'veroeffentlichen.md']) {
131 expect(text, `docs/stand.md verweist nicht auf ${ziel}.`).toContain(ziel);
132 }
133 });
134 });
135
136 describe('README.md', () => {
137 const pfad = join(wurzel, 'README.md');
138
139 it('ist vorhanden', () => {
140 expect(existsSync(pfad)).toBe(true);
141 });
142
143 it('nennt die Fassung aus der package.json', () => {
144 /*
145 Diese Wache fehlte, und sie hat gefehlt: Das README führte „Fassung
146 0.9.0“, während die Anwendung bei 0.22.0 stand – dreizehn Nummern
147 Rückstand, sichtbar in der ersten Bildschirmseite, die ein Besucher
148 eines öffentlichen Archivs zu sehen bekäme. CHANGELOG und stand.md
149 waren seit je bewacht, das Aushängeschild nicht.
150 */
151 const text = lesen(pfad);
152 expect(
153 text.includes(`Fassung ${fassung}`),
154 `README.md nennt nicht "Fassung ${fassung}". ` +
155 'Beim Anheben der Fassungsnummer den Standblock oben nachführen ' +
156 '(siehe docs/veroeffentlichen.md).',
157 ).toBe(true);
158 });
159
160 it('nennt einen Rückmeldeweg', () => {
161 /* Wer eine Barriere findet, muss sie melden können, ohne die Anwendung
162 erst zu installieren. Ein Archiv ohne Kontakt ist eine Sackgasse. */
163 const text = lesen(pfad);
164 expect(text).toContain('Olaf@olaf-willerding.de');
165 });
166
167 it('behauptet keine macOS-Fassung', () => {
168 /* Es existiert kein macOS-Paket, und nichts davon lief je auf einem Mac
169 (docs/stand.md, Abschnitt 5). docs/store-eintrag.md 9.1 führt „Auch für
170 macOS“ deshalb unter „Nicht belegt – deshalb nicht behauptet“; für das
171 README gilt derselbe Maßstab. Erlaubt bleibt, die Fassung als geplant
172 oder als fehlend zu benennen – verboten ist die Behauptung, sie laufe. */
173 const text = lesen(pfad);
174 expect(text).not.toMatch(/läuft[^.]*\bmacOS\b/iu);
175 expect(text).not.toMatch(/\bWindows und macOS\b/u);
176 });
177 });
178
179 describe('docs/veroeffentlichen.md', () => {
180 it('ist vorhanden und nennt die Schritte, die der Test nicht prüfen kann', () => {
181 const text = lesen(wurzel, 'docs', 'veroeffentlichen.md');
182
183 expect(text).toContain('CHANGELOG.md');
184 expect(text).toContain('stand.md');
185 // Ohne gebautes Paket sagt ein grüner E2E-Lauf nichts über das Paket.
186 expect(text).toContain('dist:win');
187 });
188 });
189
190 /*
191 Der Updatebericht.
192
193 Er ist **erzeugt** (`python tools/updatebericht.py`) und nicht von Hand
194 gepflegt — der Mittelteil stammt Wort für Wort aus dem Changelog-Abschnitt
195 der Fassung. Genau deshalb braucht er eine Wache: Ein erzeugtes Dokument,
196 das niemand neu erzeugt, ist stiller falsch als ein handgeschriebenes, weil
197 niemand mehr hinsieht.
198 */
199 describe('docs/updatebericht.md', () => {
200 const pfad = join(wurzel, 'docs', 'updatebericht.md');
201
202 /**
203 * Entfernt die Verweisziele, behält den sichtbaren Text.
204 *
205 * Der Bericht liegt in `docs/`, der Changelog im Wurzelverzeichnis; das
206 * Werkzeug rechnet die relativen Ziele deshalb um. Verglichen wird der
207 * Wortlaut, nicht der Pfad — sonst prüfte dieser Test die Umrechnung statt
208 * der Aktualität.
209 */
210 function ohneVerweisziele(text: string): string {
211 return text.replace(/\]\([^)]*\)/gu, ']()');
212 }
213
214 /** Der Changelog-Abschnitt einer Fassung, ohne Überschrift und Trennlinie. */
215 function changelogAbschnitt(nummer: string): string {
216 const text = lesen(wurzel, 'CHANGELOG.md');
217 const kopf = new RegExp(`^## \\[${nummer.replace(/\./gu, '\\.')}\\]`, 'mu');
218 const start = text.search(kopf);
219 if (start === -1) {
220 return '';
221 }
222 const rest = text.slice(start);
223 const naechste = rest.slice(1).search(/^## \[/mu);
224 const roh = naechste === -1 ? rest : rest.slice(0, naechste + 1);
225 return roh
226 .split('\n')
227 .slice(1)
228 .join('\n')
229 .replace(/\n---\s*$/u, '')
230 .trim();
231 }
232
233 it('ist vorhanden', () => {
234 expect(existsSync(pfad), 'docs/updatebericht.md fehlt.').toBe(true);
235 });
236
237 it('gilt für die Fassung aus der package.json', () => {
238 expect(
239 lesen(pfad).includes(`Updatebericht zur Fassung ${fassung}`),
240 `docs/updatebericht.md gilt nicht für Fassung ${fassung}. ` +
241 'Neu erzeugen mit: python tools/updatebericht.py',
242 ).toBe(true);
243 });
244
245 it('gibt den Changelog-Abschnitt der Fassung unverändert wieder', () => {
246 /* Die eigentliche Zusage. Ohne sie stünde im Bericht irgendwann etwas
247 anderes als im Änderungsverlauf — und niemand wüsste, welches von
248 beiden gilt. */
249 const bericht = lesen(pfad);
250 const anfang = bericht.indexOf('## Was sich geändert hat');
251 const ende = bericht.indexOf('## Wie Sie aktualisieren');
252
253 expect(anfang, 'Der Bericht hat keinen Abschnitt „Was sich geändert hat".').toBeGreaterThan(-1);
254 expect(ende, 'Der Bericht hat keinen Abschnitt „Wie Sie aktualisieren".').toBeGreaterThan(-1);
255
256 const imBericht = bericht
257 .slice(anfang + '## Was sich geändert hat'.length, ende)
258 .replace(/\n---\s*$/u, '')
259 .trim();
260
261 expect(
262 ohneVerweisziele(imBericht),
263 'docs/updatebericht.md gibt den Changelog-Abschnitt nicht mehr wieder. ' +
264 'Neu erzeugen mit: python tools/updatebericht.py',
265 ).toBe(ohneVerweisziele(changelogAbschnitt(fassung)));
266 });
267
268 it('führt keinen toten Verweis', () => {
269 /* Ein erzeugtes Dokument bekommt seine Verweise umgerechnet. Rechnet die
270 Umrechnung falsch, merkt es beim Lesen niemand — beim Klicken schon. */
271 const bericht = lesen(pfad);
272 const tot = [...bericht.matchAll(/\]\(([^)#]+)\)/gu)]
273 .map((treffer) => treffer[1] ?? '')
274 .filter((ziel) => !/^(https?:|mailto:)/u.test(ziel))
275 .filter((ziel) => !existsSync(join(wurzel, 'docs', ziel)));
276
277 expect([...new Set(tot)]).toEqual([]);
278 });
279
280 it('nennt die stehenden Vorbehalte', () => {
281 /* Sie sind der Grund, warum der Bericht mehr ist als eine Kopie des
282 Changelogs: Wer die Datei in die Hand bekommt, muss ohne Nachfrage
283 wissen, was die Fassung nicht leistet. */
284 const text = lesen(pfad);
285 for (const zusage of ['nicht signiert', 'macOS', 'Prüfungsausschuss', 'Ihr Lernstand bleibt']) {
286 expect(text, `docs/updatebericht.md nennt „${zusage}" nicht.`).toContain(zusage);
287 }
288 });
289 });
290
291 /*
292 Die öffentlichen Versionshinweise.
293
294 Jede Fassung braucht einen Text, der veröffentlicht werden darf – auch eine
295 Unterfassung, die nur einen Fehler behebt. Er steht im Changelog unter
296 `### Für die Öffentlichkeit`, dem Gegenstück zu `### Für die Werkbank`, und
297 `tools/store_notiz.py` macht daraus `docs/store-notiz.md`.
298
299 Warum ein Test und nicht nur ein Haken auf einer Liste: Der Text entsteht am
300 Ende einer Fassung, wenn alles andere fertig ist und niemand mehr Lust hat.
301 Genau dort wird er vergessen. Ein Haken erinnert daran, ein roter Test hält
302 an.
303
304 Arbeitsteilung mit dem Werkzeug: Hier steht, was **strukturell** stimmen
305 muss – der Block ist da, er steht auch in der erzeugten Datei, und er trägt
306 nichts offensichtlich Internes. Die vollständige Liste der
307 Öffentlichkeitsregeln führt `tools/store_notiz.py`; sie hier zu wiederholen
308 hieße, zwei Listen auseinanderlaufen zu lassen. Wer eine Regel dort verletzt,
309 bekommt kein Dokument mehr erzeugt – und fällt spätestens über die Prüfung
310 auf, dass die erzeugte Datei nicht mehr zum Changelog passt.
311 */
312 describe('Der Updatebericht widerspricht sich nicht selbst', () => {
313 /*
314 Die Wache, die gefehlt hat.
315
316 `docs/updatebericht.md` ist das eine Dokument, das dem Anwender mit der
317 Installationsdatei in die Hand gegeben wird. Sein Mittelteil kommt aus dem
318 Changelog und wird hier oben Wort für Wort verglichen; der Rahmen bleibt
319 von Hand — und genau dort stand bis Fassung 0.27.2 „ein öffentliches
320 Archiv gibt es bislang nicht“, während 83 Zeilen weiter oben im selben
321 Blatt „Der Quelltext dieser Software ist jetzt öffentlich einsehbar“ zu
322 lesen war. Bei der EUPL ist die Verfügbarkeit des Quelltextes kein
323 Nebensatz; wer den Rahmen las, ging den Anfrageweg statt der Adresse.
324
325 Geprüft wird gegen `README.md` als Quelle der Wahrheit über das Archiv,
326 nicht gegen eine hier wiederholte Adresse.
327 */
328 const bericht = lesen(wurzel, 'docs', 'updatebericht.md');
329 const liesmich = lesen(wurzel, 'README.md');
330 const klonadresse = /`git clone (https:\/\/[^`]+)`/u.exec(liesmich)?.[1];
331
332 it('nennt dieselbe Klonadresse wie die Liesmich', () => {
333 expect(klonadresse, 'README.md nennt keine Klonadresse mehr').toBeDefined();
334 expect(bericht).toContain(klonadresse);
335 });
336
337 it('bestreitet das Archiv nicht, das die Liesmich nennt', () => {
338 /* Geprüft wird der **Rahmen**, nicht der Mittelteil: Der Mittelteil kommt
339 aus dem Changelog und darf den alten Wortlaut zitieren, wenn er dessen
340 Behebung beschreibt. Der Rahmen ist die Stelle, an der der Satz als
341 Zusage stand. */
342 const rahmen = bericht.slice(bericht.indexOf('## Woran Sie die Angaben nachprüfen können'));
343 expect(rahmen.length, 'der Schlussrahmen fehlt im Bericht').toBeGreaterThan(100);
344
345 expect(rahmen).not.toMatch(/öffentliches Archiv gibt es (bislang )?nicht/u);
346 expect(rahmen).not.toMatch(/nur auf Anfrage erhältlich/u);
347 });
348 });
349
350 describe('Die Zahl der Abnahmebilder ist gezählt', () => {
351 /*
352 Zwei Dokumente nannten „dreizehn“, im Verzeichnis lagen vierzehn – seit
353 `14-kapitelwahl.png` mit Fassung 0.27.1 dazukam. Abschnitt 5 der
354 Veröffentlichungsliste verlangt, die Bilder nach dem Bauen neu zu ziehen;
355 wer das Ergebnis gegen die Beschreibung hält, sucht sonst ein Bild zu
356 wenig und weiß nicht, ob eines fehlt oder die Zahl alt ist.
357 */
358 const ZAHLWORT: Record<number, string> = {
359 10: 'zehn',
360 11: 'elf',
361 12: 'zwölf',
362 13: 'dreizehn',
363 14: 'vierzehn',
364 15: 'fünfzehn',
365 16: 'sechzehn',
366 17: 'siebzehn',
367 18: 'achtzehn',
368 19: 'neunzehn',
369 20: 'zwanzig',
370 };
371
372 const bilder = readdirSync(join(wurzel, 'docs', 'bildschirmfotos')).filter((n) =>
373 n.endsWith('.png'),
374 );
375
376 it('steht so in docs/stand.md und in docs/store-bilder/README.md', () => {
377 const wort = ZAHLWORT[bilder.length];
378 expect(wort, `Für ${String(bilder.length)} fehlt das Zahlwort in dieser Wache`).toBeDefined();
379
380 expect(lesen(wurzel, 'docs', 'stand.md')).toContain(
381 `Die ${String(wort)} Abnahmebilder unter \`docs/bildschirmfotos/\``,
382 );
383 expect(lesen(wurzel, 'docs', 'store-bilder', 'README.md')).toContain(
384 `Die ${String(wort)} dort sind Abnahmedokumente`,
385 );
386 });
387 });
388
389 describe('docs/veroeffentlichen.md nennt nur Befehle, die es gibt', () => {
390 /*
391 Die Liste sagte im Kopf „Alle Befehle laufen in `app/`.“ – und keiner der
392 acht Python-Aufrufe darunter läuft dort: `tools/` und `data-pipeline/`
393 liegen im Wurzelverzeichnis. Aus `app/` heraus bricht jeder mit „No such
394 file or directory“ ab, ausgerechnet bei den Schritten, deren Auslassen
395 die Liste selbst „den wahrscheinlichsten Fehler“ nennt.
396
397 Geprüft wird nicht die Ortsangabe im Text, sondern die Wirklichkeit
398 dahinter: Jedes genannte Skript muss vom Wurzelverzeichnis aus dasein.
399 */
400 const liste = lesen(wurzel, 'docs', 'veroeffentlichen.md');
401
402 it('nennt zu jedem Python-Aufruf ein Skript, das vom Wurzelverzeichnis aus dasteht', () => {
403 const skripte = [...liste.matchAll(/python\s+(\S+\.py)/gu)].map((t) => t[1] ?? '');
404
405 expect(skripte.length, 'keine Python-Aufrufe in der Liste gefunden').toBeGreaterThan(5);
406 const fehlend = skripte.filter((skript) => !existsSync(join(wurzel, skript)));
407 expect(fehlend, 'genannt, aber vom Wurzelverzeichnis aus nicht vorhanden').toEqual([]);
408 });
409
410 it('behauptet nicht, die Befehle liefen in app/', () => {
411 /* Die eine Formulierung, die alle acht Aufrufe falsch verortet. */
412 expect(liste).not.toMatch(/Alle Befehle laufen in `app\/`\./u);
413 });
414 });
415
416 describe('Der Commit-Haken verspricht nichts, was die Projektregel verbietet', () => {
417 /*
418 `.githooks/pre-commit` schrieb: „`git commit --no-verify` bleibt möglich
419 und soll es bleiben.“ CLAUDE.md sagt „Kein `--no-verify`“, und
420 `.claude/settings.json` sperrt den Aufruf. Die eine Datei, die bei jedem
421 Commit tatsächlich läuft, versprach damit das Gegenteil der Regel, unter
422 der sie steht.
423 */
424 const haken = lesen(wurzel, '.githooks', 'pre-commit');
425 const regeln = lesen(wurzel, 'CLAUDE.md');
426
427 it('lädt nicht zum Umgehen ein, solange CLAUDE.md es verbietet', () => {
428 expect(regeln, 'CLAUDE.md nennt die Regel nicht mehr').toContain('Kein `--no-verify`');
429 expect(haken).not.toMatch(/--no-verify` bleibt möglich/u);
430 expect(haken).toContain('NICHT vorgesehen');
431 });
432 });
433
434 describe('docs/store-notiz.md', () => {
435 const werkzeug = lesen(wurzel, 'tools', 'store_notiz.py');
436
437 /**
438 * Holt eine Festlegung aus dem Werkzeug, statt sie zu wiederholen.
439 *
440 * Die Feldgrenze und der Name des Abschnitts sind Zahlen und Zeichenketten,
441 * die an genau einer Stelle stehen sollen. Stünden sie hier ein zweites Mal,
442 * wäre die nächste Änderung eine halbe.
443 */
444 function ausWerkzeug(name: string, muster: RegExp): string {
445 const treffer = muster.exec(werkzeug);
446 expect(treffer?.[1], `tools/store_notiz.py: ${name} nicht gefunden.`).toBeDefined();
447 return treffer?.[1] ?? '';
448 }
449
450 const ueberschrift = ausWerkzeug('OEFFENTLICH', /^OEFFENTLICH = '(.+)'$/mu);
451 const feldgrenze = Number(ausWerkzeug('FELDGRENZE', /^FELDGRENZE = (\d+)$/mu));
452
453 /** Eine Fassung des Changelogs mit ihrem öffentlichen Block. */
454 interface Fassungsabschnitt {
455 nummer: string;
456 datum: string;
457 /** `null`, wenn der Abschnitt fehlt. */
458 block: string[] | null;
459 /** Alle `###`-Überschriften der Fassung. */
460 unterabschnitte: string[];
461 }
462
463 function changelogFassungen(): Fassungsabschnitt[] {
464 const zeilen = lesen(wurzel, 'CHANGELOG.md').split('\n');
465 const kopf = /^## \[([^\]]+)\](?:\s*—\s*(\S+))?\s*$/u;
466
467 const koepfe: { i: number; nummer: string; datum: string }[] = [];
468 zeilen.forEach((zeile, i) => {
469 const treffer = kopf.exec(zeile);
470 if (treffer) {
471 koepfe.push({ i, nummer: treffer[1] ?? '', datum: treffer[2] ?? '' });
472 }
473 });
474
475 // Hinter der ältesten Fassung steht ein Kommentar, der zu keiner gehört.
476 let schluss = zeilen.length;
477 for (let i = (koepfe.at(-1)?.i ?? 0) + 1; i < zeilen.length; i += 1) {
478 if (zeilen[i]?.startsWith('<!--')) {
479 schluss = i;
480 break;
481 }
482 }
483
484 return koepfe.map((eintrag, k) => {
485 const ende = koepfe[k + 1]?.i ?? schluss;
486 const inhalt = zeilen.slice(eintrag.i + 1, ende);
487 return {
488 nummer: eintrag.nummer,
489 datum: eintrag.datum,
490 unterabschnitte: inhalt.filter((z) => z.startsWith('### ')),
491 block: blockLesen(inhalt),
492 };
493 });
494 }
495
496 function blockLesen(inhalt: string[]): string[] | null {
497 const start = inhalt.findIndex((z) => z.trim() === ueberschrift);
498 if (start === -1) {
499 return null;
500 }
501 let ende = inhalt.length;
502 for (let i = start + 1; i < inhalt.length; i += 1) {
503 if (inhalt[i]?.startsWith('### ') || inhalt[i]?.startsWith('## ')) {
504 ende = i;
505 break;
506 }
507 }
508 const block = inhalt.slice(start + 1, ende);
509 while (block.length > 0 && !(block[0] ?? '').trim()) {
510 block.shift();
511 }
512 while (block.length > 0 && !(block.at(-1) ?? '').trim()) {
513 block.pop();
514 }
515 return block;
516 }
517
518 /** Der Block als reiner Text – so, wie ihn eine Eingabemaske zählt. */
519 function reintext(block: string[]): string {
520 const absaetze: string[] = [];
521 let laufend = '';
522 for (const zeile of block) {
523 const blank = zeile.trim();
524 if (!blank) {
525 continue;
526 }
527 if (blank.startsWith('- ')) {
528 if (laufend) {
529 absaetze.push(laufend);
530 }
531 laufend = `- ${blank.slice(2)}`;
532 } else {
533 laufend = laufend ? `${laufend} ${blank}` : blank;
534 }
535 }
536 if (laufend) {
537 absaetze.push(laufend);
538 }
539 return absaetze
540 .join('\n')
541 .replace(/\*\*([^*]+)\*\*/gu, '$1')
542 .replace(/(?<![*\w])\*([^*]+)\*(?![*\w])/gu, '$1')
543 .normalize('NFC');
544 }
545
546 const fassungen = changelogFassungen();
547
548 it('findet überhaupt Fassungen im Changelog', () => {
549 /* Wäre der Kopf-Ausdruck falsch, liefen alle folgenden Prüfungen über eine
550 leere Liste und wären grün, ohne etwas geprüft zu haben. */
551 expect(fassungen.length, 'CHANGELOG.md: keine Fassungsüberschrift erkannt.').toBeGreaterThan(
552 10,
553 );
554 expect(ueberschrift).toBe('### Für die Öffentlichkeit');
555 expect(feldgrenze).toBeGreaterThan(0);
556 });
557
558 it('gibt jeder Fassung einen öffentlichen Text', () => {
559 /* Die eigentliche Zusage: Es gibt keine Fassung ohne. Ausgenommen ist ein
560 leerer Abschnitt `[Unveröffentlicht]` – solange dort nichts steht, gibt
561 es auch nichts zu veröffentlichen. Sobald eine Rubrik auftaucht, gilt
562 die Pflicht. */
563 const ohne = fassungen
564 .filter((f) => f.block === null)
565 .filter((f) => !(f.nummer === 'Unveröffentlicht' && f.unterabschnitte.length === 0))
566 .map((f) => f.nummer);
567
568 expect(
569 ohne,
570 `Diesen Fassungen fehlt „${ueberschrift}" in CHANGELOG.md. ` +
571 'Ohne diesen Abschnitt gibt es keinen Text, der veröffentlicht werden darf.',
572 ).toEqual([]);
573 });
574
575 it('hält jeden öffentlichen Text im Rahmen des Feldes', () => {
576 /* Der Text geht in ein Feld mit fester Grenze. Zu lang heißt: abgeschnitten
577 – und abgeschnitten heißt, dass der letzte Punkt mitten im Satz endet. */
578 const zuLang = fassungen
579 .filter((f) => f.block !== null)
580 .map((f) => ({ nummer: f.nummer, zeichen: reintext(f.block ?? []).length }))
581 .filter((f) => f.zeichen > feldgrenze);
582
583 expect(zuLang, `Erlaubt sind ${String(feldgrenze)} Zeichen.`).toEqual([]);
584 });
585
586 it('lässt nichts Internes in einen öffentlichen Text', () => {
587 /* Nur die auffälligsten Muster – die vollständige Liste führt
588 `tools/store_notiz.py`. Hier stehen die drei, die beim schnellen
589 Schreiben wirklich passieren: ein Dateiname, ein Verweis auf ein anderes
590 Dokument, eine Auszeichnung als Quelltext. */
591 const auffaellig: { muster: RegExp; was: string }[] = [
592 { muster: /`/u, was: 'Auszeichnung als Quelltext' },
593 { muster: /\]\(/u, was: 'Verweis auf ein Dokument' },
594 {
595 muster: /\b[\w-]+\.(?:json|ts|tsx|js|mjs|md|py|yml|html|exe|db|png)\b/u,
596 was: 'Dateiname',
597 },
598 ];
599
600 const treffer: string[] = [];
601 for (const f of fassungen) {
602 if (f.block === null) {
603 continue;
604 }
605 const text = f.block.join('\n');
606 for (const { muster, was } of auffaellig) {
607 const gefunden = muster.exec(text);
608 if (gefunden) {
609 treffer.push(`${f.nummer}: ${was} – „${gefunden[0]}"`);
610 }
611 }
612 }
613
614 expect(treffer).toEqual([]);
615 });
616
617 it('ist vorhanden und gibt jeden Block unverändert wieder', () => {
618 /* Ohne diese Prüfung stünde im veröffentlichten Dokument irgendwann etwas
619 anderes als im Changelog – und niemand wüsste, welches von beiden gilt.
620 Sie fängt zugleich den Fall ab, dass jemand eine Öffentlichkeitsregel
621 verletzt hat: Das Werkzeug erzeugt dann nichts mehr, und die Datei bleibt
622 zurück. */
623 const pfad = join(wurzel, 'docs', 'store-notiz.md');
624 expect(existsSync(pfad), 'docs/store-notiz.md fehlt.').toBe(true);
625
626 const notiz = lesen(pfad);
627 const fehlend: string[] = [];
628 for (const f of fassungen) {
629 if (f.block === null || f.nummer === 'Unveröffentlicht') {
630 /* Was noch keine Fassung hat, wird nicht veröffentlicht – der Text
631 dafür muss trotzdem geschrieben sein, und die Prüfungen oben sehen
632 ihn sich an. */
633 continue;
634 }
635 const kopf = f.datum ? `### ${f.nummer} — ${f.datum}` : `### ${f.nummer}`;
636 if (!notiz.includes(`${kopf}\n\n${f.block.join('\n')}`)) {
637 fehlend.push(f.nummer);
638 }
639 }
640
641 expect(
642 fehlend,
643 'docs/store-notiz.md gibt diese Fassungen nicht mehr so wieder wie CHANGELOG.md. ' +
644 'Neu erzeugen mit: python tools/store_notiz.py',
645 ).toEqual([]);
646 });
647
648 it('hält den Text der laufenden Fassung zum Einfügen bereit', () => {
649 /* Wer eine Einreichung macht, soll den Text kopieren können, statt ihn aus
650 dem Changelog zusammenzusuchen – samt der Zahl, die die Eingabemaske
651 gleich selbst zählen wird. */
652 const notiz = lesen(wurzel, 'docs', 'store-notiz.md');
653 const aktuell = fassungen.find((f) => f.nummer === fassung);
654
655 expect(
656 aktuell?.block,
657 `CHANGELOG.md hat keinen öffentlichen Text für ${fassung}.`,
658 ).toBeTruthy();
659 expect(notiz).toContain(`## Zum Einfügen: Fassung ${fassung}`);
660 expect(notiz).toContain(reintext(aktuell?.block ?? []));
661 });
662 });
663
664 describe('Die Adresse des Quelltextarchivs steht überall gleich', () => {
665 /*
666 Die Wache, die gefehlt hat.
667
668 Die Übersichtsseite des Archivs war als Abfrage geschrieben
669 (`?page=code&repo=waffensachkunde`) und ist auf einen sauberen Pfad
670 umgestellt worden. Am 03.09.2026 stand die neue Form in `README.md`, die
671 alte noch in `docs/fernarchiv.md`, `docs/stand.md` und
672 `docs/store-eintrag.md` — drei Verweise, die ins Leere zeigen, wenn die
673 alte Form nicht mehr aufgelöst wird.
674
675 Geprüft wird gegen `README.md` als Quelle: Was dort steht, gilt. Eine
676 Adresse in zwei Schreibweisen ist immer eine zu viel — eine davon ist
677 falsch, und niemand merkt welche.
678 */
679 const liesmich = lesen(wurzel, 'README.md');
680 const DOKUMENTE = ['stand.md', 'fernarchiv.md', 'store-eintrag.md', 'datenschutz.md'];
681
682 it('nennt die Übersichtsseite nirgends mehr als Abfrage', () => {
683 const uebersicht = /<(https:\/\/[^>]*\/quelltext[^>]*)>/u.exec(liesmich)?.[1];
684 expect(uebersicht, 'README.md nennt keine Übersichtsseite unter /quelltext mehr').toBeDefined();
685
686 const alt = DOKUMENTE.filter((name) => lesen(wurzel, 'docs', name).includes('?page=code'));
687 expect(alt, 'tragen noch die alte Abfrageform der Archivadresse').toEqual([]);
688 });
689
690 it('nennt in jedem Dokument dieselbe Klonadresse wie die Liesmich', () => {
691 const klon = /`git clone (https:\/\/[^`]+)`/u.exec(liesmich)?.[1] ?? '';
692 expect(klon).not.toBe('');
693
694 for (const name of DOKUMENTE) {
695 const text = lesen(wurzel, 'docs', name);
696 if (!text.includes('git clone')) {
697 continue;
698 }
699 const eigene = [...text.matchAll(/git clone (https:\/\/\S+?)`/gu)].map((t) => t[1]);
700 const abweichend = eigene.filter((adresse) => adresse !== klon);
701 expect(abweichend, `${name} nennt eine andere Klonadresse als README.md`).toEqual([]);
702 }
703 });
704 });
705
706 describe('docs/stand.md führt nichts als offen, was die Unterlagen als erledigt belegen', () => {
707 /*
708 Die Wache, die gefehlt hat — und der teuerste Fehler dieses Dokuments.
709
710 CLAUDE.md macht `docs/stand.md` zur einzigen Stelle für den
711 Umsetzungsstand. Abschnitt 8 ist die Liste, die abgearbeitet wird. Stand
712 dort etwas als offen, das erledigt ist, sucht der Leser Arbeit, die schon
713 getan ist — und übersieht den Punkt daneben, der wirklich offen ist. Am
714 03.09.2026 traf das auf drei Dinge zu: das MSIX-Paket (seit 0.22.0
715 gebaut), den BVA-Brief (am 29.08.2026 versendet) und die
716 Store-Einreichung (zweimal abgesendet).
717
718 Geprüft wird nicht die Liste selbst, sondern der Widerspruch zwischen
719 Dokumenten: Was ein anderes Dokument mit Datum als erledigt belegt, darf
720 hier nicht als Aufgabe stehen. Jeder Fall hängt an der Quelle, die den
721 Beleg trägt — fällt der Beleg weg, greift der Fall nicht mehr.
722 */
723 const stand = lesen(wurzel, 'docs', 'stand.md');
724
725 it('nennt den BVA-Brief nicht als zu versendend, wenn er versendet ist', () => {
726 const brief = lesen(wurzel, 'docs', 'bva-anschreiben.md');
727 const versendet = /\*\*Versendet am (\d{2}\.\d{2}\.\d{4})\.?\*\*/u.exec(brief);
728
729 if (versendet === null) {
730 /* Noch nicht versendet – dann darf und soll die Liste ihn führen. */
731 expect(stand).toContain('BVA-Brief');
732 return;
733 }
734
735 expect(stand, 'der Brief ist versendet').not.toContain('**versendet ist er nicht**');
736 expect(stand).not.toContain('**Der BVA-Brief ist zu versenden.**');
737 expect(stand, 'das Versanddatum gehört in den Stand').toContain(versendet[1] ?? '');
738 });
739
740 it('behauptet nicht, es werde nur NSIS gebaut, wenn ein appx-Block dasteht', () => {
741 const bauplan = lesen(app, 'electron-builder.yml');
742 expect(bauplan, 'kein appx-Block mehr – dieser Fall ist dann gegenstandslos').toMatch(
743 /^appx:$/mu,
744 );
745
746 expect(stand).not.toContain('baut nur NSIS');
747 });
748
749 it('nennt den Store-Eintrag nicht als nicht absendbar, wenn eingereicht ist', () => {
750 const eintrag = lesen(wurzel, 'docs', 'store-eintrag.md');
751 const eingereicht = /Einreichung\s+\*\*[\d.]+\*\*\s+abgesendet worden/u.test(eintrag);
752
753 expect(eingereicht, 'store-eintrag.md meldet keine Einreichung mehr').toBe(true);
754
755 /* Verboten ist die Behauptung, nicht das Zitat: Rückblicke dürfen den
756 alten Wortlaut in Anführungszeichen nennen. Geprüft wird deshalb die
757 zusagende Form und die Stand-Spalte der Zeile. */
758 expect(stand).not.toContain('**Absendbar ist er nicht.**');
759 expect(stand).not.toContain('| Vorhaben, Entwurf angelegt |');
760 });
761 });
762
763 describe('Die Zahlen im Verzeichnisbaum von app/README.md sind gezählt', () => {
764 /*
765 Die Wache, die gefehlt hat.
766
767 Der Baum im Abschnitt „Verzeichnisstruktur“ nennt zu neun Stellen eine
768 Anzahl. Für die IPC-Kanäle gibt es seit Fassung 0.27.2 einen Fall darunter
769 – die Zahl stimmte deshalb. Fünf der übrigen waren mitgewandert, ohne
770 nachgeführt zu werden: `main/` 31 statt 32, `lernen/` 38 statt 40,
771 `ueber/` 3 statt 4, `hooks/` 26 statt 29, `tests/` 72 statt 79. Wer den
772 Umfang eines Bereichs daran abschätzt – etwa, um zu beurteilen, ob eine
773 Testdatei fehlt oder ein Haken schon existiert –, rechnet mit zu wenig.
774
775 CLAUDE.md verlangt: „Zahlen werden gemessen, nicht geschätzt.“ Also wird
776 hier gemessen, und zwar so, wie der Baum zählt: `main/` alle Dateien,
777 `tests/` nur die Testdateien, `e2e/` nur die Prüfdateien (die drei Helfer
778 stehen im Baum eigens daneben).
779 */
780 const readme = lesen(app, 'README.md');
781
782 function dateien(...teile: string[]): string[] {
783 return readdirSync(join(app, ...teile), { withFileTypes: true })
784 .filter((eintrag) => eintrag.isFile())
785 .map((eintrag) => eintrag.name);
786 }
787
788 const faelle: readonly [string, number, string][] = [
789 ['main/', dateien('src', 'main').length, 'voller Systemzugriff) – %d Dateien'],
790 [
791 'tests/',
792 dateien('tests').filter((n) => /\.test\.tsx?$/u.test(n)).length,
793 'Unit-Tests (Vitest, jsdom) – %d Testdateien',
794 ],
795 [
796 'e2e/',
797 dateien('e2e').filter((n) => n.endsWith('.spec.ts')).length,
798 'gegen echtes Electron) – %d Prüfdateien',
799 ],
800 ['hooks/', dateien('src', 'renderer', 'src', 'hooks').length, 'hooks/ %d Haken'],
801 ];
802
803 for (const [was, anzahl, vorlage] of faelle) {
804 it(`nennt zu ${was} die gezählte Anzahl`, () => {
805 expect(readme, `${was} zählt ${String(anzahl)}`).toContain(
806 vorlage.replace('%d', String(anzahl)),
807 );
808 });
809 }
810
811 it('nennt zu jedem Bausteinbereich die gezählte Anzahl', () => {
812 const bereiche = ['lernen', 'pruefung', 'profil', 'suche', 'ueber', 'hilfe'];
813 const gezaehlt = bereiche.map(
814 (name) =>
815 `${name}/ (${String(dateien('src', 'renderer', 'src', 'components', name).length)})`,
816 );
817
818 const fehlend = gezaehlt.filter((eintrag) => !readme.includes(eintrag));
819 expect(fehlend, 'so steht es nicht im Baum von app/README.md').toEqual([]);
820 });
821 });
822
823 describe('Die Zahl der IPC-Kanäle in app/README.md ist gezählt', () => {
824 /*
825 Befund der Prüfrunde zu 0.27.2. Die Übersicht führte „41 Kanäle“; gezählt
826 waren es 44 — in `IPC_KANAELE`, unter den Handlern in `main/ipc.ts` und
827 unter den vom Preload benutzten Kanälen, jeweils deckungsgleich. Drei
828 Kanäle sind seit dem Schreiben der Zeile dazugekommen.
829
830 Die Zahl beziffert die Größe der einzigen Grenze zwischen Oberfläche und
831 Anwendungskern. Wer die Angriffsfläche über das README abschätzt,
832 unterschätzte sie — und für `app/README.md` gab es bis dahin überhaupt
833 keine Wache.
834 */
835 it('nennt so viele Kanäle, wie der Vertrag führt', () => {
836 const vertrag = readFileSync(join(wurzel, 'app', 'src', 'shared', 'ipc.ts'), 'utf8');
837 const block = vertrag.slice(vertrag.indexOf('IPC_KANAELE'));
838 const kanaele = new Set(
839 [...block.slice(0, block.indexOf('];')).matchAll(/'([a-z-]+:[a-z-]+)'/gu)].map((t) => t[1]),
840 );
841 expect(kanaele.size, 'Keine Kanäle gefunden – der Test misst nichts').toBeGreaterThan(30);
842
843 const readme = readFileSync(join(wurzel, 'app', 'README.md'), 'utf8');
844 const genannt = /\((\d+) Kanäle/u.exec(readme);
845
846 expect(genannt, 'Die Zeile mit der Kanalzahl fehlt').not.toBeNull();
847 expect(Number(genannt?.[1]), 'app/README.md nennt eine andere Zahl als der Vertrag').toBe(
848 kanaele.size,
849 );
850 });
851
852 it('hält Vertrag und Handler deckungsgleich', () => {
853 /* Die Gegenprobe zur Zählung: Ein Kanal ohne Handler wäre eine tote
854 Zusage, ein Handler ohne Eintrag ein Tor am Vertrag vorbei. */
855 const vertrag = readFileSync(join(wurzel, 'app', 'src', 'shared', 'ipc.ts'), 'utf8');
856 const block = vertrag.slice(vertrag.indexOf('IPC_KANAELE'));
857 const kanaele = new Set(
858 [...block.slice(0, block.indexOf('];')).matchAll(/'([a-z-]+:[a-z-]+)'/gu)].map((t) => t[1]),
859 );
860 const main = readFileSync(join(wurzel, 'app', 'src', 'main', 'ipc.ts'), 'utf8');
861 const handler = new Set(
862 [...main.matchAll(/behandeln\(\s*'([a-z-]+:[a-z-]+)'/gu)].map((t) => t[1]),
863 );
864
865 expect([...kanaele].filter((k) => !handler.has(k))).toEqual([]);
866 expect([...handler].filter((k) => !kanaele.has(k))).toEqual([]);
867 });
868 });