/** * Die Prüfungsreife-Ampel im echten Fenster. * * Was `tests/reife.test.ts` an der reinen Rechnung prüft und * `tests/lernstand.test.ts` an einer Datenbank, läuft hier durch die ganze * Kette: echte SQLite-Datei, echter Katalog mit 575 Fragen, echte Oberfläche. * * Der Schwerpunkt liegt auf dem, was ein Bildschirmleser bekommt. Eine Ampel, * die nur farbig funktioniert, wäre für diese Anwendung kein Merkmal, sondern * ein Mangel – und der Rauchtest mit axe sieht Farbe nicht. */ 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; test.beforeAll(async () => { bauPruefen(); ({ app, fenster } = await appStarten()); }); test.afterAll(async () => { await app?.close(); }); test.describe.configure({ mode: 'serial' }); function gesamtampel() { return fenster.getByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); } 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); } } test.describe('Prüfungsreife-Ampel', () => { test('trägt ihre Aussage vollständig im Text, nicht in der Farbe', async () => { await zumStart(); /* Der Balken meldet Stufe und Zahl über `aria-valuetext`. Ohne das läse ein Screenreader eine nackte Zahl zwischen 0 und 575 vor – richtig und nutzlos. */ const text = await gesamtampel().getAttribute('aria-valuetext'); expect(text).toBeTruthy(); expect(text).toMatch(/belegt|Tag Abstand/u); /* Und das Stufenwort steht sichtbar daneben, nicht nur im Attribut. */ await expect( fenster .getByText(/Noch kein belegter Stand|Noch nicht so weit|Auf der Kippe|Prüfungsreif/u) .first(), ).toBeVisible(); }); test('nennt bei leerem Stand die Regel statt einer Stufe', async () => { /* Wer noch keine Frage wiedergesehen hat, bekommt keine Einstufung vorgegaukelt, sondern die Auskunft, woran das liegt. */ await expect(gesamtampel()).toHaveAttribute('aria-valuenow', '0'); expect(await gesamtampel().getAttribute('aria-valuetext')).toContain( 'mindestens einem Tag Abstand', ); }); test('verspricht nirgends eine Bestehenswahrscheinlichkeit', async () => { /* Eine Angabe wie „neun von zehn Läufen“ setzte voraus, dass alle Fragen dieselbe Trefferchance haben. Das Gegenteil ist der Fall – die Verteilung ist zweigipflig, und die Zahl wäre exakt aussehend und falsch. Siehe docs/entscheidung-reifegrad.md. */ const seite = (await fenster.locator('main').innerText()).toLowerCase(); expect(seite).not.toMatch(/von zehn (prüfungen|läufen)/u); expect(seite).not.toContain('wahrscheinlichkeit zu bestehen'); }); test('bleibt nach einer einzigen richtigen Antwort bei null', async () => { /* Der Kern der ganzen Umstellung, durch die volle Kette geprüft. Mit der rohen FSRS-Abrufwahrscheinlichkeit stünde eine so beantwortete Frage bei 0,947 – ein Katalog einmal zur Hälfte richtig durchgearbeitet hätte „Prüfungsreif“ gemeldet. */ await zumStart(); await fenster.getByRole('button', { name: /Weiterlernen/u }).click(); await fenster .getByRole('heading', { name: /Frage /u }) .first() .waitFor(); /* Eine Frage beantworten – gleich welche Antwort, es geht um die Anzahl. Beide Fragetypen müssen bedient werden: Der Katalog hat 104 offene Fragen unter 575, die Sitzung zieht also in etwa jedem fünften Lauf eine ohne Antwortkästchen. Die erste Fassung dieses Tests klickte blind auf ein Kästchen und war damit unzuverlässig – sie fiel im vollen Lauf um und ging allein durch. */ 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', exact: true }).click(); } else { await fenster.getByRole('button', { name: 'Musterantwort anzeigen' }).click(); await fenster.getByRole('button', { name: 'Gewusst', exact: true }).click(); } await fenster.getByRole('button', { name: /^Nächste Frage$/u }).click(); await zumStart(); await expect(gesamtampel()).toHaveAttribute('aria-valuenow', '0'); }); test('erklärt aufklappbar, was „belegt“ heißt – und die Erklärung nennt den Vorbehalt', async () => { await zumStart(); const erklaerung = fenster.getByText('Was heißt „belegt“?'); await erklaerung.scrollIntoViewIfNeeded(); await erklaerung.click(); await expect(fenster.getByText(/Raten ist nicht eingerechnet/u)).toBeVisible(); await expect(fenster.getByText(/allein der Prüfungsausschuss/u)).toBeVisible(); /* Und genau einmal: Die Lernplan-Fußnote trug denselben Vorbehalt ein zweites Mal, für eine Trefferquote, die es als eigene Zahl nicht mehr gibt. Zwei Erklärungen derselben Sache driften auseinander. */ expect(await fenster.getByText(/Raten ist nicht eingerechnet/u).count()).toBe(1); }); test('führt jeden Bereich mit eigener Ampel auf', async () => { await zumStart(); const bereiche = fenster.getByText('Nach Kapiteln und Abschnitten'); await bereiche.scrollIntoViewIfNeeded(); await bereiche.click(); /* Notwehr und Notstand ist der Bereich, an dem das Gesamturteil hängen kann – er muss einzeln sichtbar sein, sonst ist der Hinweis im Kernsatz nicht nachvollziehbar. */ await expect( fenster.getByRole('progressbar', { name: /Prüfungsreife in Notwehr und Notstand/u }), ).toBeAttached(); }); });