waffensachkunde

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

/ app tests einstellungen.test.ts

9,5 KB Rohdatei
app/tests/einstellungen.test.ts — 265 Zeilen
1 // @vitest-environment node
2 /**
3 * Die Persistenz der Nutzereinstellungen.
4 *
5 * Anlass ist eine Randnotiz aus der Abnahme (`docs/abnahme-befunde.md`,
6 * Befund 13): `einstellungenBereinigen` füllt fehlende Felder mit den
7 * Standardwerten auf. Legte man eine Teiländerung – etwa nur das Farbschema –
8 * unmittelbar zugrunde, setzte jede Umschaltung alle übrigen Einstellungen
9 * zurück. Damals war das folgenlos, weil kein Renderer-Code die anderen
10 * Felder schrieb.
11 *
12 * Seit „Antwortmöglichkeiten mischen“ und „Vorlesen“ wirklich benutzt werden,
13 * ist es das nicht mehr. Das Zusammenlegen in `einstellungenSchreiben` gibt
14 * es zwar schon; geprüft wurde es nie. Diese Datei holt das nach – damit die
15 * Zusammenlegung nicht eines Tages als überflüssig entfernt wird.
16 */
17
18 import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
19 import { tmpdir } from 'node:os';
20 import { join } from 'node:path';
21
22 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
23
24 /* `app.getPath('userData')` ist die einzige Electron-Abhängigkeit des
25 Moduls – ein Verzeichnis je Test hält die Läufe voneinander getrennt. */
26 let verzeichnis = '';
27
28 vi.mock('electron', () => ({
29 app: {
30 getPath: (): string => verzeichnis,
31 },
32 }));
33
34 const { einstellungenBereinigen, einstellungenLesen, einstellungenSchreiben } =
35 await import('../src/main/einstellungen');
36
37 function gespeichert(): Record<string, unknown> {
38 return JSON.parse(readFileSync(join(verzeichnis, 'einstellungen.json'), 'utf8')) as Record<
39 string,
40 unknown
41 >;
42 }
43
44 beforeEach(() => {
45 verzeichnis = mkdtempSync(join(tmpdir(), 'wsk-einstellungen-'));
46 });
47
48 afterEach(() => {
49 rmSync(verzeichnis, { recursive: true, force: true });
50 });
51
52 describe('einstellungenBereinigen', () => {
53 it('nimmt für alles Unbrauchbare die Standardwerte', () => {
54 /* Beide Schwierigkeitsregler stehen ab Werk auf „aus“: Ohne eigene
55 Angabe steht die Anwendung so nah am amtlichen Katalog wie möglich. */
56 expect(einstellungenBereinigen(null)).toEqual({
57 thema: 'system',
58 optionenMischen: false,
59 antwortzahlVerbergen: false,
60 vorlesen: false,
61 vorlesenAutomatisch: false,
62 vorlesenErklaerungBeiFehler: false,
63 zuschnittGefragt: false,
64 anzeigegroesse: 100,
65 textabstand: 'normal',
66 sprechtempo: 1,
67 });
68 expect(einstellungenBereinigen({ thema: 'lila', vorlesen: 'ja' })).toEqual({
69 thema: 'system',
70 optionenMischen: false,
71 antwortzahlVerbergen: false,
72 vorlesen: false,
73 vorlesenAutomatisch: false,
74 vorlesenErklaerungBeiFehler: false,
75 zuschnittGefragt: false,
76 anzeigegroesse: 100,
77 textabstand: 'normal',
78 sprechtempo: 1,
79 });
80 });
81
82 it('übernimmt gültige Werte unverändert', () => {
83 expect(
84 einstellungenBereinigen({
85 thema: 'dunkel',
86 optionenMischen: true,
87 antwortzahlVerbergen: true,
88 vorlesen: true,
89 vorlesenAutomatisch: false,
90 vorlesenErklaerungBeiFehler: false,
91 zuschnittGefragt: false,
92 profilId: 3,
93 }),
94 ).toEqual({
95 thema: 'dunkel',
96 optionenMischen: true,
97 antwortzahlVerbergen: true,
98 vorlesen: true,
99 vorlesenAutomatisch: false,
100 vorlesenErklaerungBeiFehler: false,
101 zuschnittGefragt: false,
102 anzeigegroesse: 100,
103 textabstand: 'normal',
104 sprechtempo: 1,
105 profilId: 3,
106 });
107 });
108 });
109
110 describe('Tastenkürzel', () => {
111 /*
112 Dreiwertig, und das ist der ganze Punkt: Bis 0.21.0 lag diese Wahl im
113 `localStorage` des Renderers. Beim Umzug in die Datei darf sie nicht zu
114 einem gewöhnlichen Schalter mit Standardwert werden – das Fehlen des
115 Feldes bedeutet „noch nicht entschieden“ und steuert die Voreinstellung
116 nach erkanntem Hilfsmittel.
117 */
118 it('erfindet keine Entscheidung, wo keine getroffen wurde', () => {
119 expect(einstellungenBereinigen({}).tastenkuerzel).toBeUndefined();
120 expect('tastenkuerzel' in einstellungenBereinigen({})).toBe(false);
121 });
122
123 it('hält beide ausdrücklichen Entscheidungen fest', () => {
124 /* Besonders `false`: Ein Rückfallwert `?? true` schluckte genau die
125 Entscheidung, die jemand bewusst getroffen hat. */
126 expect(einstellungenBereinigen({ tastenkuerzel: false }).tastenkuerzel).toBe(false);
127 expect(einstellungenBereinigen({ tastenkuerzel: true }).tastenkuerzel).toBe(true);
128 });
129
130 it('verwirft, was keine Entscheidung ist', () => {
131 expect(einstellungenBereinigen({ tastenkuerzel: 'aus' }).tastenkuerzel).toBeUndefined();
132 expect(einstellungenBereinigen({ tastenkuerzel: null }).tastenkuerzel).toBeUndefined();
133 });
134
135 it('überlebt einen Neustart', () => {
136 einstellungenSchreiben({ tastenkuerzel: false });
137
138 expect(einstellungenLesen().tastenkuerzel).toBe(false);
139 expect(gespeichert()['tastenkuerzel']).toBe(false);
140 });
141 });
142
143 describe('Anzeigegröße', () => {
144 it('bereinigt eine Größe, die es nicht gibt', () => {
145 /* Ein Wert aus einer früheren Fassung oder von Hand in die Datei
146 geschrieben darf die Anwendung nicht in eine unbedienbare Größe
147 zwingen. */
148 expect(einstellungenBereinigen({ anzeigegroesse: 5000 }).anzeigegroesse).toBe(400);
149 expect(einstellungenBereinigen({ anzeigegroesse: 1 }).anzeigegroesse).toBe(80);
150 expect(einstellungenBereinigen({ anzeigegroesse: 'gross' }).anzeigegroesse).toBe(100);
151 });
152
153 it('hält eine gültige Stufe fest', () => {
154 expect(einstellungenBereinigen({ anzeigegroesse: 150 }).anzeigegroesse).toBe(150);
155 });
156 });
157
158 describe('einstellungenSchreiben', () => {
159 it('legt eine Teiländerung über den gespeicherten Stand', () => {
160 einstellungenSchreiben({
161 optionenMischen: true,
162 antwortzahlVerbergen: true,
163 vorlesen: true,
164 vorlesenAutomatisch: false,
165 vorlesenErklaerungBeiFehler: false,
166 zuschnittGefragt: false,
167 profilId: 2,
168 });
169
170 /* Genau der Aufruf, den `useThema` macht: nur das Farbschema. */
171 const danach = einstellungenSchreiben({ thema: 'dunkel' });
172
173 /* Bewusst mit den vom Standard ABWEICHENDEN Werten geprüft: Nur so
174 zeigt sich, dass die Teiländerung sie stehen lässt, statt sie
175 stillschweigend auf die Standardwerte zurückzusetzen. */
176 expect(danach).toEqual({
177 thema: 'dunkel',
178 optionenMischen: true,
179 antwortzahlVerbergen: true,
180 vorlesen: true,
181 vorlesenAutomatisch: false,
182 vorlesenErklaerungBeiFehler: false,
183 zuschnittGefragt: false,
184 anzeigegroesse: 100,
185 textabstand: 'normal',
186 sprechtempo: 1,
187 profilId: 2,
188 });
189 expect(gespeichert()).toEqual(danach);
190 });
191
192 it('überlebt einen Neustart', () => {
193 einstellungenSchreiben({ vorlesen: true, optionenMischen: false });
194
195 expect(einstellungenLesen()).toMatchObject({ vorlesen: true, optionenMischen: false });
196 });
197
198 it('meldet, wenn nicht geschrieben werden konnte', () => {
199 /* Ein Pfad, unter dem sich nichts anlegen lässt: Die Datei tritt an die
200 Stelle des Verzeichnisses. Stiller Verlust wäre hier das Schlimmste –
201 die Einstellung gälte bis zum Beenden und wäre danach weg. */
202 einstellungenSchreiben({ thema: 'hell' });
203 verzeichnis = join(verzeichnis, 'einstellungen.json');
204
205 expect(() => einstellungenSchreiben({ thema: 'dunkel' })).toThrow(
206 /konnten nicht gespeichert werden/u,
207 );
208 });
209 });
210
211 /*
212 Geschrieben wird über eine Nebendatei und ein `rename`.
213
214 Vorher ging es geradewegs in die Zieldatei: Ein Abbruch mitten im Schreiben
215 – Stromausfall, voller Datenträger nach dem halben Puffer – hinterließ eine
216 verstümmelte Datei. Gelesen wurde sie danach zwar sauber abgefangen, aber
217 der Nutzer verlor wortlos Farbschema, Anzeigegröße, Vorlesen, Tastenkürzel
218 und das zuletzt benutzte Profil. Es war der einzige nicht unteilbare
219 Schreibweg der Anwendung.
220 */
221 describe('einstellungenSchreiben – unteilbar', () => {
222 const ziel = (): string => join(verzeichnis, 'einstellungen.json');
223 const neben = (): string => `${ziel()}.neu`;
224
225 it('lässt nach dem Schreiben keine Nebendatei zurück', () => {
226 einstellungenSchreiben({ thema: 'dunkel' });
227
228 expect(existsSync(ziel())).toBe(true);
229 expect(existsSync(neben())).toBe(false);
230 });
231
232 it('lässt den alten Stand unversehrt, wenn das Schreiben scheitert', () => {
233 einstellungenSchreiben({ thema: 'hell', anzeigegroesse: 250 });
234
235 /* Der Abbruch mitten im Vorgang, ohne Attrappe: Ein Verzeichnis an der
236 Stelle der Nebendatei lässt `writeFileSync` scheitern. Ginge es
237 geradewegs in die Zieldatei, träfe derselbe Fehler sie – und zwar
238 erst, nachdem sie geöffnet und geleert wurde. */
239 mkdirSync(neben(), { recursive: true });
240
241 expect(() => einstellungenSchreiben({ thema: 'dunkel' })).toThrow(
242 /konnten nicht gespeichert werden/u,
243 );
244
245 rmSync(neben(), { recursive: true, force: true });
246 expect(einstellungenLesen()).toMatchObject({ thema: 'hell', anzeigegroesse: 250 });
247 });
248
249 it('räumt die Nebendatei weg, wenn das Umbenennen scheitert', () => {
250 einstellungenSchreiben({ thema: 'hell' });
251
252 /* Ein nicht leeres Verzeichnis an der Stelle der Zieldatei: Schreiben
253 gelingt, Umbenennen nicht. Bliebe die Nebendatei liegen, stünde ein
254 halber Stand neben dem gültigen und belegte Platz, den es gerade
255 nicht gab. */
256 rmSync(ziel(), { force: true });
257 mkdirSync(join(ziel(), 'sperre'), { recursive: true });
258
259 expect(() => einstellungenSchreiben({ thema: 'dunkel' })).toThrow(
260 /konnten nicht gespeichert werden/u,
261 );
262
263 expect(existsSync(neben())).toBe(false);
264 });
265 });