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