/** * Lernstand sichern und einspielen – im echten Fenster, an echten Dateien. * * Die Einheitentests messen die Prüfkette gegen selbst gebaute Datenbanken. * Hier läuft der ganze Weg: Die Anwendung sichert ihren eigenen Lernstand, * er wird verändert, die Sicherung wird eingespielt, und danach muss der * alte Stand wieder dastehen. Nur dieser Lauf beweist, dass die Reihenfolge * beim Ersetzen stimmt. * * Die Systemdialoge werden im Hauptprozess durch Attrappen ersetzt – dieselbe * Vorgehensweise wie in `druck.spec.ts`. Geprüft wird alles, was danach kommt. */ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type ElectronApplication, type Page } from '@playwright/test'; import { amStart, appStarten, bauPruefen } from './electron-hilfe'; import { expect, test } from './konsolenwache'; let app: ElectronApplication | undefined; let fenster: Page; let ablage: string; test.beforeAll(async () => { bauPruefen(); ({ app, fenster } = await appStarten()); ablage = mkdtempSync(join(tmpdir(), 'wsk-e2e-sicherung-')); }); test.afterAll(async () => { await app?.close(); try { rmSync(ablage, { recursive: true, force: true }); } catch { /* Aufräumen ist Komfort. */ } }); test.describe.configure({ mode: 'serial' }); /** Ersetzt den Speicherdialog. */ async function speicherzielStellen(pfad: string): Promise { await app?.evaluate(({ dialog }, ziel) => { (dialog as unknown as { showSaveDialog: unknown }).showSaveDialog = () => Promise.resolve({ canceled: false, filePath: ziel }); }, pfad); } /** Ersetzt den Öffnen-Dialog. */ async function oeffnenzielStellen(pfad: string): Promise { await app?.evaluate(({ dialog }, ziel) => { (dialog as unknown as { showOpenDialog: unknown }).showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: [ziel] }); }, pfad); } /** * Wie viele Fragen sind bearbeitet? * * Aus der Zeile „Schon einmal bearbeitet“ im Lernstand. Die Klasse allein * genügt nicht: Der Lernplan benutzt dieselbe für seine eigenen Zahlen. */ async function bearbeitet(): Promise { const zeile = fenster .locator('dt:text-is("Schon einmal bearbeitet")') .locator('xpath=following-sibling::dd[1]'); const text = (await zeile.innerText()).replace(/\./gu, ''); const treffer = /(\d+) von \d+/u.exec(text); return Number(treffer?.[1] ?? -1); } async function zumStart(): Promise { for (let i = 0; i < 4; i++) { if (await amStart(fenster)) { return; } const zurueck = fenster.getByRole('button', { name: /^(Zum Start|Sitzung beenden)$/u }); if ((await zurueck.count()) === 0) { return; } await zurueck.first().click(); await fenster.waitForTimeout(250); } } /** Beantwortet die stehende Frage – gleich welche und wie. */ async function frageBeantworten(): Promise { const auswahl = fenster.getByRole('checkbox').or(fenster.getByRole('radio')); if ((await auswahl.count()) > 0) { await auswahl.first().check(); await fenster.getByRole('button', { name: /^Antwort bestätigen$/u }).click(); } else { await fenster.getByRole('button', { name: /^Musterantwort anzeigen$/u }).click(); await fenster.getByRole('button', { name: /^Gewusst$/u }).click(); } } /** * Beantwortet so lange Fragen, bis eine davon noch nie bearbeitet war. * * ## Warum nicht einfach „eine Frage beantworten“ * * „Schon einmal bearbeitet“ zählt **verschiedene** Fragen (`versuche > 0`, * `lernstand.ts`). Dieselbe Frage ein zweites Mal zu beantworten lässt die * Zahl also stehen – und genau das passierte: Beantwortet wird hier stets die * erste Antwortmöglichkeit, die oft falsch ist, und eine falsch beantwortete * Frage ist bei der nächsten Wiedervorlage die dringendste. „Weiterlernen“ * legte damit dieselbe Frage noch einmal vor. * * Nachgemessen: Im Lauf vom 27.08. blieb die Zahl bei 1 stehen und der Test * „zeigt vor dem Ersetzen …“ fiel aus; der Lauf davor war mit derselben * Fassung grün. Ein Münzwurf, kein Befund an der Anwendung. * * Der Ausweg führt **innerhalb** einer Sitzung weiter statt über eine neue: * In einer Sitzung kommt keine Frage zweimal vor, nach `vorher + 1` Antworten * muss also eine neue dabei gewesen sein. Eine neue Sitzung zu beginnen half * nicht – sie legte dieselbe dringende Frage wieder zuoberst. */ async function eineNeueFrageBeantworten(): Promise { await zumStart(); const vorher = await bearbeitet(); await fenster.getByRole('button', { name: /Weiterlernen/u }).click(); await fenster.getByRole('heading', { level: 1, name: /, Frage \d/u }).waitFor(); for (let i = 0; i <= vorher; i++) { await frageBeantworten(); const weiter = fenster.getByRole('button', { name: /^Nächste Frage$/u }); if ((await weiter.count()) === 0) { // Die Sitzung ist zu Ende – dann war die letzte Frage die letzte Gelegenheit. break; } await weiter.first().click(); await fenster.getByRole('heading', { level: 1, name: /, Frage \d/u }).waitFor(); } await zumStart(); } test.describe('Sicherung', () => { const datei = () => join(ablage, 'probe.wsklernstand'); /* Der Stand zum Zeitpunkt der Sicherung – auf ihn muss das Einspielen zurückführen. Ohne diesen Merkposten prüfte der Beweis unten nur, dass eine Erfolgsmeldung erscheint. */ let beiDerSicherung = -1; test('schreibt eine einzelne Datei – ohne Nebendateien', async () => { /* Der Grund für `VACUUM INTO` statt `db.backup()`: Dessen Ergebnis verwandelt sich beim Ansehen in drei Dateien, die liegen bleiben. */ await eineNeueFrageBeantworten(); await speicherzielStellen(datei()); await fenster.getByRole('button', { name: 'Sicherung speichern …' }).scrollIntoViewIfNeeded(); await fenster.getByRole('button', { name: 'Sicherung speichern …' }).click(); await expect(fenster.getByText(/Die Sicherung ist geschrieben/u)).toBeVisible(); beiDerSicherung = await bearbeitet(); expect(beiDerSicherung).toBeGreaterThan(0); expect(existsSync(datei())).toBe(true); expect(existsSync(`${datei()}-wal`)).toBe(false); expect(existsSync(`${datei()}.teil`)).toBe(false); }); test('zeigt vor dem Ersetzen, was in der Datei steht – und ersetzt noch nichts', async () => { const vorher = await bearbeitet(); await eineNeueFrageBeantworten(); expect(await bearbeitet()).toBeGreaterThan(vorher); await oeffnenzielStellen(datei()); const knopf = fenster.getByRole('button', { name: 'Sicherung auswählen und ansehen …' }); await knopf.scrollIntoViewIfNeeded(); await knopf.click(); await expect( fenster.getByRole('heading', { name: 'Diesen Lernstand durch die Sicherung ersetzen?' }), ).toBeVisible(); /* Die Rückfrage steht da – der Lernstand ist unberührt. */ expect(await bearbeitet()).toBeGreaterThan(vorher); }); test('stellt beim Ersetzen den gesicherten Stand wirklich wieder her', async () => { /* Der eigentliche Beweis. Zwischen Sicherung und Einspielen wurde eine Frage mehr beantwortet; danach muss die Zahl wieder die der Sicherung sein. Wäre die Reihenfolge beim Ersetzen falsch – etwa ein verwaistes Schreibprotokoll neben der neuen Datei –, stünde hier der falsche Stand bei tadelloser Datenbankprüfung. */ await fenster.getByRole('button', { name: 'Ja, alles ersetzen' }).click(); await expect(fenster.getByText(/Der Lernstand ist eingelesen/u)).toBeVisible(); /* Die Sicherheitskopie wird beim Namen genannt – sie ist der einzige Weg zurück, falls die eingespielte Datei doch nicht die gewünschte war. */ await expect(fenster.getByText(/Lernstand-vor-dem-Einspielen-/u)).toBeVisible(); /* Und jetzt die eigentliche Aussage: Die Zahl steht wieder auf dem Stand der Sicherung, nicht auf dem von eben. */ await expect.poll(bearbeitet).toBe(beiDerSicherung); }); test('weist eine fremde Datei ab, ohne etwas anzufassen', async () => { const vorher = await bearbeitet(); const fremd = join(ablage, 'urlaub.wsklernstand'); writeFileSync(fremd, 'x'.repeat(2048)); await oeffnenzielStellen(fremd); const knopf = fenster.getByRole('button', { name: 'Sicherung auswählen und ansehen …' }); await knopf.scrollIntoViewIfNeeded(); await knopf.click(); await expect(fenster.getByText(/keine Datenbank/u)).toBeVisible(); await expect(fenster.getByText(/Es wurde nichts verändert/u)).toBeVisible(); expect(await bearbeitet()).toBe(vorher); }); });