waffensachkunde

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

/ app e2e sicherung.spec.ts

8,5 KB Rohdatei
app/e2e/sicherung.spec.ts — 219 Zeilen
1 /**
2 * Lernstand sichern und einspielen – im echten Fenster, an echten Dateien.
3 *
4 * Die Einheitentests messen die Prüfkette gegen selbst gebaute Datenbanken.
5 * Hier läuft der ganze Weg: Die Anwendung sichert ihren eigenen Lernstand,
6 * er wird verändert, die Sicherung wird eingespielt, und danach muss der
7 * alte Stand wieder dastehen. Nur dieser Lauf beweist, dass die Reihenfolge
8 * beim Ersetzen stimmt.
9 *
10 * Die Systemdialoge werden im Hauptprozess durch Attrappen ersetzt – dieselbe
11 * Vorgehensweise wie in `druck.spec.ts`. Geprüft wird alles, was danach kommt.
12 */
13
14 import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
15 import { tmpdir } from 'node:os';
16 import { join } from 'node:path';
17
18 import { type ElectronApplication, type Page } from '@playwright/test';
19
20 import { amStart, appStarten, bauPruefen } from './electron-hilfe';
21 import { expect, test } from './konsolenwache';
22
23 let app: ElectronApplication | undefined;
24 let fenster: Page;
25 let ablage: string;
26
27 test.beforeAll(async () => {
28 bauPruefen();
29 ({ app, fenster } = await appStarten());
30 ablage = mkdtempSync(join(tmpdir(), 'wsk-e2e-sicherung-'));
31 });
32
33 test.afterAll(async () => {
34 await app?.close();
35 try {
36 rmSync(ablage, { recursive: true, force: true });
37 } catch {
38 /* Aufräumen ist Komfort. */
39 }
40 });
41
42 test.describe.configure({ mode: 'serial' });
43
44 /** Ersetzt den Speicherdialog. */
45 async function speicherzielStellen(pfad: string): Promise<void> {
46 await app?.evaluate(({ dialog }, ziel) => {
47 (dialog as unknown as { showSaveDialog: unknown }).showSaveDialog = () =>
48 Promise.resolve({ canceled: false, filePath: ziel });
49 }, pfad);
50 }
51
52 /** Ersetzt den Öffnen-Dialog. */
53 async function oeffnenzielStellen(pfad: string): Promise<void> {
54 await app?.evaluate(({ dialog }, ziel) => {
55 (dialog as unknown as { showOpenDialog: unknown }).showOpenDialog = () =>
56 Promise.resolve({ canceled: false, filePaths: [ziel] });
57 }, pfad);
58 }
59 /**
60 * Wie viele Fragen sind bearbeitet?
61 *
62 * Aus der Zeile „Schon einmal bearbeitet“ im Lernstand. Die Klasse allein
63 * genügt nicht: Der Lernplan benutzt dieselbe für seine eigenen Zahlen.
64 */
65 async function bearbeitet(): Promise<number> {
66 const zeile = fenster
67 .locator('dt:text-is("Schon einmal bearbeitet")')
68 .locator('xpath=following-sibling::dd[1]');
69 const text = (await zeile.innerText()).replace(/\./gu, '');
70 const treffer = /(\d+) von \d+/u.exec(text);
71 return Number(treffer?.[1] ?? -1);
72 }
73
74 async function zumStart(): Promise<void> {
75 for (let i = 0; i < 4; i++) {
76 if (await amStart(fenster)) {
77 return;
78 }
79 const zurueck = fenster.getByRole('button', { name: /^(Zum Start|Sitzung beenden)$/u });
80 if ((await zurueck.count()) === 0) {
81 return;
82 }
83 await zurueck.first().click();
84 await fenster.waitForTimeout(250);
85 }
86 }
87
88 /** Beantwortet die stehende Frage – gleich welche und wie. */
89 async function frageBeantworten(): Promise<void> {
90 const auswahl = fenster.getByRole('checkbox').or(fenster.getByRole('radio'));
91 if ((await auswahl.count()) > 0) {
92 await auswahl.first().check();
93 await fenster.getByRole('button', { name: /^Antwort bestätigen$/u }).click();
94 } else {
95 await fenster.getByRole('button', { name: /^Musterantwort anzeigen$/u }).click();
96 await fenster.getByRole('button', { name: /^Gewusst$/u }).click();
97 }
98 }
99
100 /**
101 * Beantwortet so lange Fragen, bis eine davon noch nie bearbeitet war.
102 *
103 * ## Warum nicht einfach „eine Frage beantworten“
104 *
105 * „Schon einmal bearbeitet“ zählt **verschiedene** Fragen (`versuche > 0`,
106 * `lernstand.ts`). Dieselbe Frage ein zweites Mal zu beantworten lässt die
107 * Zahl also stehen – und genau das passierte: Beantwortet wird hier stets die
108 * erste Antwortmöglichkeit, die oft falsch ist, und eine falsch beantwortete
109 * Frage ist bei der nächsten Wiedervorlage die dringendste. „Weiterlernen“
110 * legte damit dieselbe Frage noch einmal vor.
111 *
112 * Nachgemessen: Im Lauf vom 27.08. blieb die Zahl bei 1 stehen und der Test
113 * „zeigt vor dem Ersetzen …“ fiel aus; der Lauf davor war mit derselben
114 * Fassung grün. Ein Münzwurf, kein Befund an der Anwendung.
115 *
116 * Der Ausweg führt **innerhalb** einer Sitzung weiter statt über eine neue:
117 * In einer Sitzung kommt keine Frage zweimal vor, nach `vorher + 1` Antworten
118 * muss also eine neue dabei gewesen sein. Eine neue Sitzung zu beginnen half
119 * nicht – sie legte dieselbe dringende Frage wieder zuoberst.
120 */
121 async function eineNeueFrageBeantworten(): Promise<void> {
122 await zumStart();
123 const vorher = await bearbeitet();
124
125 await fenster.getByRole('button', { name: /Weiterlernen/u }).click();
126 await fenster.getByRole('heading', { level: 1, name: /, Frage \d/u }).waitFor();
127
128 for (let i = 0; i <= vorher; i++) {
129 await frageBeantworten();
130
131 const weiter = fenster.getByRole('button', { name: /^Nächste Frage$/u });
132 if ((await weiter.count()) === 0) {
133 // Die Sitzung ist zu Ende – dann war die letzte Frage die letzte Gelegenheit.
134 break;
135 }
136 await weiter.first().click();
137 await fenster.getByRole('heading', { level: 1, name: /, Frage \d/u }).waitFor();
138 }
139
140 await zumStart();
141 }
142
143 test.describe('Sicherung', () => {
144 const datei = () => join(ablage, 'probe.wsklernstand');
145
146 /* Der Stand zum Zeitpunkt der Sicherung – auf ihn muss das Einspielen
147 zurückführen. Ohne diesen Merkposten prüfte der Beweis unten nur, dass
148 eine Erfolgsmeldung erscheint. */
149 let beiDerSicherung = -1;
150
151 test('schreibt eine einzelne Datei – ohne Nebendateien', async () => {
152 /* Der Grund für `VACUUM INTO` statt `db.backup()`: Dessen Ergebnis
153 verwandelt sich beim Ansehen in drei Dateien, die liegen bleiben. */
154 await eineNeueFrageBeantworten();
155 await speicherzielStellen(datei());
156
157 await fenster.getByRole('button', { name: 'Sicherung speichern …' }).scrollIntoViewIfNeeded();
158 await fenster.getByRole('button', { name: 'Sicherung speichern …' }).click();
159
160 await expect(fenster.getByText(/Die Sicherung ist geschrieben/u)).toBeVisible();
161 beiDerSicherung = await bearbeitet();
162 expect(beiDerSicherung).toBeGreaterThan(0);
163 expect(existsSync(datei())).toBe(true);
164 expect(existsSync(`${datei()}-wal`)).toBe(false);
165 expect(existsSync(`${datei()}.teil`)).toBe(false);
166 });
167
168 test('zeigt vor dem Ersetzen, was in der Datei steht – und ersetzt noch nichts', async () => {
169 const vorher = await bearbeitet();
170 await eineNeueFrageBeantworten();
171 expect(await bearbeitet()).toBeGreaterThan(vorher);
172
173 await oeffnenzielStellen(datei());
174 const knopf = fenster.getByRole('button', { name: 'Sicherung auswählen und ansehen …' });
175 await knopf.scrollIntoViewIfNeeded();
176 await knopf.click();
177
178 await expect(
179 fenster.getByRole('heading', { name: 'Diesen Lernstand durch die Sicherung ersetzen?' }),
180 ).toBeVisible();
181 /* Die Rückfrage steht da – der Lernstand ist unberührt. */
182 expect(await bearbeitet()).toBeGreaterThan(vorher);
183 });
184
185 test('stellt beim Ersetzen den gesicherten Stand wirklich wieder her', async () => {
186 /*
187 Der eigentliche Beweis. Zwischen Sicherung und Einspielen wurde eine
188 Frage mehr beantwortet; danach muss die Zahl wieder die der Sicherung
189 sein. Wäre die Reihenfolge beim Ersetzen falsch – etwa ein verwaistes
190 Schreibprotokoll neben der neuen Datei –, stünde hier der falsche Stand
191 bei tadelloser Datenbankprüfung.
192 */
193 await fenster.getByRole('button', { name: 'Ja, alles ersetzen' }).click();
194
195 await expect(fenster.getByText(/Der Lernstand ist eingelesen/u)).toBeVisible();
196 /* Die Sicherheitskopie wird beim Namen genannt – sie ist der einzige Weg
197 zurück, falls die eingespielte Datei doch nicht die gewünschte war. */
198 await expect(fenster.getByText(/Lernstand-vor-dem-Einspielen-/u)).toBeVisible();
199
200 /* Und jetzt die eigentliche Aussage: Die Zahl steht wieder auf dem Stand
201 der Sicherung, nicht auf dem von eben. */
202 await expect.poll(bearbeitet).toBe(beiDerSicherung);
203 });
204
205 test('weist eine fremde Datei ab, ohne etwas anzufassen', async () => {
206 const vorher = await bearbeitet();
207 const fremd = join(ablage, 'urlaub.wsklernstand');
208 writeFileSync(fremd, 'x'.repeat(2048));
209
210 await oeffnenzielStellen(fremd);
211 const knopf = fenster.getByRole('button', { name: 'Sicherung auswählen und ansehen …' });
212 await knopf.scrollIntoViewIfNeeded();
213 await knopf.click();
214
215 await expect(fenster.getByText(/keine Datenbank/u)).toBeVisible();
216 await expect(fenster.getByText(/Es wurde nichts verändert/u)).toBeVisible();
217 expect(await bearbeitet()).toBe(vorher);
218 });
219 });