waffensachkunde

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

/ app tests fragendruck.test.ts

20,9 KB Rohdatei
app/tests/fragendruck.test.ts — 585 Zeilen
1 // @vitest-environment jsdom
2 /**
3 * Fehlerprotokoll und Fragenliste – Aufbau, Wahrheitsgehalt, Umfang.
4 *
5 * Geprüft wird an einem kleinen, aber echten Fragenzuschnitt: eine
6 * Auswahlfrage mit einer richtigen Antwort, eine mit zweien, eine mit
7 * Abbildung an der Option, eine offene Frage. Genau diese vier Fälle sind es,
8 * an denen die Dokumente auseinandergingen.
9 */
10
11 import axe from 'axe-core';
12 import { describe, expect, it } from 'vitest';
13
14 import { jsdomOptionen } from '../src/shared/wcag';
15
16 import {
17 fehlerprotokollBauen,
18 fehlerprotokollDateiname,
19 } from '../src/shared/druck/fehlerprotokoll';
20 import { fragenlisteBauen, fragenlisteDateiname } from '../src/shared/druck/fragenliste';
21 import type { Papierfrage } from '../src/shared/druck/frageblock';
22 import { frageKurzbezeichnung } from '../src/shared/druck/frageblock';
23 import type { Quellenangabe } from '../src/shared/druck/dokument';
24 import { seitenFehlerprotokoll, seitenFragenliste, umfangssatz } from '../src/shared/druck/umfang';
25 import type { Frage, Kapitel } from '../src/shared/katalog';
26
27 const BILD_URL = 'data:image/png;base64,iVBORw0KGgo=';
28
29 function option(label: string, text: string, korrekt: boolean, bilder: string[] = []) {
30 return { label, inhalt: { text, segmente: [{ t: text }] }, korrekt, bilder };
31 }
32
33 const EINE_RICHTIGE: Frage = {
34 id: 'I.1-01',
35 amtliche_nummer: '1.01',
36 kapitel: 'I',
37 abschnitt: 'I.1',
38 typ: 'mc',
39 seite: 1,
40 frage: {
41 text: 'Was regelt das Waffengesetz?',
42 segmente: [{ t: 'Was regelt das Waffengesetz?' }],
43 },
44 bilder: [],
45 optionen: [
46 option('a', 'Den Umgang mit Waffen.', true),
47 option('b', 'Die Jagd.', false),
48 option('c', 'Den Straßenverkehr.', false),
49 ],
50 };
51
52 const ZWEI_RICHTIGE: Frage = {
53 ...EINE_RICHTIGE,
54 id: 'I.1-02',
55 amtliche_nummer: '1.02',
56 frage: { text: 'Welche Aussagen treffen zu?', segmente: [{ t: 'Welche Aussagen treffen zu?' }] },
57 optionen: [
58 option('a', 'Erste zutreffende Aussage.', true),
59 option('b', 'Falsche Aussage.', false),
60 option('c', 'Zweite zutreffende Aussage.', true),
61 ],
62 };
63
64 /* Der Härtefall aus dem echten Katalog: Bei Frage 3.05 haben zwei Optionen
65 überhaupt keinen Text, dort steht nur das Prüfzeichen. */
66 const BILDOPTIONEN: Frage = {
67 ...EINE_RICHTIGE,
68 id: 'I.3-05',
69 amtliche_nummer: '3.05',
70 abschnitt: 'I.3',
71 frage: {
72 text: 'Welches Zeichen ist richtig?',
73 segmente: [{ t: 'Welches Zeichen ist richtig?' }],
74 },
75 optionen: [option('a', '', true, ['zeichen-1']), option('b', '', false, ['zeichen-2'])],
76 };
77
78 const OFFEN: Frage = {
79 id: 'II-01',
80 amtliche_nummer: '2.01',
81 kapitel: 'II',
82 abschnitt: null,
83 typ: 'freitext',
84 seite: 2,
85 frage: {
86 text: 'Erklären Sie den Begriff Führen.',
87 segmente: [{ t: 'Erklären Sie den Begriff Führen.' }],
88 },
89 bilder: [],
90 musterantwort: {
91 text: 'Das Ausüben der tatsächlichen Gewalt außerhalb der eigenen Wohnung.',
92 segmente: [
93 { t: 'Das ' },
94 { t: 'Ausüben der tatsächlichen Gewalt', h: true },
95 { t: ' außerhalb der eigenen Wohnung.' },
96 ],
97 },
98 };
99
100 const KAPITEL: readonly Kapitel[] = [
101 {
102 id: 'I',
103 titel: 'Waffenrecht',
104 abschnitte: [
105 { id: 'I.1', titel: 'Begriffe' },
106 { id: 'I.3', titel: 'Kennzeichnung' },
107 ],
108 },
109 { id: 'II', titel: 'Technik', abschnitte: [] },
110 ];
111
112 const QUELLE = (
113 fragen: number,
114 loesungen: 'alle' | 'keine',
115 optionen: 'alle' | 'nur die richtigen' = 'alle',
116 ): Quellenangabe => ({
117 amtlich: 'Amtlicher Fragenkatalog, Stand 16.12.2024.',
118 herausgeber: 'Bundesverwaltungsamt',
119 stand: '2024-12-16',
120 quelleUrl: 'https://www.bva.bund.de/',
121 umfang: { art: 'wiedergabe', fragen, fragenGesamt: 575, loesungen, optionen },
122 });
123
124 function papier(frage: Frage, mitErklaerung = false): Papierfrage {
125 return {
126 frage,
127 kapitelTitel: frage.kapitel === 'I' ? 'Waffenrecht' : 'Technik',
128 bilder: new Map([
129 ['zeichen-1', BILD_URL],
130 ['zeichen-2', BILD_URL],
131 ]),
132 alttexte: new Map([
133 ['zeichen-1', 'Adler über dem Buchstaben N'],
134 ['zeichen-2', 'Krone über den Buchstaben PTB'],
135 ]),
136 erklaerung: mitErklaerung
137 ? {
138 kurz: 'Es regelt den Umgang.',
139 text: 'Ausführlich: Antwort b ist falsch, weil die Jagd im Bundesjagdgesetz steht.',
140 merksatz: 'Umgang, nicht Gegenstand.',
141 fundstellen: ['§ 1 Abs. 1 WaffG'],
142 }
143 : undefined,
144 };
145 }
146
147 /* Wie in `druck-struktur.test.ts`: Das Dokument ist eine vollständige
148 HTML-Datei, kein Fragment – geparst wird es deshalb, statt es in den
149 Testbaum zu hängen. */
150 function baum(html: string): Document {
151 return new DOMParser().parseFromString(html, 'text/html');
152 }
153
154 // ─── Fehlerprotokoll ────────────────────────────────────────────────────
155
156 describe('Fehlerprotokoll', () => {
157 const dokument = fehlerprotokollBauen({
158 fragen: [papier(EINE_RICHTIGE, true), papier(ZWEI_RICHTIGE, true)],
159 profilName: 'Max',
160 erstelltAm: '2026-08-23T09:00:00.000Z',
161 tiefe: 'kurz',
162 schriftgroesse: 'normal',
163 quelle: QUELLE(2, 'alle'),
164 });
165
166 /*
167 Der entscheidende Punkt. Nachgemessen nennen 302 der 471 Erklärungen zu
168 Auswahlfragen eine FALSCHE Option beim Buchstaben („Antwort b ist
169 falsch …“). Ein Dokument mit nur der richtigen Antwort machte seine
170 eigenen Begründungen unlesbar.
171 */
172 it('druckt alle Antwortmöglichkeiten, nicht nur die richtige', () => {
173 expect(dokument).toContain('Den Umgang mit Waffen.');
174 expect(dokument).toContain('Die Jagd.');
175 expect(dokument).toContain('Den Straßenverkehr.');
176 });
177
178 it('kennzeichnet die richtige Antwort und sagt es bei mehreren', () => {
179 const doc = baum(dokument);
180 const punkte = [...doc.querySelectorAll('.optionen li')].map((li) => li.textContent);
181
182 expect(punkte.filter((t) => t.includes('☑'))).toHaveLength(3);
183 expect(dokument).toContain('Bei dieser Frage sind mehrere Antworten richtig.');
184 });
185
186 it('nennt in jeder Überschrift Kapitel und Nummer', () => {
187 /* 575 Fragen tragen nur 437 verschiedene amtliche Nummern; 227 Fragen
188 teilen sich eine mit einer anderen. Ohne das Kapitel ist die Frage im
189 amtlichen Werk nicht nachschlagbar. */
190 const doc = baum(dokument);
191 for (const h3 of doc.querySelectorAll('h3')) {
192 expect(h3.textContent).toMatch(/^Kapitel [IVX]+, Frage /u);
193 }
194 });
195
196 it('kennzeichnet die Erklärung als eigenen Inhalt – vollständig', () => {
197 expect(dokument).toContain(
198 'Diese Erklärung ist eine Ergänzung dieser Software und nicht Teil des amtlichen Fragenkatalogs.',
199 );
200 });
201
202 it('lässt die ausführliche Begründung bei „kurz“ weg', () => {
203 expect(dokument).toContain('Es regelt den Umgang.');
204 expect(dokument).not.toContain('Bundesjagdgesetz');
205 expect(dokument).not.toContain('§ 1 Abs. 1 WaffG');
206 });
207
208 it('nimmt bei „voll“ Begründung, Merksatz und Fundstellen mit', () => {
209 const voll = fehlerprotokollBauen({
210 fragen: [papier(EINE_RICHTIGE, true)],
211 profilName: 'Max',
212 erstelltAm: '2026-08-23T09:00:00.000Z',
213 tiefe: 'voll',
214 schriftgroesse: 'normal',
215 quelle: QUELLE(1, 'alle'),
216 });
217
218 expect(voll).toContain('Bundesjagdgesetz');
219 expect(voll).toContain('Umgang, nicht Gegenstand.');
220 expect(voll).toContain('§ 1 Abs. 1 WaffG');
221 });
222
223 it('sagt bei null Fehlern, dass das Dokument leer bleibt', () => {
224 const leer = fehlerprotokollBauen({
225 fragen: [],
226 profilName: 'Max',
227 erstelltAm: '2026-08-23T09:00:00.000Z',
228 tiefe: 'kurz',
229 schriftgroesse: 'normal',
230 quelle: QUELLE(0, 'alle'),
231 });
232
233 expect(leer).toContain('keine Frage als zuletzt falsch beantwortet vermerkt');
234 });
235
236 it('spricht im Singular, wenn es eine einzige Frage ist', () => {
237 const eine = fehlerprotokollBauen({
238 fragen: [papier(EINE_RICHTIGE)],
239 profilName: 'Max',
240 erstelltAm: '2026-08-23T09:00:00.000Z',
241 tiefe: 'kurz',
242 schriftgroesse: 'normal',
243 quelle: QUELLE(1, 'alle'),
244 });
245
246 expect(eine).toContain('die eine Frage, die Sie zuletzt falsch beantwortet haben');
247 });
248
249 it('macht aus Name und Datum einen brauchbaren Dateinamen', () => {
250 expect(fehlerprotokollDateiname('Max Mustermann', '2026-08-23T09:00:00.000Z')).toBe(
251 'Fehlerprotokoll-Max-Mustermann-2026-08-23.pdf',
252 );
253 });
254
255 /*
256 Die Wache, die gefehlt hat.
257
258 `absatz()` maskiert seinen ganzen Parameter. `frageblock.ts` übergab ihm
259 bis 0.24.1 Zeichenketten, die bereits maskiert waren UND Auszeichnung
260 enthielten. Auf dem Papier stand deshalb an jeder Frage mit Erklärung
261 wörtlich „<strong>Kurz:</strong>“, und aus jedem kaufmännischen Und wurde
262 „&amp;“. Kein Test hat es gemeldet, weil alle Prüftexte aus Buchstaben und
263 Punkten bestanden.
264
265 Deshalb trägt der Prüftext hier jetzt genau die Zeichen, an denen es
266 schiefging.
267 */
268 const HEIKEL: Frage = {
269 ...EINE_RICHTIGE,
270 id: 'I.2-08',
271 amtliche_nummer: '2.08',
272 frage: {
273 text: 'Welche Erlaubnis braucht eine Pistole .32 S&W?',
274 segmente: [{ t: 'Welche Erlaubnis braucht eine Pistole .32 S&W?' }],
275 },
276 optionen: [option('a', 'Waffenbesitzkarte & Bedürfnis', true), option('b', 'Keine', false)],
277 };
278
279 const heikelDokument = fehlerprotokollBauen({
280 fragen: [
281 {
282 ...papier(HEIKEL, true),
283 erklaerung: {
284 kurz: 'Kurz mit & und "Zitat".',
285 text: 'Volltext mit & Zeichen.',
286 merksatz: 'Merken: A & B.',
287 fundstellen: ['§ 1 Abs. 1 WaffG'],
288 },
289 },
290 ],
291 profilName: 'Max & Moritz',
292 erstelltAm: '2026-08-23T09:00:00.000Z',
293 tiefe: 'voll',
294 schriftgroesse: 'normal',
295 quelle: QUELLE(1, 'alle'),
296 });
297
298 it('setzt die Beschriftungen als Fettschrift und nicht als sichtbaren Quelltext', () => {
299 expect(heikelDokument).toContain('<strong>Kurz:</strong>');
300 expect(heikelDokument).toContain('<strong>Merksatz:</strong>');
301 expect(heikelDokument).not.toContain('&lt;strong&gt;');
302 });
303
304 it('maskiert Sonderzeichen genau einmal', () => {
305 /* Einmal maskiert ist „&amp;“ – zweimal maskiert wäre „&amp;amp;“, und
306 der Leser sähe auf dem Papier „&amp;“ stehen. */
307 expect(heikelDokument).not.toContain('&amp;amp;');
308 expect(heikelDokument).not.toContain('&amp;quot;');
309 expect(heikelDokument).toContain('Pistole .32 S&amp;W?');
310
311 const text = baum(heikelDokument).body.textContent;
312 expect(text).toContain('Pistole .32 S&W?');
313 expect(text).toContain('Waffenbesitzkarte & Bedürfnis');
314 expect(text).toContain('Kurz mit & und "Zitat".');
315 expect(text).toContain('Merken: A & B.');
316 expect(text).not.toContain('<strong>');
317 });
318
319 /*
320 Das Fehlerprotokoll hat keinen Lösungsanhang – anders als die Fragenliste.
321 Wer es hört, muss die Lösung trotzdem erfahren; das Kästchen ☑ ist
322 `aria-hidden` und trägt sie nicht.
323 */
324 it('nennt die richtige Antwort auch im gelesenen Text, nicht nur im Kästchen', () => {
325 const punkte = [...baum(heikelDokument).querySelectorAll('.optionen li')];
326 expect(punkte).toHaveLength(2);
327 expect(punkte[0]?.textContent).toContain('Richtig:');
328 expect(punkte[1]?.textContent).toContain('Falsch:');
329 });
330 });
331
332 // ─── Fragenliste ────────────────────────────────────────────────────────
333
334 describe('Fragenliste', () => {
335 const daten = {
336 fragen: [papier(EINE_RICHTIGE), papier(BILDOPTIONEN), papier(OFFEN)],
337 auswahl: 'gesamter Fragenkatalog',
338 erstelltAm: '2026-08-23T09:00:00.000Z',
339 kapitel: KAPITEL,
340 schriftgroesse: 'normal' as const,
341 };
342
343 const ohneLoesung = fragenlisteBauen({
344 ...daten,
345 mitLoesungen: false,
346 quelle: QUELLE(3, 'keine'),
347 });
348
349 const mitLoesung = fragenlisteBauen({
350 ...daten,
351 mitLoesungen: true,
352 quelle: QUELLE(3, 'alle'),
353 });
354
355 it('verrät im Hauptteil nicht, welche Antwort richtig ist', () => {
356 const doc = baum(ohneLoesung);
357 const punkte = [...doc.querySelectorAll('.optionen li')].map((li) => li.textContent);
358
359 expect(punkte.length).toBeGreaterThan(0);
360 expect(punkte.some((t) => t.includes('☑'))).toBe(false);
361 expect(punkte.every((t) => t.includes('☐'))).toBe(true);
362 });
363
364 it('druckt trotzdem alle Antwortmöglichkeiten im Wortlaut', () => {
365 expect(ohneLoesung).toContain('Den Umgang mit Waffen.');
366 expect(ohneLoesung).toContain('Die Jagd.');
367 });
368
369 it('bettet die Prüfzeichen ein – sonst wäre die Option leer', () => {
370 /* Bei Frage 3.05 haben zwei Optionen keinen Text. Ohne Bild stünde dort
371 nichts, und ein Bildschirmleser fände einen leeren Listenpunkt. */
372 const doc = baum(ohneLoesung);
373 const bilder = [...doc.querySelectorAll('.optionen img')];
374
375 expect(bilder).toHaveLength(2);
376 for (const b of bilder) {
377 expect(b.getAttribute('src')).toMatch(/^data:image\//u);
378 expect(b.getAttribute('alt')).toBeTruthy();
379 }
380 });
381
382 it('gibt offenen Fragen Platz zum Schreiben statt der Musterantwort', () => {
383 expect(ohneLoesung).toContain('schreibfeld');
384 expect(ohneLoesung).not.toContain('Ausüben der tatsächlichen Gewalt');
385 });
386
387 it('führt den Lösungsanhang nur auf Wunsch – und nennt dort Kapitel und Nummer', () => {
388 /* Geprüft wird die Überschrift, nicht das Wort: Der Vorspann nennt den
389 Anhang auch dann, wenn er fehlt – er sagt dort, dass er sich
390 zuschalten lässt. */
391 const ueberschriften = (html: string): string[] =>
392 [...baum(html).querySelectorAll('h2')].map((e) => e.textContent);
393
394 expect(ueberschriften(ohneLoesung)).not.toContain('Lösungsanhang');
395 expect(ueberschriften(mitLoesung)).toContain('Lösungsanhang');
396 expect(mitLoesung).toContain(`${frageKurzbezeichnung(EINE_RICHTIGE)}:`);
397 expect(mitLoesung).toContain('Richtig ist a');
398 });
399
400 it('nennt im Anhang die Musterantwort offener Fragen', () => {
401 expect(mitLoesung).toContain('Ausüben der tatsächlichen Gewalt');
402 });
403
404 it('fasst die Fragen unter Zwischenüberschriften je Bereich', () => {
405 const doc = baum(ohneLoesung);
406 const h2 = [...doc.querySelectorAll('h2')].map((e) => e.textContent);
407
408 expect(h2).toContain('I.1 – Begriffe');
409 expect(h2).toContain('II – Technik');
410 });
411
412 it('macht aus Auswahl und Datum einen brauchbaren Dateinamen', () => {
413 expect(fragenlisteDateiname('Kapitel I – Waffenrecht', '2026-08-23T09:00:00.000Z')).toBe(
414 'Fragenliste-Kapitel-I-Waffenrecht-2026-08-23.pdf',
415 );
416 });
417 });
418
419 // ─── Umfangsschätzung ───────────────────────────────────────────────────
420
421 describe('Umfangsschätzung', () => {
422 /*
423 Die Stützstellen stammen aus wirklich gedruckten PDF. Der erste Anlauf
424 rechnete mit einer Faustregel und lag um Faktor 1,7 bis 2,8 zu niedrig –
425 die Fragenliste über den ganzen Katalog wurde mit 55 Seiten angekündigt
426 und hatte 162. Diese Prüfungen halten die gemessenen Werte fest.
427 */
428 it.each([
429 ['Fehlerprotokoll 150 kurz', seitenFehlerprotokoll(150, 'kurz', 'normal'), 70],
430 ['Fehlerprotokoll 150 voll', seitenFehlerprotokoll(150, 'voll', 'normal'), 169],
431 ['Fehlerprotokoll 486 voll', seitenFehlerprotokoll(486, 'voll', 'normal'), 539],
432 ['Fragenliste 575', seitenFragenliste(575, false, 'normal'), 162],
433 ['Fragenliste 575 mit Anhang', seitenFragenliste(575, true, 'normal'), 185],
434 ])('trifft %s auf 15 Prozent', (_name, geschaetzt, gemessen) => {
435 expect(Math.abs(geschaetzt - gemessen) / gemessen).toBeLessThan(0.15);
436 });
437
438 it('schätzt den Großdruck eher zu hoch als zu niedrig', () => {
439 /* Lieber ein Dokument, das kürzer ausfällt als angekündigt. */
440 expect(seitenFragenliste(575, false, 'gross')).toBeGreaterThanOrEqual(279);
441 expect(seitenFehlerprotokoll(486, 'voll', 'gross')).toBeGreaterThanOrEqual(982);
442 });
443
444 it('nennt Blatt und Seiten, weil beides verschiedene Dinge sind', () => {
445 expect(umfangssatz(982)).toContain('982 Seiten');
446 expect(umfangssatz(982)).toContain('491 Blatt');
447 });
448
449 it('bleibt bei einer Seite im Singular', () => {
450 expect(umfangssatz(1)).toBe('Geschätzt eine Seite.');
451 expect(seitenFragenliste(0, true, 'normal')).toBe(1);
452 });
453 });
454
455 // ─── Struktur des erzeugten Dokuments ───────────────────────────────────
456
457 describe('Struktur beider Dokumente', () => {
458 const beide = [
459 [
460 'Fehlerprotokoll',
461 fehlerprotokollBauen({
462 fragen: [papier(EINE_RICHTIGE, true), papier(BILDOPTIONEN), papier(OFFEN)],
463 profilName: 'Max',
464 erstelltAm: '2026-08-23T09:00:00.000Z',
465 tiefe: 'voll',
466 schriftgroesse: 'normal',
467 quelle: QUELLE(3, 'alle'),
468 }),
469 ],
470 [
471 'Fragenliste',
472 fragenlisteBauen({
473 fragen: [papier(EINE_RICHTIGE), papier(BILDOPTIONEN), papier(OFFEN)],
474 auswahl: 'gesamter Fragenkatalog',
475 erstelltAm: '2026-08-23T09:00:00.000Z',
476 mitLoesungen: true,
477 kapitel: KAPITEL,
478 schriftgroesse: 'normal',
479 quelle: QUELLE(3, 'alle'),
480 }),
481 ],
482 ] as const;
483
484 it.each(beide)('%s: hält die Überschriftenhierarchie ohne Sprünge ein', (_name, html) => {
485 /* Ohne diese Regel wird der Strukturbaum des PDF unbrauchbar – und er ist
486 die einzige Orientierung, die ein Bildschirmleser im PDF hat. */
487 const ebenen = [...baum(html).querySelectorAll('h1, h2, h3, h4, h5, h6')].map((e) =>
488 Number(e.tagName.slice(1)),
489 );
490
491 expect(ebenen[0]).toBe(1);
492 expect(ebenen.filter((e) => e === 1)).toHaveLength(1);
493 for (let stelle = 1; stelle < ebenen.length; stelle += 1) {
494 expect(ebenen[stelle]!).toBeLessThanOrEqual(ebenen[stelle - 1]! + 1);
495 }
496 });
497
498 it.each(beide)('%s: bettet Bilder ein und führt nirgendwohin', (_name, html) => {
499 const doc = baum(html);
500
501 expect(doc.querySelectorAll('script')).toHaveLength(0);
502 expect(doc.querySelectorAll('a[href], link[href]')).toHaveLength(0);
503 for (const b of doc.querySelectorAll('img')) {
504 expect(b.getAttribute('src') ?? '').toMatch(/^data:image\//u);
505 expect(b.getAttribute('alt')).toBeTruthy();
506 }
507 });
508
509 it.each(beide)('%s: nennt die Herkunft im sichtbaren Text', (_name, html) => {
510 const text = baum(html).body.textContent;
511
512 expect(text).toContain('Bundesverwaltungsamt');
513 expect(text).toContain('des amtlichen Fragenkatalogs wieder');
514 expect(text).toContain('stammen nicht aus dem amtlichen Fragenkatalog');
515 });
516
517 it.each(beide)('%s: besteht axe ohne Verstoß', async (_name, html) => {
518 const geparst = baum(html);
519 document.documentElement.lang = geparst.documentElement.lang;
520 document.title = geparst.title;
521 document.body.innerHTML = geparst.body.innerHTML;
522
523 const ergebnis = await axe.run(document.body, jsdomOptionen());
524
525 expect(ergebnis.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]);
526 });
527 });
528
529 describe('Fehlende Fundstelle: der Grund steht auch auf dem Papier', () => {
530 /*
531 Befund der Prüfrunde zu 0.27.2, der schwerste dieser Runde.
532
533 Nachgemessen an `content/erklaerungen.json`: 84 der 575 Erklärungen haben
534 keine Fundstelle — und **alle 84** tragen `ohneFundstelleGrund`, den Satz,
535 der sagt, warum es keine gibt. `shared/erklaerungen.ts` begründet das
536 Feld: „Dieses Feld verlangt, das auszusprechen – die Alternative wäre,
537 eine Fundstelle zu erfinden.“ Bildschirm, Sprachausgabe und Glossar zeigen
538 ihn.
539
540 Der Druckweg verlor ihn an zwei Stellen: `erklaerungenFuer` kopierte das
541 Feld nicht, und `Frageerklaerung` kannte es gar nicht. Auf dem Papier —
542 dem einzigen Erzeugnis, das die Anwendung aus der Hand gibt — endete die
543 Begründung dann ohne Beleg und ohne die Auskunft, dass es keinen gibt,
544 unter einem Vorspann, der „die Fundstellen im Gesetz“ zu jeder Frage
545 zusagt.
546 */
547 const OHNE_GRUNDLAGE = 'Das Waffenrecht bestimmt diese Wörter nirgends; es ist Waffentechnik.';
548
549 function dokumentOhneFundstelle(tiefe: 'kurz' | 'voll'): string {
550 return fehlerprotokollBauen({
551 fragen: [
552 {
553 ...papier(EINE_RICHTIGE, true),
554 erklaerung: {
555 kurz: 'Kurz gesagt.',
556 text: 'Ausführlich gesagt.',
557 fundstellen: [],
558 ohneFundstelleGrund: OHNE_GRUNDLAGE,
559 },
560 },
561 ],
562 profilName: 'Standard',
563 erstelltAm: '2026-08-23T09:00:00.000Z',
564 tiefe,
565 schriftgroesse: 'normal',
566 quelle: QUELLE(1, 'alle'),
567 });
568 }
569
570 it('druckt den Grund, wo keine Fundstelle steht', () => {
571 const html = dokumentOhneFundstelle('voll');
572
573 expect(html).toContain(OHNE_GRUNDLAGE);
574 expect(html).not.toContain('Im Gesetz nachlesen');
575 });
576
577 it('lässt bei der Kurzfassung beides weg', () => {
578 /* Die Gegenprobe: Die Kurzfassung führt keine Fundstellen – dann gehört
579 auch die Auskunft über ihr Fehlen nicht dorthin. */
580 const html = dokumentOhneFundstelle('kurz');
581
582 expect(html).not.toContain(OHNE_GRUNDLAGE);
583 expect(html).not.toContain('Im Gesetz nachlesen');
584 });
585 });