waffensachkunde

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

/ app tests dokumentation.test.ts

20,1 KB Rohdatei
app/tests/dokumentation.test.ts — 540 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, 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('docs/store-notiz.md', () => {
313 const werkzeug = lesen(wurzel, 'tools', 'store_notiz.py');
314
315 /**
316 * Holt eine Festlegung aus dem Werkzeug, statt sie zu wiederholen.
317 *
318 * Die Feldgrenze und der Name des Abschnitts sind Zahlen und Zeichenketten,
319 * die an genau einer Stelle stehen sollen. Stünden sie hier ein zweites Mal,
320 * wäre die nächste Änderung eine halbe.
321 */
322 function ausWerkzeug(name: string, muster: RegExp): string {
323 const treffer = muster.exec(werkzeug);
324 expect(treffer?.[1], `tools/store_notiz.py: ${name} nicht gefunden.`).toBeDefined();
325 return treffer?.[1] ?? '';
326 }
327
328 const ueberschrift = ausWerkzeug('OEFFENTLICH', /^OEFFENTLICH = '(.+)'$/mu);
329 const feldgrenze = Number(ausWerkzeug('FELDGRENZE', /^FELDGRENZE = (\d+)$/mu));
330
331 /** Eine Fassung des Changelogs mit ihrem öffentlichen Block. */
332 interface Fassungsabschnitt {
333 nummer: string;
334 datum: string;
335 /** `null`, wenn der Abschnitt fehlt. */
336 block: string[] | null;
337 /** Alle `###`-Überschriften der Fassung. */
338 unterabschnitte: string[];
339 }
340
341 function changelogFassungen(): Fassungsabschnitt[] {
342 const zeilen = lesen(wurzel, 'CHANGELOG.md').split('\n');
343 const kopf = /^## \[([^\]]+)\](?:\s*—\s*(\S+))?\s*$/u;
344
345 const koepfe: { i: number; nummer: string; datum: string }[] = [];
346 zeilen.forEach((zeile, i) => {
347 const treffer = kopf.exec(zeile);
348 if (treffer) {
349 koepfe.push({ i, nummer: treffer[1] ?? '', datum: treffer[2] ?? '' });
350 }
351 });
352
353 // Hinter der ältesten Fassung steht ein Kommentar, der zu keiner gehört.
354 let schluss = zeilen.length;
355 for (let i = (koepfe.at(-1)?.i ?? 0) + 1; i < zeilen.length; i += 1) {
356 if (zeilen[i]?.startsWith('<!--')) {
357 schluss = i;
358 break;
359 }
360 }
361
362 return koepfe.map((eintrag, k) => {
363 const ende = koepfe[k + 1]?.i ?? schluss;
364 const inhalt = zeilen.slice(eintrag.i + 1, ende);
365 return {
366 nummer: eintrag.nummer,
367 datum: eintrag.datum,
368 unterabschnitte: inhalt.filter((z) => z.startsWith('### ')),
369 block: blockLesen(inhalt),
370 };
371 });
372 }
373
374 function blockLesen(inhalt: string[]): string[] | null {
375 const start = inhalt.findIndex((z) => z.trim() === ueberschrift);
376 if (start === -1) {
377 return null;
378 }
379 let ende = inhalt.length;
380 for (let i = start + 1; i < inhalt.length; i += 1) {
381 if (inhalt[i]?.startsWith('### ') || inhalt[i]?.startsWith('## ')) {
382 ende = i;
383 break;
384 }
385 }
386 const block = inhalt.slice(start + 1, ende);
387 while (block.length > 0 && !(block[0] ?? '').trim()) {
388 block.shift();
389 }
390 while (block.length > 0 && !(block.at(-1) ?? '').trim()) {
391 block.pop();
392 }
393 return block;
394 }
395
396 /** Der Block als reiner Text – so, wie ihn eine Eingabemaske zählt. */
397 function reintext(block: string[]): string {
398 const absaetze: string[] = [];
399 let laufend = '';
400 for (const zeile of block) {
401 const blank = zeile.trim();
402 if (!blank) {
403 continue;
404 }
405 if (blank.startsWith('- ')) {
406 if (laufend) {
407 absaetze.push(laufend);
408 }
409 laufend = `- ${blank.slice(2)}`;
410 } else {
411 laufend = laufend ? `${laufend} ${blank}` : blank;
412 }
413 }
414 if (laufend) {
415 absaetze.push(laufend);
416 }
417 return absaetze
418 .join('\n')
419 .replace(/\*\*([^*]+)\*\*/gu, '$1')
420 .replace(/(?<![*\w])\*([^*]+)\*(?![*\w])/gu, '$1')
421 .normalize('NFC');
422 }
423
424 const fassungen = changelogFassungen();
425
426 it('findet überhaupt Fassungen im Changelog', () => {
427 /* Wäre der Kopf-Ausdruck falsch, liefen alle folgenden Prüfungen über eine
428 leere Liste und wären grün, ohne etwas geprüft zu haben. */
429 expect(fassungen.length, 'CHANGELOG.md: keine Fassungsüberschrift erkannt.').toBeGreaterThan(
430 10,
431 );
432 expect(ueberschrift).toBe('### Für die Öffentlichkeit');
433 expect(feldgrenze).toBeGreaterThan(0);
434 });
435
436 it('gibt jeder Fassung einen öffentlichen Text', () => {
437 /* Die eigentliche Zusage: Es gibt keine Fassung ohne. Ausgenommen ist ein
438 leerer Abschnitt `[Unveröffentlicht]` – solange dort nichts steht, gibt
439 es auch nichts zu veröffentlichen. Sobald eine Rubrik auftaucht, gilt
440 die Pflicht. */
441 const ohne = fassungen
442 .filter((f) => f.block === null)
443 .filter((f) => !(f.nummer === 'Unveröffentlicht' && f.unterabschnitte.length === 0))
444 .map((f) => f.nummer);
445
446 expect(
447 ohne,
448 `Diesen Fassungen fehlt „${ueberschrift}" in CHANGELOG.md. ` +
449 'Ohne diesen Abschnitt gibt es keinen Text, der veröffentlicht werden darf.',
450 ).toEqual([]);
451 });
452
453 it('hält jeden öffentlichen Text im Rahmen des Feldes', () => {
454 /* Der Text geht in ein Feld mit fester Grenze. Zu lang heißt: abgeschnitten
455 – und abgeschnitten heißt, dass der letzte Punkt mitten im Satz endet. */
456 const zuLang = fassungen
457 .filter((f) => f.block !== null)
458 .map((f) => ({ nummer: f.nummer, zeichen: reintext(f.block ?? []).length }))
459 .filter((f) => f.zeichen > feldgrenze);
460
461 expect(zuLang, `Erlaubt sind ${String(feldgrenze)} Zeichen.`).toEqual([]);
462 });
463
464 it('lässt nichts Internes in einen öffentlichen Text', () => {
465 /* Nur die auffälligsten Muster – die vollständige Liste führt
466 `tools/store_notiz.py`. Hier stehen die drei, die beim schnellen
467 Schreiben wirklich passieren: ein Dateiname, ein Verweis auf ein anderes
468 Dokument, eine Auszeichnung als Quelltext. */
469 const auffaellig: { muster: RegExp; was: string }[] = [
470 { muster: /`/u, was: 'Auszeichnung als Quelltext' },
471 { muster: /\]\(/u, was: 'Verweis auf ein Dokument' },
472 {
473 muster: /\b[\w-]+\.(?:json|ts|tsx|js|mjs|md|py|yml|html|exe|db|png)\b/u,
474 was: 'Dateiname',
475 },
476 ];
477
478 const treffer: string[] = [];
479 for (const f of fassungen) {
480 if (f.block === null) {
481 continue;
482 }
483 const text = f.block.join('\n');
484 for (const { muster, was } of auffaellig) {
485 const gefunden = muster.exec(text);
486 if (gefunden) {
487 treffer.push(`${f.nummer}: ${was} – „${gefunden[0]}"`);
488 }
489 }
490 }
491
492 expect(treffer).toEqual([]);
493 });
494
495 it('ist vorhanden und gibt jeden Block unverändert wieder', () => {
496 /* Ohne diese Prüfung stünde im veröffentlichten Dokument irgendwann etwas
497 anderes als im Changelog – und niemand wüsste, welches von beiden gilt.
498 Sie fängt zugleich den Fall ab, dass jemand eine Öffentlichkeitsregel
499 verletzt hat: Das Werkzeug erzeugt dann nichts mehr, und die Datei bleibt
500 zurück. */
501 const pfad = join(wurzel, 'docs', 'store-notiz.md');
502 expect(existsSync(pfad), 'docs/store-notiz.md fehlt.').toBe(true);
503
504 const notiz = lesen(pfad);
505 const fehlend: string[] = [];
506 for (const f of fassungen) {
507 if (f.block === null || f.nummer === 'Unveröffentlicht') {
508 /* Was noch keine Fassung hat, wird nicht veröffentlicht – der Text
509 dafür muss trotzdem geschrieben sein, und die Prüfungen oben sehen
510 ihn sich an. */
511 continue;
512 }
513 const kopf = f.datum ? `### ${f.nummer} — ${f.datum}` : `### ${f.nummer}`;
514 if (!notiz.includes(`${kopf}\n\n${f.block.join('\n')}`)) {
515 fehlend.push(f.nummer);
516 }
517 }
518
519 expect(
520 fehlend,
521 'docs/store-notiz.md gibt diese Fassungen nicht mehr so wieder wie CHANGELOG.md. ' +
522 'Neu erzeugen mit: python tools/store_notiz.py',
523 ).toEqual([]);
524 });
525
526 it('hält den Text der laufenden Fassung zum Einfügen bereit', () => {
527 /* Wer eine Einreichung macht, soll den Text kopieren können, statt ihn aus
528 dem Changelog zusammenzusuchen – samt der Zahl, die die Eingabemaske
529 gleich selbst zählen wird. */
530 const notiz = lesen(wurzel, 'docs', 'store-notiz.md');
531 const aktuell = fassungen.find((f) => f.nummer === fassung);
532
533 expect(
534 aktuell?.block,
535 `CHANGELOG.md hat keinen öffentlichen Text für ${fassung}.`,
536 ).toBeTruthy();
537 expect(notiz).toContain(`## Zum Einfügen: Fassung ${fassung}`);
538 expect(notiz).toContain(reintext(aktuell?.block ?? []));
539 });
540 });