waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Die Prüfungsreife-Ampel im echten Fenster. |
| 3 | * |
| 4 | * Was `tests/reife.test.ts` an der reinen Rechnung prüft und |
| 5 | * `tests/lernstand.test.ts` an einer Datenbank, läuft hier durch die ganze |
| 6 | * Kette: echte SQLite-Datei, echter Katalog mit 575 Fragen, echte Oberfläche. |
| 7 | * |
| 8 | * Der Schwerpunkt liegt auf dem, was ein Bildschirmleser bekommt. Eine Ampel, |
| 9 | * die nur farbig funktioniert, wäre für diese Anwendung kein Merkmal, sondern |
| 10 | * ein Mangel – und der Rauchtest mit axe sieht Farbe nicht. |
| 11 | */ |
| 12 | |
| 13 | import { type ElectronApplication, type Page } from '@playwright/test'; |
| 14 | |
| 15 | import { amStart, appStarten, bauPruefen } from './electron-hilfe'; |
| 16 | import { expect, test } from './konsolenwache'; |
| 17 | |
| 18 | let app: ElectronApplication | undefined; |
| 19 | let fenster: Page; |
| 20 | |
| 21 | test.beforeAll(async () => { |
| 22 | bauPruefen(); |
| 23 | ({ app, fenster } = await appStarten()); |
| 24 | }); |
| 25 | |
| 26 | test.afterAll(async () => { |
| 27 | await app?.close(); |
| 28 | }); |
| 29 | |
| 30 | test.describe.configure({ mode: 'serial' }); |
| 31 | |
| 32 | function gesamtampel() { |
| 33 | return fenster.getByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); |
| 34 | } |
| 35 | |
| 36 | async function zumStart(): Promise<void> { |
| 37 | for (let i = 0; i < 4; i++) { |
| 38 | if (await amStart(fenster)) { |
| 39 | return; |
| 40 | } |
| 41 | const zurueck = fenster.getByRole('button', { name: /^(Zum Start|Sitzung beenden)$/u }); |
| 42 | if ((await zurueck.count()) === 0) { |
| 43 | return; |
| 44 | } |
| 45 | await zurueck.first().click(); |
| 46 | await fenster.waitForTimeout(250); |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | test.describe('Prüfungsreife-Ampel', () => { |
| 51 | test('trägt ihre Aussage vollständig im Text, nicht in der Farbe', async () => { |
| 52 | await zumStart(); |
| 53 | |
| 54 | /* Der Balken meldet Stufe und Zahl über `aria-valuetext`. Ohne das läse |
| 55 | ein Screenreader eine nackte Zahl zwischen 0 und 575 vor – richtig und |
| 56 | nutzlos. */ |
| 57 | const text = await gesamtampel().getAttribute('aria-valuetext'); |
| 58 | expect(text).toBeTruthy(); |
| 59 | expect(text).toMatch(/belegt|Tag Abstand/u); |
| 60 | |
| 61 | /* Und das Stufenwort steht sichtbar daneben, nicht nur im Attribut. */ |
| 62 | await expect( |
| 63 | fenster |
| 64 | .getByText(/Noch kein belegter Stand|Noch nicht so weit|Auf der Kippe|Prüfungsreif/u) |
| 65 | .first(), |
| 66 | ).toBeVisible(); |
| 67 | }); |
| 68 | |
| 69 | test('nennt bei leerem Stand die Regel statt einer Stufe', async () => { |
| 70 | /* Wer noch keine Frage wiedergesehen hat, bekommt keine Einstufung |
| 71 | vorgegaukelt, sondern die Auskunft, woran das liegt. */ |
| 72 | await expect(gesamtampel()).toHaveAttribute('aria-valuenow', '0'); |
| 73 | expect(await gesamtampel().getAttribute('aria-valuetext')).toContain( |
| 74 | 'mindestens einem Tag Abstand', |
| 75 | ); |
| 76 | }); |
| 77 | |
| 78 | test('verspricht nirgends eine Bestehenswahrscheinlichkeit', async () => { |
| 79 | /* Eine Angabe wie „neun von zehn Läufen“ setzte voraus, dass alle Fragen |
| 80 | dieselbe Trefferchance haben. Das Gegenteil ist der Fall – die |
| 81 | Verteilung ist zweigipflig, und die Zahl wäre exakt aussehend und |
| 82 | falsch. Siehe docs/entscheidung-reifegrad.md. */ |
| 83 | const seite = (await fenster.locator('main').innerText()).toLowerCase(); |
| 84 | |
| 85 | expect(seite).not.toMatch(/von zehn (prüfungen|läufen)/u); |
| 86 | expect(seite).not.toContain('wahrscheinlichkeit zu bestehen'); |
| 87 | }); |
| 88 | |
| 89 | test('bleibt nach einer einzigen richtigen Antwort bei null', async () => { |
| 90 | /* |
| 91 | Der Kern der ganzen Umstellung, durch die volle Kette geprüft. Mit der |
| 92 | rohen FSRS-Abrufwahrscheinlichkeit stünde eine so beantwortete Frage bei |
| 93 | 0,947 – ein Katalog einmal zur Hälfte richtig durchgearbeitet hätte |
| 94 | „Prüfungsreif“ gemeldet. |
| 95 | */ |
| 96 | await zumStart(); |
| 97 | await fenster.getByRole('button', { name: /Weiterlernen/u }).click(); |
| 98 | await fenster |
| 99 | .getByRole('heading', { name: /Frage /u }) |
| 100 | .first() |
| 101 | .waitFor(); |
| 102 | |
| 103 | /* |
| 104 | Eine Frage beantworten – gleich welche Antwort, es geht um die Anzahl. |
| 105 | |
| 106 | Beide Fragetypen müssen bedient werden: Der Katalog hat 104 offene |
| 107 | Fragen unter 575, die Sitzung zieht also in etwa jedem fünften Lauf |
| 108 | eine ohne Antwortkästchen. Die erste Fassung dieses Tests klickte blind |
| 109 | auf ein Kästchen und war damit unzuverlässig – sie fiel im vollen Lauf |
| 110 | um und ging allein durch. |
| 111 | */ |
| 112 | const auswahl = fenster.getByRole('checkbox').or(fenster.getByRole('radio')); |
| 113 | if ((await auswahl.count()) > 0) { |
| 114 | await auswahl.first().check(); |
| 115 | await fenster.getByRole('button', { name: 'Antwort bestätigen', exact: true }).click(); |
| 116 | } else { |
| 117 | await fenster.getByRole('button', { name: 'Musterantwort anzeigen' }).click(); |
| 118 | await fenster.getByRole('button', { name: 'Gewusst', exact: true }).click(); |
| 119 | } |
| 120 | await fenster.getByRole('button', { name: /^Nächste Frage$/u }).click(); |
| 121 | |
| 122 | await zumStart(); |
| 123 | await expect(gesamtampel()).toHaveAttribute('aria-valuenow', '0'); |
| 124 | }); |
| 125 | |
| 126 | test('erklärt aufklappbar, was „belegt“ heißt – und die Erklärung nennt den Vorbehalt', async () => { |
| 127 | await zumStart(); |
| 128 | |
| 129 | const erklaerung = fenster.getByText('Was heißt „belegt“?'); |
| 130 | await erklaerung.scrollIntoViewIfNeeded(); |
| 131 | await erklaerung.click(); |
| 132 | |
| 133 | await expect(fenster.getByText(/Raten ist nicht eingerechnet/u)).toBeVisible(); |
| 134 | await expect(fenster.getByText(/allein der Prüfungsausschuss/u)).toBeVisible(); |
| 135 | |
| 136 | /* Und genau einmal: Die Lernplan-Fußnote trug denselben Vorbehalt ein |
| 137 | zweites Mal, für eine Trefferquote, die es als eigene Zahl nicht mehr |
| 138 | gibt. Zwei Erklärungen derselben Sache driften auseinander. */ |
| 139 | expect(await fenster.getByText(/Raten ist nicht eingerechnet/u).count()).toBe(1); |
| 140 | }); |
| 141 | |
| 142 | test('führt jeden Bereich mit eigener Ampel auf', async () => { |
| 143 | await zumStart(); |
| 144 | |
| 145 | const bereiche = fenster.getByText('Nach Kapiteln und Abschnitten'); |
| 146 | await bereiche.scrollIntoViewIfNeeded(); |
| 147 | await bereiche.click(); |
| 148 | |
| 149 | /* Notwehr und Notstand ist der Bereich, an dem das Gesamturteil hängen |
| 150 | kann – er muss einzeln sichtbar sein, sonst ist der Hinweis im |
| 151 | Kernsatz nicht nachvollziehbar. */ |
| 152 | await expect( |
| 153 | fenster.getByRole('progressbar', { name: /Prüfungsreife in Notwehr und Notstand/u }), |
| 154 | ).toBeAttached(); |
| 155 | }); |
| 156 | }); |