waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app e2e suche.spec.ts

9,7 KB Rohdatei
app/e2e/suche.spec.ts — 255 Zeilen
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 });