waffensachkunde

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

/ app src shared fragennummer.ts

4,3 KB Rohdatei
app/src/shared/fragennummer.ts — 128 Zeilen
1 /**
2 * Fragen über ihre Nummer nachschlagen.
3 *
4 * ## Warum das ein eigenes Modul ist
5 *
6 * Weil es eine eigene Fehlerquelle ist. Der Katalog führt **zwei**
7 * Nummernformate nebeneinander, und beide haben ihre Tücken:
8 *
9 * * Kapitel I nummeriert abschnittsweise: `1.01` bis `2.150`, als Kennung
10 * `I.1-01` bis `I.2-150`.
11 * * Kapitel II, III und IV zählen jeweils bei `01` neu, als Kennung `II-01`,
12 * `III-01`, `IV-01`.
13 *
14 * Daraus folgen zwei Dinge, die man leicht falsch macht:
15 *
16 * **Führende Nullen.** Wer `2.5` tippt, meint Frage `2.05`. Eine
17 * Präfixsuche lieferte ihm still die Fragen `2.50` bis `2.59` – also das
18 * Falsche statt nichts. Deshalb werden beide Seiten gleich normalisiert:
19 * führende Nullen der Laufnummer fallen weg, hier wie dort.
20 *
21 * **Mehrdeutigkeit.** Von 437 verschiedenen Nummern sind **89 mehrdeutig** –
22 * eine bloße `45` gibt es in Kapitel II, III und IV. Die Anwendung wählt
23 * nicht heimlich eine aus, sondern zeigt alle drei nebeneinander, jede mit
24 * ihrer Kapitelzeile.
25 *
26 * ## Ausdrücklich keine Teilzeichenkettensuche
27 *
28 * Die Nummern der Kapitel II bis IV sind zweistellig aufgefüllt. Eine
29 * Teilzeichenkettensuche nach `05` höbe sechs Fragen über jeden Texttreffer.
30 * Ein Nummerntreffer entsteht nur bei Gleichheit nach Normalisierung.
31 */
32
33 import type { Katalog } from './katalog';
34
35 /** Höflichkeitsfloskeln, die vor einer Nummer stehen können. */
36 const VORSPANN = /^(frage|nr\.?|nummer)\s*/u;
37
38 /** `2.5`, `2,05`, `2.150` – die Zählweise des Kapitels I. */
39 const MIT_ABSCHNITT = /^([1-9])[.,]0*(\d{1,3})$/u;
40
41 /** `45`, `05`, `5` – die Zählweise der Kapitel II bis IV. */
42 const BLOSSE_NUMMER = /^0*(\d{1,3})$/u;
43
44 /** `I.2-150`, `II-45`, `ii 45` – die vollständige Kennung. */
45 const KENNUNG = /^(i[.,]([1-5])|ii|iii|iv)[-– ]0*(\d{1,3})$/u;
46
47 /**
48 * Bringt eine Eingabe auf einen Nachschlageschlüssel.
49 *
50 * Liefert `null`, wenn die Eingabe nicht wie eine Nummer aussieht – dann
51 * bleibt es bei der Textsuche.
52 */
53 export function nummerSchluessel(roh: string): string | null {
54 const t = roh.trim().toLowerCase().replace(/\s+/gu, ' ').replace(VORSPANN, '').trim();
55
56 const kennung = KENNUNG.exec(t);
57 if (kennung !== null) {
58 const kapitel = kennung[2] === undefined ? (kennung[1] ?? '') : `i.${kennung[2]}`;
59 return `kennung:${kapitel}-${kennung[3] ?? ''}`;
60 }
61
62 const mitAbschnitt = MIT_ABSCHNITT.exec(t);
63 if (mitAbschnitt !== null) {
64 return `nummer:${mitAbschnitt[1] ?? ''}.${mitAbschnitt[2] ?? ''}`;
65 }
66
67 const bloss = BLOSSE_NUMMER.exec(t);
68 if (bloss !== null) {
69 return `nummer:${bloss[1] ?? ''}`;
70 }
71
72 return null;
73 }
74
75 /** Der Schlüssel, unter dem eine Frage selbst zu finden ist. */
76 function schluesselDerFrage(kennung: string, nummer: string): readonly string[] {
77 const aus: string[] = [];
78
79 // Kennung: „I.2-150“ → „kennung:i.2-150“, „II-45“ → „kennung:ii-45“.
80 const teile = /^(i\.[1-5]|ii|iii|iv)-0*(\d{1,3})$/u.exec(kennung.toLowerCase());
81 if (teile !== null) {
82 aus.push(`kennung:${teile[1] ?? ''}-${teile[2] ?? ''}`);
83 }
84
85 // Amtliche Nummer: „1.01“ → „nummer:1.1“, „45“ → „nummer:45“.
86 const mitAbschnitt = MIT_ABSCHNITT.exec(nummer);
87 if (mitAbschnitt !== null) {
88 aus.push(`nummer:${mitAbschnitt[1] ?? ''}.${mitAbschnitt[2] ?? ''}`);
89 } else {
90 const bloss = BLOSSE_NUMMER.exec(nummer);
91 if (bloss !== null) {
92 aus.push(`nummer:${bloss[1] ?? ''}`);
93 }
94 }
95
96 return aus;
97 }
98
99 export interface Nummernindex {
100 readonly nach: ReadonlyMap<string, readonly string[]>;
101 }
102
103 export function nummernindexBauen(katalog: Katalog): Nummernindex {
104 const nach = new Map<string, string[]>();
105 for (const frage of katalog.fragen) {
106 for (const schluessel of schluesselDerFrage(frage.id, frage.amtliche_nummer)) {
107 const liste = nach.get(schluessel) ?? [];
108 liste.push(frage.id);
109 nach.set(schluessel, liste);
110 }
111 }
112 return { nach };
113 }
114
115 /**
116 * Die Fragen zu einer Nummerneingabe – in Katalogreihenfolge.
117 *
118 * Leere Liste heißt: Die Eingabe sah nicht wie eine Nummer aus oder es gibt
119 * die Nummer nicht. Beides ist kein Fehler; die Textsuche läuft ohnehin
120 * darunter weiter.
121 */
122 export function nummernTreffer(index: Nummernindex, eingabe: string): readonly string[] {
123 const schluessel = nummerSchluessel(eingabe);
124 if (schluessel === null) {
125 return [];
126 }
127 return index.nach.get(schluessel) ?? [];
128 }