waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src shared fragennummer.ts
| 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 | } |