/** * Die Sprachausgabe im echten Electron. * * Die Einheitentests bilden `speechSynthesis` nach; hier läuft die richtige * Engine. Genau dort steckte die Überraschung: `getVoices()` gibt unter * Windows eine leere Liste zurück und `voiceschanged` bleibt aus, **bis zum * ersten `speak()`**. Gemessen an dieser Maschine sind danach drei deutsche * Stimmen da (Hedda, Katja, Stefan). Eine Umsetzung, die auf die Liste * wartet, bevor sie etwas anbietet, wartet also ewig. * * Was hier geprüft wird, hängt trotzdem nicht davon ab, ob dieses Gerät eine * Stimme besitzt: Beobachtet wird der Auftrag an die Engine, nicht der Klang. * Ob wirklich zu hören ist, was hier losgeschickt wird, kann nur ein Mensch * beurteilen – dafür steht Schritt S9 im Prüfplan. */ 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 () => { // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen. bauPruefen(); ({ app, fenster } = await appStarten()); }); test.afterAll(async () => { await app?.close(); }); /** Hängt sich vor `speak()` und `cancel()` und schreibt beides mit. */ async function mithoeren(seite: Page): Promise { await seite.evaluate(() => { const fenster = window as unknown as { __gesprochen?: { text: string; lang: string }[]; __abbrueche?: number; }; fenster.__gesprochen = []; fenster.__abbrueche = 0; const echt = window.speechSynthesis.speak.bind(window.speechSynthesis); window.speechSynthesis.speak = (aeusserung: SpeechSynthesisUtterance): void => { fenster.__gesprochen?.push({ text: aeusserung.text, lang: aeusserung.lang }); echt(aeusserung); }; const echtesAbbrechen = window.speechSynthesis.cancel.bind(window.speechSynthesis); window.speechSynthesis.cancel = (): void => { fenster.__abbrueche = (fenster.__abbrueche ?? 0) + 1; echtesAbbrechen(); }; }); } /** * Zurück auf den Startbildschirm. * * Die Tests dieser Datei teilen sich eine Anwendung und laufen der Reihe * nach; die vorigen lassen sie in einer Sitzung stehen. Wer den Schalter für * die Sprachausgabe braucht, muss also erst hierher zurück. */ async function zumStart(seite: Page): Promise { for (let i = 0; i < 4; i++) { if (await amStart(seite)) { return; } const zurueck = seite.getByRole('button', { name: /^(Zum Start|Sitzung beenden)$/u }); if ((await zurueck.count()) === 0) { return; } await zurueck.first().click(); await seite.waitForTimeout(250); } } async function abbrueche(seite: Page): Promise { return seite.evaluate(() => (window as unknown as { __abbrueche?: number }).__abbrueche ?? 0); } async function gesprochenes(seite: Page): Promise<{ text: string; lang: string }[]> { return seite.evaluate( () => (window as unknown as { __gesprochen?: { text: string; lang: string }[] }).__gesprochen ?? [], ); } test.describe('Vorlesen', () => { test('bietet die Sprachausgabe an, obwohl noch keine Stimme gemeldet ist', async () => { /* Der Fallstrick in Person: Vor dem ersten `speak()` ist die Liste leer. Der Schalter muss trotzdem bedienbar sein. */ const stimmenVorher = await fenster.evaluate(() => window.speechSynthesis.getVoices().length); expect(stimmenVorher).toBe(0); const schalter = fenster.getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen', }); await expect(schalter).toBeEnabled(); await expect(schalter).not.toBeChecked(); }); test('liest die Frage vor und lässt sich wieder anhalten', async () => { await mithoeren(fenster); await fenster .getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }) .setChecked(true); await fenster.getByRole('button', { name: /Weiterlernen/u }).click(); await expect(fenster.getByRole('heading', { level: 1, name: /, Frage \d/u })).toBeVisible(); const vorlesen = fenster.getByRole('button', { name: /Frage vorlesen/u }); await expect(vorlesen).toBeVisible(); await vorlesen.click(); const stuecke = await gesprochenes(fenster); expect(stuecke.length).toBeGreaterThan(0); expect(stuecke.every((stueck) => stueck.lang === 'de-DE')).toBe(true); /* Kein Stück darf so lang sein, dass Chromium es nach 15 Sekunden abschneidet. */ expect(Math.max(...stuecke.map((stueck) => stueck.text.length))).toBeLessThanOrEqual(180); /* WCAG 1.4.2: Der Halt-Knopf steht an derselben Stelle. */ const anhalten = fenster.getByRole('button', { name: /Vorlesen beenden/u }); await expect(anhalten).toBeVisible(); await anhalten.click(); await expect(fenster.getByRole('button', { name: /Frage vorlesen/u })).toBeVisible(); }); test('schreibt Paragrafen und Gesetzeskürzel für die Stimme aus', async () => { /* Über den ganzen Katalog gesehen kommt „§“ in nahezu jeder Erklärung vor. Geprüft wird deshalb nicht ein bestimmter Wortlaut, sondern dass gesprochener Text keine Kürzel mehr enthält, die eine Stimme buchstabieren müsste. */ const stuecke = await gesprochenes(fenster); const gesamt = stuecke.map((stueck) => stueck.text).join(' '); expect(gesamt).not.toContain('§'); expect(gesamt).not.toMatch(/\bAbs\.|\bNr\.\s|\bWaffG\b/u); }); test('meldet nach dem ersten Sprechen die Systemstimmen', async () => { /* Der eigentliche Befund dieser Datei, festgehalten als Test: Erst jetzt ist die Liste gefüllt. Ist sie es nicht, hat diese Maschine keine Stimme installiert – das ist kein Fehler der Anwendung, deshalb wird hier nur berichtet und nicht abgebrochen. */ const stimmen = await fenster.evaluate(() => window.speechSynthesis.getVoices().map((stimme) => `${stimme.lang} ${stimme.name}`), ); console.info(`Stimmen nach dem ersten speak(): ${stimmen.join(', ') || '(keine)'}`); expect(Array.isArray(stimmen)).toBe(true); }); }); test.describe('Vorlesen und Fragenwechsel', () => { /* Gemeldet von einem Nutzer: „Wenn eine Frage oder Antwort vorgelesen wird und es wird die nächste Frage ausgewählt, dann wird immer noch die alte Frage oder Antwort vorgelesen.“ Hier läuft die richtige Engine – geprüft wird, dass ihr das Schweigen auch wirklich aufgetragen wird, und dass die Oberfläche danach nicht mehr anbietet, etwas zu beenden, das zur vorigen Frage gehörte. */ test('bricht die Ausgabe beim Wechsel zur nächsten Frage ab', async () => { await mithoeren(fenster); await zumStart(fenster); await fenster .getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }) .setChecked(true); await fenster.getByRole('button', { name: /Weiterlernen/u }).click(); await expect(fenster.getByRole('heading', { level: 1, name: /, Frage \d/u })).toBeVisible(); await fenster.getByRole('button', { name: /Frage vorlesen/u }).click(); await expect(fenster.getByRole('button', { name: /Vorlesen beenden/u })).toBeVisible(); const vorher = await abbrueche(fenster); /* Beide Fragetypen bedienen: Der Katalog hat 104 offene Fragen unter 575. */ const auswahl = fenster.getByRole('checkbox').or(fenster.getByRole('radio')); if ((await auswahl.count()) > 0) { await auswahl.first().check(); await fenster.getByRole('button', { name: 'Antwort bestätigen', exact: true }).click(); } else { await fenster.getByRole('button', { name: 'Musterantwort anzeigen' }).click(); await fenster.getByRole('button', { name: 'Gewusst', exact: true }).click(); } await fenster.getByRole('button', { name: /^Nächste Frage$/u }).click(); expect(await abbrueche(fenster)).toBeGreaterThan(vorher); await expect(fenster.getByRole('button', { name: /Vorlesen beenden/u })).toHaveCount(0); await expect(fenster.getByRole('button', { name: /Frage vorlesen/u })).toBeVisible(); /* Zustand hinterlassen, wie er vorgefunden wurde. „Sitzung beenden“ führt auf die Zusammenfassung, nicht auf den Startbildschirm – der Schalter steht erst einen Schritt weiter. */ await zumStart(fenster); await fenster .getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }) .setChecked(false); }); });