waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Die Volltextsuche im echten Fenster. |
| 3 | * |
| 4 | * Der Suchkern ist in `tests/fragensuche.test.ts` gegen den echten Katalog |
| 5 | * gemessen, die Oberfläche in `tests/renderer-suche.test.tsx` gegen eine |
| 6 | * Attrappe. Hier läuft beides zusammen – und zwar an dem Bestand, den der |
| 7 | * Nutzer tatsächlich vor sich hat: 575 amtliche Fragen. |
| 8 | * |
| 9 | * Zwei Dinge lassen sich nur hier prüfen: der Durchstich von der Suche über |
| 10 | * eine Sitzung zurück in dieselbe Trefferliste, und die Bedienbarkeit |
| 11 | * ausschließlich mit der Tastatur. |
| 12 | */ |
| 13 | |
| 14 | import { type ElectronApplication, type Page } from '@playwright/test'; |
| 15 | |
| 16 | import { amStart, appStarten, bauPruefen } from './electron-hilfe'; |
| 17 | import { expect, test } from './konsolenwache'; |
| 18 | |
| 19 | let app: ElectronApplication | undefined; |
| 20 | let fenster: Page; |
| 21 | |
| 22 | test.beforeAll(async () => { |
| 23 | bauPruefen(); |
| 24 | ({ app, fenster } = await appStarten()); |
| 25 | }); |
| 26 | |
| 27 | test.afterAll(async () => { |
| 28 | await app?.close(); |
| 29 | }); |
| 30 | |
| 31 | function feld() { |
| 32 | return fenster.getByLabel('Suchbegriff'); |
| 33 | } |
| 34 | |
| 35 | /** Die Überschriften der Trefferkarten. */ |
| 36 | function treffer() { |
| 37 | return fenster.locator('.treffer__frage'); |
| 38 | } |
| 39 | |
| 40 | async function zumStart(): Promise<void> { |
| 41 | for (let i = 0; i < 4; i++) { |
| 42 | if (await amStart(fenster)) { |
| 43 | return; |
| 44 | } |
| 45 | const zurueck = fenster.getByRole('button', { name: /^(Zum Start|Sitzung beenden)$/u }); |
| 46 | if ((await zurueck.count()) === 0) { |
| 47 | return; |
| 48 | } |
| 49 | await zurueck.first().click(); |
| 50 | await fenster.waitForTimeout(250); |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | async function sucheOeffnen(): Promise<void> { |
| 55 | await zumStart(); |
| 56 | await fenster.getByRole('button', { name: /Fragen durchsuchen/u }).click(); |
| 57 | await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); |
| 58 | } |
| 59 | |
| 60 | test.describe('Suche – am echten Katalog', () => { |
| 61 | test('findet über Umlaute, ß und Zusammensetzungen hinweg', async () => { |
| 62 | await sucheOeffnen(); |
| 63 | |
| 64 | /* |
| 65 | Die drei Fälle, an denen SQLite FTS5 nachweislich scheitert – siehe |
| 66 | docs/entscheidung-volltextsuche.md. Hier müssen sie stimmen, sonst war |
| 67 | die Abweichung vom Plan umsonst. |
| 68 | */ |
| 69 | const faelle: readonly (readonly [string, string])[] = [ |
| 70 | ['schiessen', 'Fragen gefunden'], |
| 71 | ['schießen', 'Fragen gefunden'], |
| 72 | ['besitzkarte', 'Fragen gefunden'], |
| 73 | ['buechse', 'Fragen gefunden'], |
| 74 | ]; |
| 75 | |
| 76 | for (const [eingabe, erwartet] of faelle) { |
| 77 | await feld().fill(eingabe); |
| 78 | await fenster.waitForTimeout(500); |
| 79 | await expect(fenster.locator('.suche__anzahl'), `„${eingabe}“ findet nichts`).toContainText( |
| 80 | erwartet, |
| 81 | ); |
| 82 | expect(await treffer().count(), `„${eingabe}“ ohne Trefferkarte`).toBeGreaterThan(0); |
| 83 | } |
| 84 | }); |
| 85 | |
| 86 | test('liefert für beide Schreibweisen dieselbe Zahl', async () => { |
| 87 | await sucheOeffnen(); |
| 88 | await feld().fill('schiessen'); |
| 89 | await fenster.waitForTimeout(500); |
| 90 | const mitSS = (await fenster.locator('.suche__anzahl').textContent()) ?? ''; |
| 91 | |
| 92 | await feld().fill('schießen'); |
| 93 | await fenster.waitForTimeout(500); |
| 94 | expect(await fenster.locator('.suche__anzahl').textContent()).toBe(mitSS); |
| 95 | }); |
| 96 | |
| 97 | test('zeigt bei leerem Feld den ganzen Bereich – der Fragen-Browser', async () => { |
| 98 | await sucheOeffnen(); |
| 99 | await feld().fill(''); |
| 100 | await fenster.getByRole('radio', { name: /I\.5 – Notwehr und Notstand \(43\)/u }).check(); |
| 101 | await fenster.waitForTimeout(500); |
| 102 | await expect(fenster.locator('.suche__anzahl')).toContainText('43 Fragen'); |
| 103 | }); |
| 104 | |
| 105 | test('schlägt eine Frage über ihre amtliche Nummer nach', async () => { |
| 106 | await sucheOeffnen(); |
| 107 | await fenster.getByRole('radio', { name: /Alle Bereiche/u }).check(); |
| 108 | await feld().fill('2.150'); |
| 109 | await fenster.waitForTimeout(400); |
| 110 | await expect(fenster.getByText('Über die Fragennummer gefunden:')).toBeVisible(); |
| 111 | }); |
| 112 | }); |
| 113 | |
| 114 | test.describe('Suche – der Durchstich', () => { |
| 115 | test('führt von der Suche ins Üben und zurück in dieselbe Trefferliste', async () => { |
| 116 | await sucheOeffnen(); |
| 117 | /* Eingeschränkt, und das ist seit Fassung 0.26.0 der eigentliche Punkt: |
| 118 | Bis dahin kam nur der Suchtext zurück, und die Liste stand danach über |
| 119 | dem ganzen Katalog. „Dieselbe Trefferliste“, wie dieser Prüflauf seit |
| 120 | jeher heißt, war sie damit nicht. */ |
| 121 | const bereich = fenster.getByRole('radio', { name: /^I\.1 /u }).first(); |
| 122 | await bereich.check(); |
| 123 | await feld().fill('Anscheinswaffe'); |
| 124 | await fenster.waitForTimeout(500); |
| 125 | |
| 126 | const ersterKnopf = fenster.getByRole('button', { name: /^Frage [\d.]+ üben$/u }).first(); |
| 127 | const beschriftung = (await ersterKnopf.textContent())?.trim() ?? ''; |
| 128 | await ersterKnopf.click(); |
| 129 | |
| 130 | // In der Sitzung: genau eine Frage. |
| 131 | await expect(fenster.getByText(/Frage 1 von 1/u)).toBeVisible(); |
| 132 | |
| 133 | await fenster.getByRole('button', { name: 'Sitzung beenden' }).click(); |
| 134 | await fenster.getByRole('button', { name: 'Zurück zur Suche' }).click(); |
| 135 | |
| 136 | await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); |
| 137 | /* Der Suchtext steht wieder da – das ist der Punkt der ganzen Übung. Wer |
| 138 | eine Frage aus einer Suche übt, will danach die übrigen Treffer sehen |
| 139 | und nicht denselben Begriff noch einmal tippen. */ |
| 140 | await expect(feld()).toHaveValue('Anscheinswaffe'); |
| 141 | await expect(bereich).toBeChecked(); |
| 142 | /* Und die Zählzeile nennt den Bereich – der sichtbare Stand IST die |
| 143 | Ansage, eine eigene Meldung „wiederhergestellt“ gibt es bewusst |
| 144 | nicht. */ |
| 145 | await expect(fenster.locator('.suche__anzahl')).toContainText('I.1'); |
| 146 | expect(beschluesselt(beschriftung)).toBe(true); |
| 147 | }); |
| 148 | |
| 149 | test('vergisst die Anfrage auf dem Weg über den Startbildschirm', async () => { |
| 150 | /* Die Wache über die Verfallsregel. Die Anfrage reist mit dem |
| 151 | Sitzungsauftrag und stirbt mit ihm; wer über den Startbildschirm geht, |
| 152 | beginnt von vorn. Deckt zugleich ab, dass zwischen zwei Prüfläufen |
| 153 | nichts wandert. */ |
| 154 | await zumStart(); |
| 155 | await fenster.getByRole('button', { name: /Fragen durchsuchen/u }).click(); |
| 156 | await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); |
| 157 | |
| 158 | await expect(feld()).toHaveValue(''); |
| 159 | await expect(fenster.getByRole('radio', { name: /Alle Bereiche/u })).toBeChecked(); |
| 160 | }); |
| 161 | }); |
| 162 | |
| 163 | /** Der Knopf trägt seinen vollen Namen (WCAG 2.4.6), nicht nur „Üben“. */ |
| 164 | function beschluesselt(beschriftung: string): boolean { |
| 165 | return /^Frage [\d.]+ üben$/u.test(beschriftung); |
| 166 | } |
| 167 | |
| 168 | test.describe('Suche – ohne Maus', () => { |
| 169 | test('ist vollständig mit der Tastatur bedienbar', async () => { |
| 170 | await sucheOeffnen(); |
| 171 | |
| 172 | /* Leeren, bevor getippt wird, und seit Fassung 0.26.0 aus einem anderen |
| 173 | Grund als vorher: Das Feld ist nach `sucheOeffnen()` leer, weil der |
| 174 | Weg über den Startbildschirm führt und eine Suchanfrage nur den |
| 175 | Rückweg aus einer Sitzung überlebt. Das Leeren bleibt als |
| 176 | Unabhängigkeit vom vorigen Lauf stehen — Playwright fährt die Läufe |
| 177 | dieser Datei nacheinander im selben Fenster. */ |
| 178 | await feld().fill(''); |
| 179 | await feld().focus(); |
| 180 | await fenster.keyboard.type('Notwehr'); |
| 181 | await fenster.waitForTimeout(500); |
| 182 | expect(await treffer().count()).toBeGreaterThan(0); |
| 183 | |
| 184 | /* |
| 185 | Die Bereichsgruppe ist genau EIN Tabulatorhalt – das ist der Grund für |
| 186 | die Radiogruppe statt zwölf einzelner Halte. Vom Feld aus ein Tab, dann |
| 187 | steht der Fokus in der Gruppe und die Pfeiltasten wählen. |
| 188 | */ |
| 189 | await fenster.keyboard.press('Tab'); |
| 190 | const inGruppe = await fenster.evaluate( |
| 191 | () => document.activeElement?.closest('.suche__bereiche') !== null, |
| 192 | ); |
| 193 | expect(inGruppe, 'Nach einem Tab steht der Fokus nicht in der Bereichsgruppe.').toBe(true); |
| 194 | }); |
| 195 | |
| 196 | test('setzt den Fokus nach dem Nachladen auf den ersten neuen Treffer', async () => { |
| 197 | await sucheOeffnen(); |
| 198 | await feld().fill('waffe'); |
| 199 | await fenster.waitForTimeout(500); |
| 200 | |
| 201 | const vorher = await treffer().count(); |
| 202 | expect(vorher).toBe(25); |
| 203 | |
| 204 | await fenster.getByRole('button', { name: /Weitere \d+ anzeigen/u }).click(); |
| 205 | await fenster.waitForTimeout(300); |
| 206 | |
| 207 | /* Die neuen Karten stehen im Baum VOR dem Knopf. Bliebe der Fokus dort, |
| 208 | würden sie beim Weitertabben vollständig übersprungen. */ |
| 209 | const aufNeuem = await fenster.evaluate( |
| 210 | () => document.activeElement?.classList.contains('treffer__frage') ?? false, |
| 211 | ); |
| 212 | expect(aufNeuem, 'Der Fokus steht nicht auf dem ersten neuen Treffer.').toBe(true); |
| 213 | expect(await treffer().count()).toBe(50); |
| 214 | }); |
| 215 | }); |
| 216 | |
| 217 | test.describe('Suche – verrät die Lösung nicht', () => { |
| 218 | test('nennt bei einer offenen Frage die Musterantwort nicht', async () => { |
| 219 | await sucheOeffnen(); |
| 220 | /* „5.01“ ist der Lückentext; seine Musterantwort beginnt mit „Lösung:“ – |
| 221 | ein Wort, das in der Trefferliste nichts zu suchen hat. */ |
| 222 | await feld().fill('5.01'); |
| 223 | await fenster.waitForTimeout(400); |
| 224 | |
| 225 | const inhalt = (await fenster.locator('main').textContent()) ?? ''; |
| 226 | expect(inhalt).not.toContain('Lösung:'); |
| 227 | }); |
| 228 | |
| 229 | test('weist im DOM nirgends aus, welche Antwort richtig ist', async () => { |
| 230 | await sucheOeffnen(); |
| 231 | await feld().fill('Anscheinswaffe'); |
| 232 | await fenster.waitForTimeout(500); |
| 233 | await fenster |
| 234 | .getByText(/Antwortmöglichkeiten anzeigen/u) |
| 235 | .first() |
| 236 | .click(); |
| 237 | |
| 238 | const verraeter = await fenster.evaluate(() => { |
| 239 | const bereich = document.querySelector('.suche'); |
| 240 | if (bereich === null) { |
| 241 | return ['kein Suchbereich']; |
| 242 | } |
| 243 | const gefunden: string[] = []; |
| 244 | for (const knoten of bereich.querySelectorAll('*')) { |
| 245 | for (const attribut of knoten.attributes) { |
| 246 | if (/korrekt|richtig/iu.test(attribut.name) || /korrekt|richtig/iu.test(attribut.value)) { |
| 247 | gefunden.push(`${knoten.tagName}[${attribut.name}="${attribut.value}"]`); |
| 248 | } |
| 249 | } |
| 250 | } |
| 251 | return gefunden; |
| 252 | }); |
| 253 | expect(verraeter).toEqual([]); |
| 254 | }); |
| 255 | }); |