waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests einstellungen.test.ts
| 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 | }); |