waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Prüfhilfen für Nutzlasten an der IPC-Grenze. |
| 3 | * |
| 4 | * Jede Nutzlast aus dem Renderer ist unbekannt, bis sie geprüft wurde. Die |
| 5 | * hier gesammelten Funktionen nehmen deshalb `unknown` entgegen, liefern |
| 6 | * einen engen Typ zurück und brechen sonst mit einer deutschen Meldung ab. |
| 7 | * |
| 8 | * Bewusst ein eigenes Modul: Lernstand und Prüfungssimulation prüfen ihre |
| 9 | * Eingaben nach denselben Regeln, und zwei Kopien derselben Regel driften |
| 10 | * mit der Zeit auseinander. |
| 11 | */ |
| 12 | |
| 13 | /** Bricht die Verarbeitung mit einer sprechenden deutschen Meldung ab. */ |
| 14 | export function abweisen(nachricht: string): never { |
| 15 | throw new Error(nachricht); |
| 16 | } |
| 17 | |
| 18 | /** Stellt sicher, dass die Nutzlast ein einfaches Objekt ist. */ |
| 19 | export function nutzlast(wert: unknown, name: string): Record<string, unknown> { |
| 20 | if (typeof wert !== 'object' || wert === null || Array.isArray(wert)) { |
| 21 | abweisen(`Ungültige Anfrage: ${name} muss ein Objekt sein.`); |
| 22 | } |
| 23 | return wert as Record<string, unknown>; |
| 24 | } |
| 25 | |
| 26 | export function ganzeZahl(wert: unknown, name: string, min: number, max: number): number { |
| 27 | if (typeof wert !== 'number' || !Number.isInteger(wert) || wert < min || wert > max) { |
| 28 | abweisen( |
| 29 | `Ungültige Anfrage: ${name} muss eine ganze Zahl zwischen ${String(min)} und ${String(max)} sein.`, |
| 30 | ); |
| 31 | } |
| 32 | return wert; |
| 33 | } |
| 34 | |
| 35 | /** Wie {@link ganzeZahl}, lässt aber Nachkommastellen zu (etwa für Quoten). */ |
| 36 | export function endlicheZahl(wert: unknown, name: string, min: number, max: number): number { |
| 37 | if (typeof wert !== 'number' || !Number.isFinite(wert) || wert < min || wert > max) { |
| 38 | abweisen( |
| 39 | `Ungültige Anfrage: ${name} muss eine Zahl zwischen ${String(min)} und ${String(max)} sein.`, |
| 40 | ); |
| 41 | } |
| 42 | return wert; |
| 43 | } |
| 44 | |
| 45 | export function wahrheitswert(wert: unknown, name: string, standard: boolean): boolean { |
| 46 | if (wert === undefined || wert === null) { |
| 47 | return standard; |
| 48 | } |
| 49 | if (typeof wert !== 'boolean') { |
| 50 | abweisen(`Ungültige Anfrage: ${name} muss ein Wahrheitswert sein.`); |
| 51 | } |
| 52 | return wert; |
| 53 | } |
| 54 | |
| 55 | export function textliste(wert: unknown, name: string): string[] { |
| 56 | if (wert === undefined || wert === null) { |
| 57 | return []; |
| 58 | } |
| 59 | if (!Array.isArray(wert)) { |
| 60 | abweisen(`Ungültige Anfrage: ${name} muss eine Liste von Zeichenketten sein.`); |
| 61 | } |
| 62 | return (wert as readonly unknown[]).map((eintrag) => { |
| 63 | if (typeof eintrag !== 'string') { |
| 64 | abweisen(`Ungültige Anfrage: ${name} enthält einen Eintrag, der keine Zeichenkette ist.`); |
| 65 | } |
| 66 | return eintrag; |
| 67 | }); |
| 68 | } |
| 69 | |
| 70 | /** Obergrenze einer bereinigten Meldung, in Graphemen. */ |
| 71 | const MAX_MELDUNGSLAENGE = 80; |
| 72 | |
| 73 | /** |
| 74 | * Zeichen, die in einer Meldung nichts zu suchen haben. |
| 75 | * |
| 76 | * Neben den klassischen Steuerzeichen (C0 und DEL) auch: |
| 77 | * |
| 78 | * - **C1** (U+0080–U+009F) – in manchen Terminals als Steuerbefehl gedeutet. |
| 79 | * - **U+2028/U+2029** – Zeilen- und Absatztrenner; sie brechen eine Logzeile |
| 80 | * auf, ohne wie ein Zeilenumbruch auszusehen. |
| 81 | * - **Bidirektionale Steuerzeichen** (U+200E/U+200F, U+202A–U+202E, |
| 82 | * U+2066–U+2069) – mit ihnen lässt sich die Anzeigereihenfolge umkehren. |
| 83 | * Eine Meldung kann dann etwas völlig anderes zeigen, als sie enthält. |
| 84 | * - **U+FEFF** – unsichtbar und in Textvergleichen leicht zu übersehen. |
| 85 | */ |
| 86 | function istGefaehrlich(punkt: number): boolean { |
| 87 | return ( |
| 88 | punkt < 0x20 || |
| 89 | punkt === 0x7f || |
| 90 | (punkt >= 0x80 && punkt <= 0x9f) || |
| 91 | punkt === 0x200e || |
| 92 | punkt === 0x200f || |
| 93 | (punkt >= 0x202a && punkt <= 0x202e) || |
| 94 | punkt === 0x2028 || |
| 95 | punkt === 0x2029 || |
| 96 | (punkt >= 0x2066 && punkt <= 0x2069) || |
| 97 | punkt === 0xfeff |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * Ersetzt Steuerzeichen und kürzt auf 80 Zeichen. |
| 103 | * |
| 104 | * Fremdeingaben landen in Fehlermeldungen, im Protokoll und in Profilnamen. |
| 105 | * Ohne diese Reinigung könnte eine geschickt gewählte Zeichenkette Log-Zeilen |
| 106 | * oder Terminalausgaben verfälschen – im Fall der bidirektionalen |
| 107 | * Steuerzeichen sogar so, dass die Anzeige das Gegenteil des Inhalts zeigt. |
| 108 | * |
| 109 | * Gekürzt wird nach **Graphemen**, nicht nach UTF-16-Einheiten und auch nicht |
| 110 | * nach Codepoints. Nach UTF-16-Einheiten bliebe womöglich ein halbes |
| 111 | * Surrogatpaar zurück; nach Codepoints zerfiele eine zusammengesetzte |
| 112 | * Darstellung – eine Flagge, eine Familie, ein Buchstabe mit Akzent – in ihre |
| 113 | * Bestandteile und zeigte etwas anderes an als vorher. |
| 114 | */ |
| 115 | const SEGMENTIERER = new Intl.Segmenter('de', { granularity: 'grapheme' }); |
| 116 | |
| 117 | export function entschaerft(wert: unknown, ersatz = '?'): string { |
| 118 | const text = typeof wert === 'string' ? wert : String(wert); |
| 119 | |
| 120 | let sauber = ''; |
| 121 | let gezaehlt = 0; |
| 122 | |
| 123 | for (const { segment } of SEGMENTIERER.segment(text)) { |
| 124 | if (gezaehlt >= MAX_MELDUNGSLAENGE) { |
| 125 | break; |
| 126 | } |
| 127 | gezaehlt += 1; |
| 128 | |
| 129 | /* Ein Graphem kann aus mehreren Codepoints bestehen. Steckt auch nur ein |
| 130 | gefährlicher darin, wird das ganze Graphem ersetzt – ein bidirektionales |
| 131 | Steuerzeichen wirkt sonst weiter, nur eben eingebettet. */ |
| 132 | let gefaehrlich = false; |
| 133 | for (const zeichen of segment) { |
| 134 | if (istGefaehrlich(zeichen.codePointAt(0) ?? 0)) { |
| 135 | gefaehrlich = true; |
| 136 | break; |
| 137 | } |
| 138 | } |
| 139 | sauber += gefaehrlich ? ersatz : segment; |
| 140 | } |
| 141 | |
| 142 | return sauber; |
| 143 | } |
| 144 | |
| 145 | /** Fisher-Yates. Liefert eine neue Liste, die Vorlage bleibt unberührt. */ |
| 146 | export function gemischt<T>(werte: readonly T[], zufall: () => number): T[] { |
| 147 | const kopie = [...werte]; |
| 148 | for (let i = kopie.length - 1; i > 0; i -= 1) { |
| 149 | const j = Math.floor(zufall() * (i + 1)); |
| 150 | const a = kopie[i]; |
| 151 | const b = kopie[j]; |
| 152 | if (a !== undefined && b !== undefined) { |
| 153 | kopie[i] = b; |
| 154 | kopie[j] = a; |
| 155 | } |
| 156 | } |
| 157 | return kopie; |
| 158 | } |