waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Was ginge beim Ersetzen verloren? – reine Rechnerei, ohne Electron. |
| 3 | * |
| 4 | * ## Warum es diese Auszählung gibt |
| 5 | * |
| 6 | * Das Ersetzen legt eine Sicherheitskopie an und lässt sich zurückrollen – |
| 7 | * aber nur, solange jemand die Kopie findet und weiss, dass es sie gibt. Die |
| 8 | * bessere Sicherung ist, vorher zu wissen, was man aufgibt. |
| 9 | * |
| 10 | * Bisher stellte die Rückfrage nur zwei Gesamtzahlen nebeneinander. Das |
| 11 | * genügt nicht: Wer hier zwei Profile mit zusammen 1.000 Antworten hat und |
| 12 | * eine Datei mit einem Profil und 1.200 Antworten einliest, liest „mehr“ – |
| 13 | * und verliert trotzdem eines seiner beiden Profile vollständig. |
| 14 | * |
| 15 | * ## Was hier NICHT behauptet wird |
| 16 | * |
| 17 | * Dass ein Befund ohne Fund gleichbedeutend mit „verlustfrei“ wäre. Verglichen |
| 18 | * werden Profile über ihre **Namen** und je Profil vier Zahlen. Nicht |
| 19 | * verglichen werden – und das steht auch so in der Anzeige: |
| 20 | * |
| 21 | * - **Prüfungstermin und Kapitelabwahl.** Beide werden mitersetzt. Die |
| 22 | * Kapitelabwahl verschiebt den Nenner des Reifegrads: Nachgerechnet fällt |
| 23 | * derselbe Bestand von 85,0 auf 71,9 Prozent, sobald Kapitel IV wieder |
| 24 | * mitzählt – über zwei Ampelschwellen, ohne dass eine Antwort verloren ging. |
| 25 | * - **Welche** Antworten es sind. Zwei Dateien können dieselbe Zahl tragen |
| 26 | * und verschiedene Fragen meinen. Eine Zeile im Protokoll lässt sich über |
| 27 | * Geräte hinweg nicht zuverlässig wiedererkennen; das ist derselbe Grund, |
| 28 | * aus dem das Verschmelzen zweier Lernstände abgelehnt wurde |
| 29 | * (`docs/entscheidung-lernstand-verschmelzen.md`). |
| 30 | * - Der unterbrochene Prüfungsbogen. Er wird gesondert genannt, weil er sich |
| 31 | * nicht in einer Zahl ausdrücken lässt. |
| 32 | */ |
| 33 | |
| 34 | import type { Kennzahlen, Profilkennzahlen } from './sicherung'; |
| 35 | |
| 36 | /** Ein Profil, das es hier gibt und in der Datei nicht. */ |
| 37 | export interface FehlendesProfil { |
| 38 | readonly name: string; |
| 39 | readonly antworten: number; |
| 40 | } |
| 41 | |
| 42 | /** Ein Profil, das es in beiden gibt, hier aber mit mehr Inhalt. */ |
| 43 | export interface GeschrumpftesProfil { |
| 44 | readonly name: string; |
| 45 | /** Antworten hier minus Antworten in der Datei; immer grösser als 0. */ |
| 46 | readonly antwortenWeniger: number; |
| 47 | readonly gemerktWeniger: number; |
| 48 | readonly laeufeWeniger: number; |
| 49 | } |
| 50 | |
| 51 | export interface Verlustbefund { |
| 52 | /** Profile, die die Datei überhaupt nicht kennt. */ |
| 53 | readonly fehlendeProfile: readonly FehlendesProfil[]; |
| 54 | /** Profile, die in der Datei weniger enthalten als hier. */ |
| 55 | readonly geschrumpfteProfile: readonly GeschrumpftesProfil[]; |
| 56 | /** Ein unterbrochener Bogen läge hier und ginge verloren. */ |
| 57 | readonly offenerBogenGehtVerloren: boolean; |
| 58 | /** Nichts von dem, was gezählt wurde, fehlt in der Datei. */ |
| 59 | readonly ohneBefund: boolean; |
| 60 | } |
| 61 | |
| 62 | function nachNamen(zahlen: readonly Profilkennzahlen[]): ReadonlyMap<string, Profilkennzahlen> { |
| 63 | return new Map(zahlen.map((p) => [p.name, p])); |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * Vergleicht den jetzigen Stand mit dem aus der Datei. |
| 68 | * |
| 69 | * Der Name ist der einzige Anker. Die Nummern werden auf jedem Gerät |
| 70 | * unabhängig vergeben und sagen über die Zugehörigkeit nichts – genau |
| 71 | * deshalb wechselt die Oberfläche nach dem Ersetzen zwingend ins erste |
| 72 | * Profil der eingespielten Datei (siehe `Einspielergebnis.ersteProfilId`). |
| 73 | */ |
| 74 | export function verlustbefund(jetzt: Kennzahlen, ausDatei: Kennzahlen): Verlustbefund { |
| 75 | const inDatei = nachNamen(ausDatei.jeProfil); |
| 76 | |
| 77 | const fehlendeProfile: FehlendesProfil[] = []; |
| 78 | const geschrumpfteProfile: GeschrumpftesProfil[] = []; |
| 79 | |
| 80 | for (const hier of jetzt.jeProfil) { |
| 81 | const dort = inDatei.get(hier.name); |
| 82 | if (dort === undefined) { |
| 83 | fehlendeProfile.push({ name: hier.name, antworten: hier.antworten }); |
| 84 | continue; |
| 85 | } |
| 86 | |
| 87 | const antwortenWeniger = Math.max(0, hier.antworten - dort.antworten); |
| 88 | const gemerktWeniger = Math.max(0, hier.gemerkt - dort.gemerkt); |
| 89 | const laeufeWeniger = Math.max(0, hier.pruefungslaeufe - dort.pruefungslaeufe); |
| 90 | |
| 91 | if (antwortenWeniger > 0 || gemerktWeniger > 0 || laeufeWeniger > 0) { |
| 92 | geschrumpfteProfile.push({ |
| 93 | name: hier.name, |
| 94 | antwortenWeniger, |
| 95 | gemerktWeniger, |
| 96 | laeufeWeniger, |
| 97 | }); |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | return { |
| 102 | fehlendeProfile, |
| 103 | geschrumpfteProfile, |
| 104 | offenerBogenGehtVerloren: jetzt.offenerBogen, |
| 105 | ohneBefund: |
| 106 | fehlendeProfile.length === 0 && geschrumpfteProfile.length === 0 && !jetzt.offenerBogen, |
| 107 | }; |
| 108 | } |
| 109 | |
| 110 | /** Ein Wort für „n Antworten“, im richtigen Numerus. */ |
| 111 | function antwortwort(n: number): string { |
| 112 | return n === 1 ? '1 Antwort' : `${String(n)} Antworten`; |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Der Befund als Sätze. |
| 117 | * |
| 118 | * Bewusst keine Gesamtzahl und keine Note: Was zählt, ist welches Profil |
| 119 | * betroffen ist. Eine Summe über verschiedene Profile hinweg sagte nichts. |
| 120 | */ |
| 121 | export function verlustsaetze(befund: Verlustbefund): readonly string[] { |
| 122 | const saetze: string[] = []; |
| 123 | |
| 124 | for (const p of befund.fehlendeProfile) { |
| 125 | saetze.push( |
| 126 | `Das Profil „${p.name}“ kommt in der Datei nicht vor. Es geht mit ${antwortwort(p.antworten)} verloren.`, |
| 127 | ); |
| 128 | } |
| 129 | |
| 130 | for (const p of befund.geschrumpfteProfile) { |
| 131 | const teile: string[] = []; |
| 132 | if (p.antwortenWeniger > 0) { |
| 133 | teile.push(antwortwort(p.antwortenWeniger)); |
| 134 | } |
| 135 | if (p.gemerktWeniger > 0) { |
| 136 | teile.push( |
| 137 | `${String(p.gemerktWeniger)} gemerkte ${p.gemerktWeniger === 1 ? 'Frage' : 'Fragen'}`, |
| 138 | ); |
| 139 | } |
| 140 | if (p.laeufeWeniger > 0) { |
| 141 | teile.push( |
| 142 | `${String(p.laeufeWeniger)} ${p.laeufeWeniger === 1 ? 'Prüfungslauf' : 'Prüfungsläufe'}`, |
| 143 | ); |
| 144 | } |
| 145 | saetze.push( |
| 146 | `Im Profil „${p.name}“ enthält die Datei ${teile.join(', ')} weniger als dieser Rechner.`, |
| 147 | ); |
| 148 | } |
| 149 | |
| 150 | if (befund.offenerBogenGehtVerloren) { |
| 151 | saetze.push( |
| 152 | 'Ein unterbrochener Prüfungsbogen liegt hier und lässt sich danach nicht fortsetzen.', |
| 153 | ); |
| 154 | } |
| 155 | |
| 156 | return saetze; |
| 157 | } |
| 158 | |
| 159 | /** |
| 160 | * Der Satz für den Fall ohne Befund. |
| 161 | * |
| 162 | * Er sagt ausdrücklich, **was** verglichen wurde – nicht „alles stimmt“. |
| 163 | * Ein unbedingter Satz über einen Inhalt, der nicht vollständig geprüft |
| 164 | * wurde, ist genau der Fehler, den `docs/stand.md` 7.6 zuletzt behoben hat. |
| 165 | */ |
| 166 | export const OHNE_BEFUND_SATZ = |
| 167 | 'Verglichen wurden Profile, Antworten, gemerkte Fragen und Prüfungsläufe. ' + |
| 168 | 'Davon fehlt in der Datei nichts. Nicht verglichen wurden Prüfungstermin und ' + |
| 169 | 'Kapitelabwahl – beide werden mitersetzt, und die Kapitelabwahl verändert Ihren ' + |
| 170 | 'angezeigten Lernstand, ohne dass eine Antwort verloren geht. ' + |
| 171 | 'Ihre Einstellungen werden ebenfalls ersetzt, wenn die Datei welche mitbringt – Sicherungen aus Fassung 0.26.7 und davor tun das nicht.'; |