/** * Die Volltextsuche im echten Fenster. * * Der Suchkern ist in `tests/fragensuche.test.ts` gegen den echten Katalog * gemessen, die Oberfläche in `tests/renderer-suche.test.tsx` gegen eine * Attrappe. Hier läuft beides zusammen – und zwar an dem Bestand, den der * Nutzer tatsächlich vor sich hat: 575 amtliche Fragen. * * Zwei Dinge lassen sich nur hier prüfen: der Durchstich von der Suche über * eine Sitzung zurück in dieselbe Trefferliste, und die Bedienbarkeit * ausschließlich mit der Tastatur. */ 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(); }); function feld() { return fenster.getByLabel('Suchbegriff'); } /** Die Überschriften der Trefferkarten. */ function treffer() { return fenster.locator('.treffer__frage'); } 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); } } async function sucheOeffnen(): Promise { await zumStart(); await fenster.getByRole('button', { name: /Fragen durchsuchen/u }).click(); await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); } test.describe('Suche – am echten Katalog', () => { test('findet über Umlaute, ß und Zusammensetzungen hinweg', async () => { await sucheOeffnen(); /* Die drei Fälle, an denen SQLite FTS5 nachweislich scheitert – siehe docs/entscheidung-volltextsuche.md. Hier müssen sie stimmen, sonst war die Abweichung vom Plan umsonst. */ const faelle: readonly (readonly [string, string])[] = [ ['schiessen', 'Fragen gefunden'], ['schießen', 'Fragen gefunden'], ['besitzkarte', 'Fragen gefunden'], ['buechse', 'Fragen gefunden'], ]; for (const [eingabe, erwartet] of faelle) { await feld().fill(eingabe); await fenster.waitForTimeout(500); await expect(fenster.locator('.suche__anzahl'), `„${eingabe}“ findet nichts`).toContainText( erwartet, ); expect(await treffer().count(), `„${eingabe}“ ohne Trefferkarte`).toBeGreaterThan(0); } }); test('liefert für beide Schreibweisen dieselbe Zahl', async () => { await sucheOeffnen(); await feld().fill('schiessen'); await fenster.waitForTimeout(500); const mitSS = (await fenster.locator('.suche__anzahl').textContent()) ?? ''; await feld().fill('schießen'); await fenster.waitForTimeout(500); expect(await fenster.locator('.suche__anzahl').textContent()).toBe(mitSS); }); test('zeigt bei leerem Feld den ganzen Bereich – der Fragen-Browser', async () => { await sucheOeffnen(); await feld().fill(''); await fenster.getByRole('radio', { name: /I\.5 – Notwehr und Notstand \(43\)/u }).check(); await fenster.waitForTimeout(500); await expect(fenster.locator('.suche__anzahl')).toContainText('43 Fragen'); }); test('schlägt eine Frage über ihre amtliche Nummer nach', async () => { await sucheOeffnen(); await fenster.getByRole('radio', { name: /Alle Bereiche/u }).check(); await feld().fill('2.150'); await fenster.waitForTimeout(400); await expect(fenster.getByText('Über die Fragennummer gefunden:')).toBeVisible(); }); }); test.describe('Suche – der Durchstich', () => { test('führt von der Suche ins Üben und zurück in dieselbe Trefferliste', async () => { await sucheOeffnen(); /* Eingeschränkt, und das ist seit Fassung 0.26.0 der eigentliche Punkt: Bis dahin kam nur der Suchtext zurück, und die Liste stand danach über dem ganzen Katalog. „Dieselbe Trefferliste“, wie dieser Prüflauf seit jeher heißt, war sie damit nicht. */ const bereich = fenster.getByRole('radio', { name: /^I\.1 /u }).first(); await bereich.check(); await feld().fill('Anscheinswaffe'); await fenster.waitForTimeout(500); const ersterKnopf = fenster.getByRole('button', { name: /^Frage [\d.]+ üben$/u }).first(); const beschriftung = (await ersterKnopf.textContent())?.trim() ?? ''; await ersterKnopf.click(); // In der Sitzung: genau eine Frage. await expect(fenster.getByText(/Frage 1 von 1/u)).toBeVisible(); await fenster.getByRole('button', { name: 'Sitzung beenden' }).click(); await fenster.getByRole('button', { name: 'Zurück zur Suche' }).click(); await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); /* Der Suchtext steht wieder da – das ist der Punkt der ganzen Übung. Wer eine Frage aus einer Suche übt, will danach die übrigen Treffer sehen und nicht denselben Begriff noch einmal tippen. */ await expect(feld()).toHaveValue('Anscheinswaffe'); await expect(bereich).toBeChecked(); /* Und die Zählzeile nennt den Bereich – der sichtbare Stand IST die Ansage, eine eigene Meldung „wiederhergestellt“ gibt es bewusst nicht. */ await expect(fenster.locator('.suche__anzahl')).toContainText('I.1'); expect(beschluesselt(beschriftung)).toBe(true); }); test('vergisst die Anfrage auf dem Weg über den Startbildschirm', async () => { /* Die Wache über die Verfallsregel. Die Anfrage reist mit dem Sitzungsauftrag und stirbt mit ihm; wer über den Startbildschirm geht, beginnt von vorn. Deckt zugleich ab, dass zwischen zwei Prüfläufen nichts wandert. */ await zumStart(); await fenster.getByRole('button', { name: /Fragen durchsuchen/u }).click(); await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); await expect(feld()).toHaveValue(''); await expect(fenster.getByRole('radio', { name: /Alle Bereiche/u })).toBeChecked(); }); }); /** Der Knopf trägt seinen vollen Namen (WCAG 2.4.6), nicht nur „Üben“. */ function beschluesselt(beschriftung: string): boolean { return /^Frage [\d.]+ üben$/u.test(beschriftung); } test.describe('Suche – ohne Maus', () => { test('ist vollständig mit der Tastatur bedienbar', async () => { await sucheOeffnen(); /* Leeren, bevor getippt wird, und seit Fassung 0.26.0 aus einem anderen Grund als vorher: Das Feld ist nach `sucheOeffnen()` leer, weil der Weg über den Startbildschirm führt und eine Suchanfrage nur den Rückweg aus einer Sitzung überlebt. Das Leeren bleibt als Unabhängigkeit vom vorigen Lauf stehen — Playwright fährt die Läufe dieser Datei nacheinander im selben Fenster. */ await feld().fill(''); await feld().focus(); await fenster.keyboard.type('Notwehr'); await fenster.waitForTimeout(500); expect(await treffer().count()).toBeGreaterThan(0); /* Die Bereichsgruppe ist genau EIN Tabulatorhalt – das ist der Grund für die Radiogruppe statt zwölf einzelner Halte. Vom Feld aus ein Tab, dann steht der Fokus in der Gruppe und die Pfeiltasten wählen. */ await fenster.keyboard.press('Tab'); const inGruppe = await fenster.evaluate( () => document.activeElement?.closest('.suche__bereiche') !== null, ); expect(inGruppe, 'Nach einem Tab steht der Fokus nicht in der Bereichsgruppe.').toBe(true); }); test('setzt den Fokus nach dem Nachladen auf den ersten neuen Treffer', async () => { await sucheOeffnen(); await feld().fill('waffe'); await fenster.waitForTimeout(500); const vorher = await treffer().count(); expect(vorher).toBe(25); await fenster.getByRole('button', { name: /Weitere \d+ anzeigen/u }).click(); await fenster.waitForTimeout(300); /* Die neuen Karten stehen im Baum VOR dem Knopf. Bliebe der Fokus dort, würden sie beim Weitertabben vollständig übersprungen. */ const aufNeuem = await fenster.evaluate( () => document.activeElement?.classList.contains('treffer__frage') ?? false, ); expect(aufNeuem, 'Der Fokus steht nicht auf dem ersten neuen Treffer.').toBe(true); expect(await treffer().count()).toBe(50); }); }); test.describe('Suche – verrät die Lösung nicht', () => { test('nennt bei einer offenen Frage die Musterantwort nicht', async () => { await sucheOeffnen(); /* „5.01“ ist der Lückentext; seine Musterantwort beginnt mit „Lösung:“ – ein Wort, das in der Trefferliste nichts zu suchen hat. */ await feld().fill('5.01'); await fenster.waitForTimeout(400); const inhalt = (await fenster.locator('main').textContent()) ?? ''; expect(inhalt).not.toContain('Lösung:'); }); test('weist im DOM nirgends aus, welche Antwort richtig ist', async () => { await sucheOeffnen(); await feld().fill('Anscheinswaffe'); await fenster.waitForTimeout(500); await fenster .getByText(/Antwortmöglichkeiten anzeigen/u) .first() .click(); const verraeter = await fenster.evaluate(() => { const bereich = document.querySelector('.suche'); if (bereich === null) { return ['kein Suchbereich']; } const gefunden: string[] = []; for (const knoten of bereich.querySelectorAll('*')) { for (const attribut of knoten.attributes) { if (/korrekt|richtig/iu.test(attribut.name) || /korrekt|richtig/iu.test(attribut.value)) { gefunden.push(`${knoten.tagName}[${attribut.name}="${attribut.value}"]`); } } } return gefunden; }); expect(verraeter).toEqual([]); }); });