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, 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 | }); |