waffensachkunde

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

/ app tests druck.test.ts

23,2 KB Rohdatei
app/tests/druck.test.ts — 701 Zeilen
1 // @vitest-environment node
2 /**
3 * Die Bausteine gedruckter Dokumente.
4 *
5 * Geprüft wird hier alles, was ohne Electron prüfbar ist: Maskierung,
6 * Struktur, Quellenangabe, Farbkontrast. Ob Chromium daraus ein brauchbares
7 * PDF macht, misst `e2e/druck.spec.ts` am erzeugten Artefakt – behauptet wird
8 * es an keiner Stelle.
9 */
10
11 import { readFileSync } from 'node:fs';
12 import { join } from 'node:path';
13 import { fileURLToPath } from 'node:url';
14
15 import { describe, expect, it } from 'vitest';
16
17 import {
18 abschnitt,
19 absatz,
20 dokumentBauen,
21 fusszeilenVorlage,
22 kurzquelle,
23 maskiert,
24 quellensatz,
25 tabelle,
26 type Quellenangabe,
27 } from '../src/shared/druck/dokument';
28 import { dateiname, lernberichtBauen } from '../src/shared/druck/lernbericht';
29 import { DRUCK_FARBEN, druckStil } from '../src/shared/druck/stil';
30 import { seitenFehlerprotokoll, seitenFragenliste } from '../src/shared/druck/umfang';
31 import type { Lernplan } from '../src/shared/lernplan';
32 import type { Lernuebersicht } from '../src/shared/lernstand';
33 import type { Pruefungsverlauf } from '../src/shared/pruefung';
34
35 const QUELLE: Quellenangabe = {
36 amtlich: 'Amtlicher Fragenkatalog für die Waffensachkundeprüfung, Stand 16.12.2024.',
37 herausgeber: 'Bundesverwaltungsamt',
38 stand: '2024-12-16',
39 quelleUrl: 'https://www.bva.bund.de/',
40 umfang: { art: 'kein amtlicher wortlaut' },
41 };
42
43 describe('maskiert', () => {
44 it('entschärft alle fünf gefährlichen Zeichen', () => {
45 expect(maskiert(`<a href="x" class='y'>Müller & Co</a>`)).toBe(
46 '&lt;a href=&quot;x&quot; class=&#39;y&#39;&gt;Müller &amp; Co&lt;/a&gt;',
47 );
48 });
49
50 it('maskiert das kaufmännische Und zuerst', () => {
51 /* Andernfalls würde aus `&lt;` am Ende `&amp;lt;` – die häufigste Falle
52 bei handgeschriebener Maskierung. */
53 expect(maskiert('<')).toBe('&lt;');
54 expect(maskiert('&lt;')).toBe('&amp;lt;');
55 });
56 });
57
58 describe('dokumentBauen', () => {
59 const dokument = dokumentBauen({
60 titel: 'Testbericht',
61 augenbraue: 'Profil "Max & Moritz"',
62 quelle: QUELLE,
63 schriftgroesse: 'normal',
64 abschnitte: [abschnitt('Erster Teil', [absatz('Ein Satz.')])],
65 });
66
67 it('nennt die Sprache am Wurzelelement', () => {
68 expect(dokument).toContain('<html lang="de">');
69 });
70
71 it('hat genau eine Hauptüberschrift', () => {
72 expect(dokument.match(/<h1[ >]/gu) ?? []).toHaveLength(1);
73 });
74
75 it('führt keine Sprünge in der Überschriftenfolge', () => {
76 const ebenen = [...dokument.matchAll(/<h([1-6])[ >]/gu)].map((t) => Number(t[1]));
77
78 expect(ebenen[0]).toBe(1);
79 let vorige = 1;
80 for (const ebene of ebenen) {
81 expect(ebene).toBeLessThanOrEqual(vorige + 1);
82 vorige = ebene;
83 }
84 });
85
86 it('maskiert auch die Kopfzeilen', () => {
87 expect(dokument).toContain('Profil &quot;Max &amp; Moritz&quot;');
88 expect(dokument).not.toContain('Profil "Max & Moritz"');
89 });
90
91 it('enthält kein Skript und keine ladbare Adresse', () => {
92 expect(dokument).not.toMatch(/<script/iu);
93 expect(dokument).not.toMatch(/(?:src|href)\s*=\s*["']https?:/iu);
94 });
95
96 it('bringt eine eigene, strenge Inhaltsrichtlinie mit', () => {
97 expect(dokument).toContain("default-src 'none'");
98 expect(dokument).toContain("style-src 'unsafe-inline'");
99 });
100
101 describe('Quellenangabe', () => {
102 it('steht im Dokument, nicht nur in den Metadaten', () => {
103 expect(dokument).toContain('Bundesverwaltungsamt');
104 expect(dokument).toContain('Stand: 2024-12-16');
105 expect(dokument).toContain('https://www.bva.bund.de/');
106 });
107
108 it('nennt auch die eigene Lizenz der redaktionellen Teile', () => {
109 expect(dokument).toContain('EUPL-1.2');
110 expect(dokument).toContain('Olaf Willerding');
111 });
112
113 it('sagt, ob amtlicher Wortlaut enthalten ist', () => {
114 expect(dokument).toContain('enthält keinen Wortlaut des amtlichen Fragenkatalogs');
115 });
116
117 /*
118 Bis Fassung 0.18.0 stand hier unbedingt „Dieses Dokument gibt nur einen
119 Teil des amtlichen Fragenkatalogs wieder“. Für eine Fragenliste über den
120 ganzen Katalog war das nachweislich falsch – 575 von 575 Fragen sind
121 kein Teil. Der Satz wird deshalb gezählt, nicht behauptet, und diese
122 Prüfungen halten jede Zählung fest.
123 */
124 it('nennt bei einer Vollwiedergabe „alle“ und nicht „einen Teil“', () => {
125 expect(
126 quellensatz({
127 art: 'wiedergabe',
128 fragen: 575,
129 fragenGesamt: 575,
130 loesungen: 'keine',
131 optionen: 'alle',
132 }),
133 ).toContain('alle 575 Fragen');
134 expect(
135 quellensatz({
136 art: 'wiedergabe',
137 fragen: 575,
138 fragenGesamt: 575,
139 loesungen: 'keine',
140 optionen: 'alle',
141 }),
142 ).not.toContain('der 575 Fragen');
143 });
144
145 it('zählt bei einer Teilwiedergabe die Fragen', () => {
146 expect(
147 quellensatz({
148 art: 'wiedergabe',
149 fragen: 42,
150 fragenGesamt: 575,
151 loesungen: 'alle',
152 optionen: 'alle',
153 }),
154 ).toContain('42 der 575 Fragen');
155 });
156
157 it('kennt den Singular – ein einziger Fehler ist erreichbar', () => {
158 const satz = quellensatz({
159 art: 'wiedergabe',
160 fragen: 1,
161 fragenGesamt: 575,
162 loesungen: 'alle',
163 optionen: 'alle',
164 });
165
166 expect(satz).toContain('1 der 575 Fragen');
167 expect(satz).not.toContain('1 Fragen des');
168 });
169
170 it('sagt ausdrücklich, wenn die Lösungskennzeichnung fehlt', () => {
171 expect(
172 quellensatz({
173 art: 'wiedergabe',
174 fragen: 90,
175 fragenGesamt: 575,
176 loesungen: 'keine',
177 optionen: 'alle',
178 }),
179 ).toContain('die amtliche Kennzeichnung fehlt hier vollständig');
180 });
181
182 it('sagt ausdrücklich, wenn Antwortmöglichkeiten weggelassen wurden', () => {
183 expect(
184 quellensatz({
185 art: 'wiedergabe',
186 fragen: 90,
187 fragenGesamt: 575,
188 loesungen: 'alle',
189 optionen: 'nur die richtigen',
190 }),
191 ).toContain('die übrigen fehlen');
192 });
193
194 /*
195 Für einen blinden Leser ersetzt der Alternativtext das amtliche Zeichen.
196 Er ist eigener Inhalt dieser Software – wer ihn hört, muss das erfahren.
197 */
198 it('weist die Bildbeschreibungen als eigenen Inhalt aus', () => {
199 expect(
200 quellensatz({
201 art: 'wiedergabe',
202 fragen: 5,
203 fragenGesamt: 575,
204 loesungen: 'alle',
205 optionen: 'alle',
206 }),
207 ).toContain('stammen nicht aus dem amtlichen Fragenkatalog');
208 });
209
210 it('lässt sich nicht weglassen', () => {
211 /* Es gibt keinen Schalter dafür – geprüft wird der einzige Weg, auf dem
212 sie fehlen könnte: eine leere Angabe im Katalog. */
213 expect(() =>
214 dokumentBauen({
215 titel: 'T',
216 augenbraue: 'A',
217 quelle: { ...QUELLE, amtlich: ' ' },
218 schriftgroesse: 'normal',
219 abschnitte: [],
220 }),
221 ).toThrow(/Quellenangabe/u);
222 });
223
224 it('steht als Kurzform auch in der Fußzeile jeder Seite', () => {
225 const fuss = fusszeilenVorlage(QUELLE);
226
227 expect(fuss).toContain('Bundesverwaltungsamt');
228 expect(fuss).toContain('2024-12-16');
229 expect(fuss).toContain('class="pageNumber"');
230 expect(fuss).toContain('class="totalPages"');
231 });
232
233 it('maskiert auch die Kurzform', () => {
234 const fuss = fusszeilenVorlage({ ...QUELLE, herausgeber: 'A & B <GmbH>' });
235
236 expect(fuss).toContain('A &amp; B &lt;GmbH&gt;');
237 expect(kurzquelle({ ...QUELLE, herausgeber: 'A & B' })).toContain('A & B');
238 });
239 });
240 });
241
242 describe('tabelle', () => {
243 const html = tabelle(
244 'Werte',
245 ['Bereich', 'Anzahl'],
246 [[{ text: 'Kapitel I' }, { text: '42', zahl: true }]],
247 );
248
249 it('trägt eine Beschriftung', () => {
250 expect(html).toContain('<caption>Werte</caption>');
251 });
252
253 it('weist jeder Kopfzelle ihre Richtung zu', () => {
254 /* Ohne `scope` weiß ein Screenreader beim Vorlesen einer Zelle nicht,
255 wozu sie gehört – und im getaggten PDF fehlt dieselbe Zuordnung. */
256 expect(html).toContain('<th scope="col">Bereich</th>');
257 expect(html).toContain('<th scope="row">Kapitel I</th>');
258 });
259
260 it('maskiert den Zellinhalt', () => {
261 const boese = tabelle('T', ['A'], [[{ text: '<script>alert(1)</script>' }]]);
262
263 expect(boese).not.toContain('<script>');
264 expect(boese).toContain('&lt;script&gt;');
265 });
266 });
267
268 describe('Farben des Ausdrucks', () => {
269 /** Relative Leuchtdichte nach WCAG 2.x. */
270 function leuchtdichte(hex: string): number {
271 const kanal = (n: number): number => {
272 const s = n / 255;
273 return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
274 };
275 const r = Number.parseInt(hex.slice(1, 3), 16);
276 const g = Number.parseInt(hex.slice(3, 5), 16);
277 const b = Number.parseInt(hex.slice(5, 7), 16);
278 return 0.2126 * kanal(r) + 0.7152 * kanal(g) + 0.0722 * kanal(b);
279 }
280
281 function kontrast(vorne: string, hinten: string): number {
282 const a = leuchtdichte(vorne);
283 const b = leuchtdichte(hinten);
284 return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
285 }
286
287 /*
288 Der Kommentar in stil.ts nennt Zahlen. Hier werden sie nachgerechnet,
289 damit sie Angaben bleiben und nicht zu Behauptungen verkommen. Papier ist
290 weiß – Hintergrundfarben druckt kaum ein Gerät.
291 */
292 it.each([
293 ['text', DRUCK_FARBEN.text, 7],
294 ['leise', DRUCK_FARBEN.leise, 7],
295 ['erfolg', DRUCK_FARBEN.erfolg, 7],
296 ['fehler', DRUCK_FARBEN.fehler, 7],
297 ['akzent', DRUCK_FARBEN.akzent, 7],
298 ])('hält %s über %s zu 1 gegen weißes Papier', (_name, farbe, mindestens) => {
299 expect(kontrast(farbe, '#ffffff')).toBeGreaterThanOrEqual(mindestens);
300 });
301
302 it('hält den Text auch auf der hellen Tabellenkopffläche über 7 zu 1', () => {
303 expect(kontrast(DRUCK_FARBEN.text, DRUCK_FARBEN.flaecheLeise)).toBeGreaterThanOrEqual(7);
304 });
305
306 it('verlässt sich nicht auf den Abstand zwischen Grün und Rot', () => {
307 /*
308 Im Graustufendruck fallen die beiden fast zusammen. Genau deshalb steht
309 neben jeder farbigen Angabe ein Wort – der Test hält den Befund fest,
310 damit niemand die Farbe später zum alleinigen Träger macht.
311 */
312 expect(kontrast(DRUCK_FARBEN.erfolg, DRUCK_FARBEN.fehler)).toBeLessThan(2);
313 });
314 });
315
316 describe('druckStil', () => {
317 it('setzt keine Seitengeometrie – die kommt aus den Optionen', () => {
318 /* Zwei Quellen für dieselbe Größe wären eine Fehlerquelle, und sobald
319 `@page` im Spiel ist, übergeht Electron die Option `landscape`. */
320 expect(druckStil('normal')).not.toContain('@page');
321 });
322
323 it('vergrößert im Großdruck auch Zeilenlänge und Überschriften', () => {
324 const normal = druckStil('normal');
325 const gross = druckStil('gross');
326
327 expect(normal).toContain('font-size: 12pt');
328 expect(gross).toContain('font-size: 17pt');
329 expect(normal).toContain('max-width: 78ch');
330 expect(gross).toContain('max-width: 52ch');
331 });
332
333 it('wiederholt den Tabellenkopf auf Folgeseiten', () => {
334 expect(druckStil('normal')).toContain('thead { display: table-header-group; }');
335 });
336 });
337
338 // ─── Der Lernbericht ────────────────────────────────────────────────────
339
340 const UEBERSICHT: Lernuebersicht = {
341 fragenGesamt: 575,
342 beantwortet: 120,
343 belegt: 48,
344 reifegrad: 48 / 575,
345 stufe: 'zurueck',
346 deckelnd: [],
347 faellig: 12,
348 gemerkt: 5,
349 offen: 0,
350 fehler: 0,
351 heuteRichtig: 8,
352 heuteFalsch: 2,
353 heuteBearbeitet: 0,
354 tageSeitLetzterAntwort: null,
355 bereiche: [
356 {
357 id: 'I',
358 titel: 'Waffenrecht & Vorschriften',
359 fragenGesamt: 300,
360 beantwortet: 100,
361 belegt: 40,
362 reifegrad: 40 / 300,
363 stufe: 'zurueck',
364 },
365 ],
366 themengruppen: [],
367 };
368
369 const PLAN: Lernplan = {
370 termin: '2026-10-01',
371 tageBisTermin: 41,
372 gesamtFragen: 575,
373 nieBeantwortet: 455,
374 faellig: 12,
375 zielquote: 0.9,
376 prognoseHeute: 0.42,
377 prognoseAmTermin: 0.31,
378 pensum: { neu: 12, wiederholung: 8, gesamt: 20, minuten: 9 },
379 machbarkeit: 'machbar',
380 sekundenProFrage: 25,
381 };
382
383 const VERLAUF: Pruefungsverlauf[] = [
384 {
385 id: 3,
386 profilId: 'standard',
387 profilName: 'Standard',
388 zeitpunkt: '2026-08-20T10:00:00.000Z',
389 gesamt: 80,
390 richtig: 70,
391 quote: 0.875,
392 urteil: 'bestanden',
393 dauerMs: 71 * 60_000,
394 zeitmodus: 'normal',
395 unbeantwortet: 0,
396 zeitAbgelaufen: false,
397 bereiche: null,
398 bestehensQuote: 0.8,
399 },
400 ];
401
402 describe('lernberichtBauen', () => {
403 const bericht = lernberichtBauen({
404 uebersicht: UEBERSICHT,
405 plan: PLAN,
406 verlauf: VERLAUF,
407 profilName: 'Max & Moritz',
408 quelle: QUELLE,
409 erstelltAm: '2026-08-21T06:00:00.000Z',
410 schriftgroesse: 'normal',
411 });
412
413 it('nennt die vier Abschnitte', () => {
414 for (const titel of [
415 'Ihr Stand insgesamt',
416 'Nach Bereichen',
417 'Ihr Lernplan',
418 'Prüfungssimulationen',
419 ]) {
420 expect(bericht).toContain(titel);
421 }
422 });
423
424 it('gibt die Zahlen des Lernstands wieder', () => {
425 expect(bericht).toContain('48 von 575 Fragen sitzen belegt.');
426 expect(bericht).toContain('8 %');
427 });
428
429 it('übernimmt die Bereichsbezeichnung des Katalogs unverändert und maskiert', () => {
430 /* Änderungsverbot: Der amtliche Titel darf nicht umformuliert werden –
431 maskieren ist keine Änderung des Wortlauts, sondern seine korrekte
432 Darstellung. */
433 expect(bericht).toContain('I – Waffenrecht &amp; Vorschriften');
434 });
435
436 it('sagt bei der Prognose zum Termin dazu, worauf sie beruht', () => {
437 expect(bericht).toContain('Ohne weiteres Lernen');
438 });
439
440 it('nennt das Urteil eines Laufs als Wort', () => {
441 expect(bericht).toContain('Bestanden');
442 });
443
444 it('nennt zu jedem Lauf die Bearbeitungsdauer', () => {
445 /* Auch auf dem Papier: Zeitnot ist ein eigenes Durchfallrisiko und an der
446 Trefferquote nicht abzulesen. In vollen Minuten – zum Vergleich
447 mehrerer Läufe untereinander sind Sekunden Rauschen. */
448 expect(bericht).toContain('>Dauer</th>');
449 expect(bericht).toContain('71 Minuten');
450 });
451
452 it('kommt ohne Lernplan aus', () => {
453 const ohne = lernberichtBauen({
454 uebersicht: UEBERSICHT,
455 plan: null,
456 verlauf: [],
457 profilName: 'A',
458 quelle: QUELLE,
459 erstelltAm: '2026-08-21T06:00:00.000Z',
460 schriftgroesse: 'normal',
461 });
462
463 expect(ohne).toContain('konnte für diesen Bericht nicht ermittelt werden');
464 expect(ohne).toContain('noch keine Prüfungssimulation');
465 });
466
467 it('enthält keine Frage und keine Antwort im Wortlaut', () => {
468 /* Der Bericht soll den Stand zeigen, nicht den Katalog ersetzen.
469
470 Geprüft wird der Inhalt ohne das eingebettete Stylesheet: Dessen
471 Kommentare erklären Regeln für die anderen Dokumente und nennen dabei
472 Wörter wie „Antwortmöglichkeiten“. Sie stehen in jedem Dokument, auch
473 in diesem – gemeint ist hier aber, was der Leser sieht. */
474 const inhalt = bericht.replace(/<style>[\s\S]*?<\/style>/u, '');
475
476 expect(inhalt).not.toMatch(/Antwortm[oö]glichkeit/u);
477 expect(inhalt).not.toContain('Musterantwort');
478 });
479 });
480
481 describe('dateiname', () => {
482 it('macht aus Namen und Datum einen brauchbaren Dateinamen', () => {
483 expect(dateiname('Max Mustermann', '2026-08-21T06:00:00.000Z')).toBe(
484 'Lernbericht-Max-Mustermann-2026-08-21.pdf',
485 );
486 });
487
488 it('entfernt Zeichen, mit denen Dateisysteme nichts anfangen', () => {
489 const name = dateiname('Müller/Meier: "Test" \\ *?', '2026-08-21T06:00:00.000Z');
490
491 expect(name).toMatch(/^Lernbericht-[A-Za-z0-9-]+-2026-08-21\.pdf$/u);
492 expect(name).not.toMatch(/[/\\:*?"<>|]/u);
493 });
494
495 it('kommt mit einem leeren Namen und einem unbrauchbaren Datum zurecht', () => {
496 expect(dateiname('', 'unsinn')).toBe('Lernbericht-ohne-datum.pdf');
497 });
498 });
499
500 describe('Lernbericht – Befunde der Prüfrunde zu 0.27.2', () => {
501 it('nennt die Zeile nach dem, was darin steht: dem Lernumfang', () => {
502 /*
503 `uebersicht.fragenGesamt` ist der Lernumfang des Profils, nicht der
504 Katalog: Wer Kapitel IV abgewählt hat, hat dort 486 stehen. Die Zeile
505 hieß trotzdem „Fragen im Katalog“ – auf einem Blatt, das jemand aus der
506 Hand gibt, eine Zahl unter einer Überschrift, die etwas anderes
507 behauptet. `Profil.kapitelAusschluss` sagt es selbst: abgewählte Fragen
508 kommen „in keiner Sitzung und in keiner Zahl mehr“ vor.
509 */
510 const bericht = lernberichtBauen({
511 uebersicht: { ...UEBERSICHT, fragenGesamt: 486 },
512 plan: PLAN,
513 verlauf: [],
514 profilName: 'Standard',
515 quelle: QUELLE,
516 erstelltAm: '2026-08-21T06:00:00.000Z',
517 schriftgroesse: 'normal',
518 });
519
520 expect(bericht).not.toContain('Fragen im Katalog');
521 expect(bericht).toContain('Fragen in Ihrem Lernumfang');
522 });
523
524 it('druckt nach einem verstrichenen Termin keine negative Tageszahl', () => {
525 /*
526 Die Oberfläche hat für diesen Fall einen eigenen Satz („Der eingetragene
527 Termin liegt in der Vergangenheit“); das Papier hatte keinen und schrieb
528 „noch -33 Tage“.
529 */
530 const bericht = lernberichtBauen({
531 uebersicht: UEBERSICHT,
532 plan: { ...PLAN, termin: '2026-07-19', tageBisTermin: -33, machbarkeit: 'termin_vorbei' },
533 verlauf: [],
534 profilName: 'Standard',
535 quelle: QUELLE,
536 erstelltAm: '2026-08-21T06:00:00.000Z',
537 schriftgroesse: 'normal',
538 });
539
540 expect(bericht).not.toMatch(/noch -\d+ Tage/u);
541 expect(bericht).toContain('19.07.2026');
542 expect(bericht).toMatch(/vergangen|verstrichen|vorbei/u);
543 });
544
545 it('nennt in der Simulationstabelle Zeitstufe und unbeantwortete Fragen', () => {
546 /*
547 Beide Felder tragen im Vertrag die Begründung ihrer Existenz. Zur
548 Zeitstufe: „Ohne diese Angabe stünden ein Lauf ohne Uhr und einer unter
549 Zeitdruck in der Tabelle nebeneinander, als wären sie vergleichbar.“ Zu
550 den Unbeantworteten: „Der Wert ist der Grund, warum es diese Fassung
551 gibt: `quote` zählt Unbeantwortete wie falsch beantwortete.“ Auf dem
552 Papier stand keines von beiden.
553 */
554 const bericht = lernberichtBauen({
555 uebersicht: UEBERSICHT,
556 plan: PLAN,
557 verlauf: [
558 { ...VERLAUF[0]!, zeitmodus: 'aus', unbeantwortet: 0 },
559 {
560 ...VERLAUF[0]!,
561 id: 4,
562 zeitpunkt: '2026-08-21T10:00:00.000Z',
563 zeitmodus: 'plus50',
564 unbeantwortet: 12,
565 },
566 ],
567 profilName: 'Standard',
568 quelle: QUELLE,
569 erstelltAm: '2026-08-21T06:00:00.000Z',
570 schriftgroesse: 'normal',
571 });
572
573 expect(bericht).toContain('Zeit');
574 expect(bericht).toContain('Offen');
575 expect(bericht).toContain('ohne Uhr');
576 expect(bericht).toContain('+50 %');
577 expect(bericht).toContain('>12<');
578 });
579
580 it('richtet den Tabellenkopf nach derselben Regel aus wie die Zellen', () => {
581 /*
582 Der Kopf entschied allein nach der Spaltennummer und setzte jede Spalte
583 außer der ersten rechtsbündig – auch „Profil“ und „Urteil“, deren Zellen
584 links stehen. In zwei der vier Tabellen stand die Überschrift damit am
585 gegenüberliegenden Rand ihrer Spalte.
586 */
587 const bericht = lernberichtBauen({
588 uebersicht: UEBERSICHT,
589 plan: PLAN,
590 verlauf: VERLAUF,
591 profilName: 'Standard',
592 quelle: QUELLE,
593 erstelltAm: '2026-08-21T06:00:00.000Z',
594 schriftgroesse: 'normal',
595 });
596
597 const kopf = /<th scope="col"([^>]*)>([^<]*)</gu;
598 const treffer = [...bericht.matchAll(kopf)].map(([, attribute, text]) => ({
599 text: text ?? '',
600 zahl: (attribute ?? '').includes('zahl'),
601 }));
602
603 expect(treffer.length).toBeGreaterThan(0);
604 expect(treffer.find((t) => t.text === 'Profil')?.zahl).toBe(false);
605 expect(treffer.find((t) => t.text === 'Urteil')?.zahl).toBe(false);
606 expect(treffer.find((t) => t.text === 'Richtig')?.zahl).toBe(true);
607 });
608 });
609
610 describe('Seitenzahlen stehen an genau einer Stelle', () => {
611 /*
612 Befund der Prüfrunde zu 0.27.2. Vier Dateien nannten für dieselben
613 Dokumente „an wirklich gedruckten PDF gemessene“ Seitenzahlen, und sie
614 widersprachen einander: die Fragenliste über den ganzen Katalog mit 162
615 (`umfang.ts`) gegen 120 (`fragenliste.ts`); das Fehlerprotokoll über 150
616 Fragen mit Kurzerklärung mit 70 gegen 51 (`ipc.ts`). Höchstens eine Zahl
617 je Dokument kann stimmen, und welche, war nicht mehr feststellbar.
618
619 Seither gilt: Die Stützstellen stehen in `umfang.ts` und sonst nirgends.
620 Wer eine Seitenzahl braucht, rechnet sie mit dem Schätzer aus.
621 */
622 const WURZEL = fileURLToPath(new URL('..', import.meta.url));
623
624 it('nennt außerhalb von umfang.ts keine Seitenzahl dieser Dokumente', () => {
625 const dateien = [
626 'src/shared/druck/fragenliste.ts',
627 'src/shared/druck/fehlerprotokoll.ts',
628 'src/shared/druck/lernbericht.ts',
629 'src/shared/ipc.ts',
630 'src/shared/hilfe.ts',
631 ];
632
633 const treffer: string[] = [];
634 for (const datei of dateien) {
635 const inhalt = readFileSync(join(WURZEL, datei), 'utf8');
636 for (const [stelle] of inhalt.matchAll(/\d+\s*Seiten/gu)) {
637 treffer.push(`${datei}: „${stelle}"`);
638 }
639 }
640
641 expect(treffer, 'Seitenzahlen gehören ausschließlich in umfang.ts').toEqual([]);
642 });
643
644 it('rechnet die eigenen Stützstellen nach', () => {
645 /*
646 Die Tabelle im Kopf von `umfang.ts` ist die Messung, die Konstanten sind
647 die daraus gezogene Gerade. Laufen sie auseinander, ist eine der beiden
648 Angaben falsch – und genau das war der Fall, als der Absatz zum
649 Großdruck 508 Seiten nannte und die Tabelle darüber 539.
650
651 Bei Normalgröße bleibt die Schätzung innerhalb von fünf Prozent. Beim
652 Großdruck ist die Spanne größer und ausdrücklich so gewollt: Der Faktor
653 schwankt zwischen 1,7 und 1,9, und genommen wird der obere Wert, damit
654 die Schätzung eher zu hoch als zu niedrig ausfällt.
655 */
656 const stuetzstellen = [
657 {
658 name: 'Fehlerprotokoll kurz, 150',
659 ist: seitenFehlerprotokoll(150, 'kurz', 'normal'),
660 soll: 70,
661 },
662 {
663 name: 'Fehlerprotokoll voll, 150',
664 ist: seitenFehlerprotokoll(150, 'voll', 'normal'),
665 soll: 169,
666 },
667 {
668 name: 'Fehlerprotokoll voll, 486',
669 ist: seitenFehlerprotokoll(486, 'voll', 'normal'),
670 soll: 539,
671 },
672 { name: 'Fragenliste, 575', ist: seitenFragenliste(575, false, 'normal'), soll: 162 },
673 {
674 name: 'Fragenliste mit Anhang, 575',
675 ist: seitenFragenliste(575, true, 'normal'),
676 soll: 185,
677 },
678 ];
679
680 for (const s of stuetzstellen) {
681 const abweichung = Math.abs(s.ist - s.soll) / s.soll;
682 expect(
683 abweichung,
684 `${s.name}: geschätzt ${String(s.ist)}, gemessen ${String(s.soll)}`,
685 ).toBeLessThanOrEqual(0.05);
686 }
687
688 /* Der Großdruck, mit der größeren und gewollten Spanne nach oben. */
689 for (const s of [
690 { name: 'Fragenliste gross, 575', ist: seitenFragenliste(575, false, 'gross'), soll: 279 },
691 {
692 name: 'Fehlerprotokoll voll gross, 486',
693 ist: seitenFehlerprotokoll(486, 'voll', 'gross'),
694 soll: 982,
695 },
696 ]) {
697 expect(s.ist, `${s.name} unterschätzt`).toBeGreaterThanOrEqual(s.soll);
698 expect(s.ist, `${s.name} überschätzt stark`).toBeLessThanOrEqual(Math.round(s.soll * 1.15));
699 }
700 });
701 });