waffensachkunde

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

/ app src main eingaben.ts

5,5 KB Rohdatei
app/src/main/eingaben.ts — 158 Zeilen
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 }