waffensachkunde

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

/ app src shared verlust.ts

6,5 KB Rohdatei
app/src/shared/verlust.ts — 171 Zeilen
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.';