waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests glossarabdeckung.test.ts
| 1 | /** |
| 2 | * Wie weit das Glossar den Katalog wirklich abdeckt. |
| 3 | * |
| 4 | * **Der Anlass.** Die 87 Einträge vor 0.22.0 waren fast durchweg |
| 5 | * Rechtsbegriffe. In Kapitel II – Waffentechnik – erreichten nur zwei von fünf |
| 6 | * Fragen überhaupt einen Treffer, und selbst diese Treffer waren meist |
| 7 | * Rechtsbegriffe wie „Schusswaffe“. Das Glossar ist die WCAG-3.1.3-Zusage |
| 8 | * dieser Software; eingelöst wurde sie ausgerechnet in dem Kapitel nicht, |
| 9 | * dessen Vokabular Einsteigern am fremdesten ist. |
| 10 | * |
| 11 | * **Warum gemessen und nicht gezählt.** Eine Zahl wie „118 Einträge“ sagt |
| 12 | * nichts darüber, ob sie dort greifen, wo gelernt wird. Gemessen wird deshalb |
| 13 | * mit **derselben Begriffssuche**, die auch unter der Frage läuft: Wie viele |
| 14 | * Fragen eines Kapitels finden mindestens einen Begriff? |
| 15 | */ |
| 16 | |
| 17 | import { readFileSync } from 'node:fs'; |
| 18 | import { join } from 'node:path'; |
| 19 | |
| 20 | import { describe, expect, it } from 'vitest'; |
| 21 | |
| 22 | import { begriffssucheBauen, type Glossar } from '../src/shared/glossar'; |
| 23 | import type { Katalog } from '../src/shared/katalog'; |
| 24 | |
| 25 | const WURZEL = join(process.cwd(), '..'); |
| 26 | const GLOSSAR = JSON.parse( |
| 27 | readFileSync(join(WURZEL, 'content', 'glossar.json'), 'utf8'), |
| 28 | ) as Glossar; |
| 29 | const KATALOG = JSON.parse( |
| 30 | readFileSync(join(WURZEL, 'content', 'katalog', 'katalog.json'), 'utf8'), |
| 31 | ) as Katalog; |
| 32 | |
| 33 | const SUCHE = begriffssucheBauen(GLOSSAR); |
| 34 | |
| 35 | /** Der Text, den die Oberfläche nach Begriffen durchsucht. */ |
| 36 | function volltext(frage: Katalog['fragen'][number]): string { |
| 37 | const optionen = (frage.optionen ?? []).map((option) => option.inhalt.text).join(' '); |
| 38 | return `${frage.frage.text} ${optionen} ${frage.musterantwort?.text ?? ''}`; |
| 39 | } |
| 40 | |
| 41 | /** Anteil der Fragen eines Kapitels mit mindestens einem Begriff, in Prozent. */ |
| 42 | function abdeckung(kapitelId: string): number { |
| 43 | const fragen = KATALOG.fragen.filter((frage) => frage.kapitel === kapitelId); |
| 44 | const getroffen = fragen.filter((frage) => SUCHE.imText(volltext(frage)).length > 0); |
| 45 | return Math.round((100 * getroffen.length) / fragen.length); |
| 46 | } |
| 47 | |
| 48 | describe('Abdeckung des Glossars', () => { |
| 49 | it('erreicht in der Waffentechnik mindestens vier von fünf Fragen', () => { |
| 50 | /* Der eigentliche Befund: Kapitel II lag bei 40 Prozent. Die Schwelle |
| 51 | steht bewusst unter dem gemessenen Wert – sie soll einen Rückfall |
| 52 | melden, nicht jede Schwankung. */ |
| 53 | expect(abdeckung('II')).toBeGreaterThanOrEqual(80); |
| 54 | }); |
| 55 | |
| 56 | it('fällt in keinem Kapitel unter drei von vier Fragen', () => { |
| 57 | for (const kapitel of KATALOG.kapitel) { |
| 58 | expect(abdeckung(kapitel.id), kapitel.id).toBeGreaterThanOrEqual(75); |
| 59 | } |
| 60 | }); |
| 61 | |
| 62 | it('kennt die tragenden Begriffe der Waffentechnik', () => { |
| 63 | /* Namentlich, damit ein stilles Verschwinden auffällt: Ein Schwellwert |
| 64 | allein bliebe grün, wenn ein Begriff wegfiele und ein anderer |
| 65 | dazukäme. */ |
| 66 | const begriffe = new Set(GLOSSAR.eintraege.map((eintrag) => eintrag.begriff)); |
| 67 | for (const begriff of [ |
| 68 | 'Munition', |
| 69 | 'Patrone', |
| 70 | 'Kaliber', |
| 71 | 'Lauf', |
| 72 | 'Verschluss', |
| 73 | 'Patronenlager', |
| 74 | 'Geschoss', |
| 75 | 'Magazin', |
| 76 | 'Trommel', |
| 77 | 'Revolver', |
| 78 | 'Pistole', |
| 79 | 'Büchse', |
| 80 | 'Flinte', |
| 81 | 'Züge und Felder', |
| 82 | 'Drall', |
| 83 | 'Mündungsenergie', |
| 84 | 'Höchstreichweite', |
| 85 | ]) { |
| 86 | expect(begriffe.has(begriff), begriff).toBe(true); |
| 87 | } |
| 88 | }); |
| 89 | |
| 90 | it('kennt die beiden Maßstäbe der Aufbewahrung und hält sie auseinander', () => { |
| 91 | /* Widerstandsgrad ist geltendes Recht, Sicherheitsstufe lebt nur über die |
| 92 | Übergangsregelung fort. Wer beides verwechselt, verwechselt |
| 93 | Bestandsschutz mit Anforderung. */ |
| 94 | const widerstand = GLOSSAR.eintraege.find((e) => e.begriff === 'Widerstandsgrad'); |
| 95 | const stufe = GLOSSAR.eintraege.find((e) => e.begriff === 'Sicherheitsstufe'); |
| 96 | |
| 97 | expect(widerstand?.fundstellen.some((f) => f.gesetz === 'AWaffV')).toBe(true); |
| 98 | expect(stufe?.text).toContain('Übergangsregelung'); |
| 99 | expect(stufe?.kurz).toContain('nicht mehr vor'); |
| 100 | }); |
| 101 | |
| 102 | it('begründet jeden Eintrag ohne Fundstelle', () => { |
| 103 | /* Bei der Waffentechnik ist das der Regelfall – sie ist fachliche Praxis, |
| 104 | kein Gesetzestext. Das auszusprechen ist die Alternative dazu, eine |
| 105 | Fundstelle zu dehnen. */ |
| 106 | const stumm = GLOSSAR.eintraege |
| 107 | .filter( |
| 108 | (eintrag) => eintrag.fundstellen.length === 0 && eintrag.ohneFundstelleGrund === undefined, |
| 109 | ) |
| 110 | .map((eintrag) => eintrag.begriff); |
| 111 | |
| 112 | expect(stumm).toEqual([]); |
| 113 | }); |
| 114 | |
| 115 | it('lässt keinen „Siehe auch“-Verweis ins Leere laufen', () => { |
| 116 | const begriffe = new Set(GLOSSAR.eintraege.map((eintrag) => eintrag.begriff)); |
| 117 | const kaputt = GLOSSAR.eintraege.flatMap((eintrag) => |
| 118 | (eintrag.siehe ?? []) |
| 119 | .filter((ziel) => !begriffe.has(ziel)) |
| 120 | .map((ziel) => `${eintrag.begriff} -> ${ziel}`), |
| 121 | ); |
| 122 | |
| 123 | expect(kaputt).toEqual([]); |
| 124 | }); |
| 125 | }); |