waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests dokumentation.test.ts
| 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 | }); |