/** * Das Hilfefenster im echten Fenster. * * ## Warum diese Suite nötig ist * * Die Unit-Tests in `tests/renderer-hilfe.test.tsx` prüfen Inhalt, Aufbau und * Erreichbarkeit – aber nicht das, was die Hilfe überhaupt erst zu einem * Dialog macht. jsdom 27 kennt `` als Element, hat aber weder * `showModal()` noch `close()`; dort läuft der Dialog über einen Rückfallweg * und ist **nicht modal**. Fokusfalle, Inertheit des Hintergrunds und die * Rückgabe des Fokus gibt es nur in Chromium – also nur hier. * * Dazu kommt der Weg über die Menüleiste: Sie liegt im Hauptprozess und ist * unter jsdom gar nicht vorhanden. */ import { type ElectronApplication, type Page } from '@playwright/test'; import { HILFE_KAPITEL, HILFE_TITEL } from '../src/shared/hilfe'; import { amStart, animationenAbwarten, 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(); }); /** Der Weg zur Hilfe: die erste Schaltfläche im Fußbereich. */ function hilfeknopf() { return fenster.getByRole('contentinfo').getByRole('button', { name: 'Hilfe', exact: true }); } function hilfefenster() { return fenster.getByRole('dialog', { name: HILFE_TITEL }); } async function hilfeOeffnen(): Promise { await hilfeknopf().click(); await hilfefenster().waitFor(); await animationenAbwarten(fenster); } async function hilfeSchliessen(): Promise { if ((await hilfefenster().count()) > 0) { await fenster.keyboard.press('Escape'); await hilfefenster().waitFor({ state: 'hidden' }); } } /** Zurück auf den Startbildschirm, egal wo der vorige Test aufgehört hat. */ async function zumStart(): Promise { await hilfeSchliessen(); 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('Hilfe – ein wirklich modaler Dialog', () => { test('öffnet sich modal und legt sich über die Ansicht', async () => { await zumStart(); await hilfeOeffnen(); /* `:modal` trifft nur auf einen über showModal() geöffneten Dialog zu. Ein `` ohne Modalität fiele hier durch – und genau das ist der Unterschied, den jsdom nicht abbilden kann. */ const istModal = await fenster.evaluate( () => document.querySelector('dialog')?.matches(':modal') ?? false, ); expect(istModal, 'Der Dialog ist nicht modal geöffnet.').toBe(true); await hilfeSchliessen(); }); test('hält den Fokus im Dialog fest', async () => { await zumStart(); await hilfeOeffnen(); /* Ohne Fokusfalle wandert der Tabulator hinter den Dialog und bedient Elemente, die optisch verdeckt sind. Zwanzig Schritte reichen: Das Inhaltsverzeichnis hat zwölf Verweise, dazu zwei Schaltflächen – nach einem vollen Umlauf wäre der Fokus draußen, wenn er es könnte. Die Zahl stand bis 0.24.2 bei „zehn“ und war schon damals falsch; sie ist keine Zusicherung, sondern die Begründung dafür, dass zwanzig Schritte reichen. */ for (let schritt = 0; schritt < 20; schritt++) { await fenster.keyboard.press('Tab'); const drinnen = await fenster.evaluate(() => { return document.querySelector('dialog')?.contains(document.activeElement) ?? false; }); expect( drinnen, `Der Fokus ist nach ${String(schritt + 1)} Tabs aus dem Dialog gefallen.`, ).toBe(true); } await hilfeSchliessen(); }); test('gibt den Fokus beim Schließen an die Schaltfläche zurück', async () => { await zumStart(); await hilfeOeffnen(); await fenster.keyboard.press('Escape'); await hilfefenster().waitFor({ state: 'hidden' }); /* Wer die Hilfe schließt, soll dort weitermachen, wo er war – nicht am Anfang des Dokuments (WCAG 2.4.3, Fokusreihenfolge). */ const zurueckAufKnopf = await fenster.evaluate( () => document.activeElement?.textContent.trim() === 'Hilfe', ); expect(zurueckAufKnopf, 'Der Fokus steht nach dem Schließen nicht auf „Hilfe“.').toBe(true); }); test('zeigt alle Kapitel und springt aus dem Verzeichnis dorthin', async () => { await zumStart(); await hilfeOeffnen(); for (const kapitel of HILFE_KAPITEL) { await expect( hilfefenster().getByRole('heading', { name: kapitel.titel, level: 3 }), ).toBeVisible(); } const letztes = HILFE_KAPITEL[HILFE_KAPITEL.length - 1]; await fenster.getByRole('link', { name: letztes?.titel ?? '' }).click(); /* Der Sprung soll den Fokus mitnehmen, nicht nur den Bildausschnitt: Wer nicht sieht, wohin gescrollt wurde, merkt sonst nichts davon. */ const beimKapitel = await fenster.evaluate( (id) => document.activeElement?.id === id, `hilfe-${letztes?.id ?? ''}`, ); expect(beimKapitel, 'Der Sprung aus dem Verzeichnis nimmt den Fokus nicht mit.').toBe(true); await hilfeSchliessen(); }); }); test.describe('Hilfe – Erreichbarkeit (WCAG 3.2.6)', () => { test('steht in jeder Ansicht als erste Schaltfläche im Fußbereich', async () => { const wege: readonly (readonly [string, RegExp])[] = [ ['Kapitelwahl', /Kapitel wählen/u], ['Glossar', /Fachbegriffe nachschlagen/u], ['Über diese Software', /Lizenzen und Herkunft anzeigen/u], ['Prüfungswahl', /Prüfungssimulation vorbereiten/u], ]; for (const [name, knopfname] of wege) { await zumStart(); await fenster.getByRole('button', { name: knopfname }).click(); await fenster.waitForTimeout(300); const erste = await fenster .getByRole('contentinfo') .getByRole('button') .first() .textContent(); expect(erste?.trim(), `${name}: Die Hilfe steht nicht zuoberst im Fußbereich.`).toBe('Hilfe'); } await zumStart(); }); test('geht über den Menüeintrag „Handbuch“ auf', async () => { await zumStart(); /* Derselbe Weg, den F1 nimmt: Der Accelerator hängt an diesem Menüeintrag. Ihn hier auszulösen prüft die ganze Kette – Menü im Hauptprozess, IPC-Meldung, Dialog in der Oberfläche. */ const gefunden = await app?.evaluate(({ Menu }) => { const menue = Menu.getApplicationMenu(); const hilfe = menue?.items.find((eintrag) => eintrag.label.replace('&', '') === 'Hilfe'); const handbuch = hilfe?.submenu?.items.find((eintrag) => eintrag.label === 'Handbuch'); handbuch?.click(); return handbuch !== undefined; }); expect(gefunden, 'Im Hilfemenü steht kein Eintrag „Handbuch“.').toBe(true); await expect(hilfefenster()).toBeVisible(); await hilfeSchliessen(); }); }); test.describe('Hilfe – nimmt nichts mit', () => { test('lässt den laufenden Prüfungsbogen stehen und die Uhr laufen', async () => { /* Der eigentliche Grund für einen Dialog statt einer eigenen Ansicht. Und zugleich die Probe auf eine Zusage im Handbuch selbst: Die Prüfungsuhr läuft weiter – sie anzuhalten würde die Simulation verfälschen. Der Lauf ist bewusst der kleinstmögliche (fünf Fragen, eine Minute): Die Restzeitanzeige nennt bis 59 Sekunden hinab jede Sekunde einzeln, darüber nur volle Minuten. Mit dem Standardprofil stünde nach zwei Sekunden immer noch „2 Stunden“ da, und der Test bewiese nichts. */ await zumStart(); await fenster.getByRole('button', { name: /Prüfungssimulation vorbereiten/u }).click(); await fenster.getByRole('radio', { name: /Selbst einstellen/u }).check(); await fenster.getByLabel('Fragen im Bogen').fill('5'); await fenster.getByLabel('Bearbeitungszeit').fill('1'); await fenster.getByRole('button', { name: 'Simulation starten' }).click(); await fenster.getByRole('button', { name: 'Simulation abbrechen' }).waitFor(); const restzeit = fenster.locator('.restzeit__wert'); const vorher = (await restzeit.textContent())?.trim() ?? ''; expect(vorher.length, 'Es wurde keine Restzeit angezeigt.').toBeGreaterThan(0); await hilfeOeffnen(); await fenster.waitForTimeout(2500); await hilfeSchliessen(); await expect(fenster.getByRole('button', { name: 'Simulation abbrechen' })).toBeVisible(); const nachher = (await restzeit.textContent())?.trim() ?? ''; expect(nachher, 'Die Prüfungsuhr stand still, während die Hilfe offen war.').not.toBe(vorher); // Aufräumen: Ein laufender Bogen ließe das Fenster beim Schließen // nachfragen und brächte den nächsten Lauf durcheinander. await fenster.getByRole('button', { name: 'Simulation abbrechen' }).click(); await fenster.getByRole('button', { name: 'Abbrechen und verwerfen' }).click(); await fenster.waitForTimeout(400); await zumStart(); }); /* Die Kehrseite der vorigen Prüfung, und die gefährlichere. `showModal()` macht den Hintergrund inert – für Zeiger und für Bildschirmleser. Für Tastendrücke gilt das nicht: Sie steigen bis zum `window` auf, und dort hängt der Zuhörer der Zeichenkürzel. Bis Fassung 0.24.1 bestätigte deshalb ein Druck auf die Eingabetaste bei offener Hilfe die – womöglich leere – Antwort der dahinterliegenden Frage. Die Frage zählte als falsch, und die Wiedervorlage rechnete damit weiter. Gemessen wird an der Frage selbst: Steht nach dem Schließen der Hilfe immer noch die Antwortmöglichkeit statt der Rückmeldung, ist nichts bestätigt worden. */ test('lässt die Tastenkürzel der Lernsitzung nicht durch den Dialog wirken', async () => { /* Der Weg über die Suche, und das ist der Punkt: Eine Auswahlfrage muss es sein — bei einer offenen Frage steht der Fokus im Textfeld, und dort greifen die Kürzel ohnehin nicht. Bis Fassung 0.25.0 ging dieser Test über „Weiterlernen“ und verlangte, dass die Sitzung mit einer Auswahlfrage **beginnt**. Die Reihenfolge ist gemischt, und 104 der 575 Fragen sind offen — der Test hing damit am Zufall und fiel im Gate-Lauf zu 0.25.0 aus genau diesem Grund. Über die amtliche Nummer ist die Frage festgelegt: 1.02 kommt im Katalog genau einmal vor und hat drei Antwortmöglichkeiten. */ await zumStart(); await fenster.getByRole('button', { name: /Fragen durchsuchen/u }).click(); await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); await fenster.getByRole('radio', { name: /Alle Bereiche/u }).check(); await fenster.getByRole('searchbox').fill('1.02'); await fenster .getByRole('button', { name: /^Frage 1\.02 üben$/u }) .first() .click(); const optionen = fenster .getByRole('group') .getByRole('checkbox') .or(fenster.getByRole('radio')); await optionen.first().waitFor(); const vorher = await optionen.count(); expect(vorher, 'Die Sitzung begann nicht mit einer Auswahlfrage.').toBe(3); await hilfeOeffnen(); await fenster.keyboard.press('Enter'); await fenster.keyboard.press('1'); await fenster.keyboard.press('m'); await fenster.waitForTimeout(200); /* Der Dialog steht noch – keine der Tasten hat ihn geschlossen. */ await expect(hilfefenster()).toBeVisible(); await hilfeSchliessen(); /* Und die Frage ist unberührt: Die Antwortmöglichkeiten stehen weiterhin zur Wahl, und keine ist ausgewählt. */ await expect(optionen.first()).toBeVisible(); expect(await optionen.count()).toBe(vorher); for (let i = 0; i < vorher; i++) { await expect(optionen.nth(i)).not.toBeChecked(); } await expect(fenster.getByRole('button', { name: /^(Weiter|Nächste Frage)/u })).toHaveCount(0); await zumStart(); }); });