waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Die Sprachausgabe im echten Electron. |
| 3 | * |
| 4 | * Die Einheitentests bilden `speechSynthesis` nach; hier läuft die richtige |
| 5 | * Engine. Genau dort steckte die Überraschung: `getVoices()` gibt unter |
| 6 | * Windows eine leere Liste zurück und `voiceschanged` bleibt aus, **bis zum |
| 7 | * ersten `speak()`**. Gemessen an dieser Maschine sind danach drei deutsche |
| 8 | * Stimmen da (Hedda, Katja, Stefan). Eine Umsetzung, die auf die Liste |
| 9 | * wartet, bevor sie etwas anbietet, wartet also ewig. |
| 10 | * |
| 11 | * Was hier geprüft wird, hängt trotzdem nicht davon ab, ob dieses Gerät eine |
| 12 | * Stimme besitzt: Beobachtet wird der Auftrag an die Engine, nicht der Klang. |
| 13 | * Ob wirklich zu hören ist, was hier losgeschickt wird, kann nur ein Mensch |
| 14 | * beurteilen – dafür steht Schritt S9 im Prüfplan. |
| 15 | */ |
| 16 | |
| 17 | import { type ElectronApplication, type Page } from '@playwright/test'; |
| 18 | |
| 19 | import { amStart, appStarten, bauPruefen } from './electron-hilfe'; |
| 20 | import { expect, test } from './konsolenwache'; |
| 21 | |
| 22 | let app: ElectronApplication | undefined; |
| 23 | let fenster: Page; |
| 24 | |
| 25 | test.beforeAll(async () => { |
| 26 | // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen. |
| 27 | bauPruefen(); |
| 28 | ({ app, fenster } = await appStarten()); |
| 29 | }); |
| 30 | |
| 31 | test.afterAll(async () => { |
| 32 | await app?.close(); |
| 33 | }); |
| 34 | |
| 35 | /** Hängt sich vor `speak()` und `cancel()` und schreibt beides mit. */ |
| 36 | async function mithoeren(seite: Page): Promise<void> { |
| 37 | await seite.evaluate(() => { |
| 38 | const fenster = window as unknown as { |
| 39 | __gesprochen?: { text: string; lang: string }[]; |
| 40 | __abbrueche?: number; |
| 41 | }; |
| 42 | fenster.__gesprochen = []; |
| 43 | fenster.__abbrueche = 0; |
| 44 | |
| 45 | const echt = window.speechSynthesis.speak.bind(window.speechSynthesis); |
| 46 | window.speechSynthesis.speak = (aeusserung: SpeechSynthesisUtterance): void => { |
| 47 | fenster.__gesprochen?.push({ text: aeusserung.text, lang: aeusserung.lang }); |
| 48 | echt(aeusserung); |
| 49 | }; |
| 50 | |
| 51 | const echtesAbbrechen = window.speechSynthesis.cancel.bind(window.speechSynthesis); |
| 52 | window.speechSynthesis.cancel = (): void => { |
| 53 | fenster.__abbrueche = (fenster.__abbrueche ?? 0) + 1; |
| 54 | echtesAbbrechen(); |
| 55 | }; |
| 56 | }); |
| 57 | } |
| 58 | |
| 59 | /** |
| 60 | * Zurück auf den Startbildschirm. |
| 61 | * |
| 62 | * Die Tests dieser Datei teilen sich eine Anwendung und laufen der Reihe |
| 63 | * nach; die vorigen lassen sie in einer Sitzung stehen. Wer den Schalter für |
| 64 | * die Sprachausgabe braucht, muss also erst hierher zurück. |
| 65 | */ |
| 66 | async function zumStart(seite: Page): Promise<void> { |
| 67 | for (let i = 0; i < 4; i++) { |
| 68 | if (await amStart(seite)) { |
| 69 | return; |
| 70 | } |
| 71 | const zurueck = seite.getByRole('button', { name: /^(Zum Start|Sitzung beenden)$/u }); |
| 72 | if ((await zurueck.count()) === 0) { |
| 73 | return; |
| 74 | } |
| 75 | await zurueck.first().click(); |
| 76 | await seite.waitForTimeout(250); |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | async function abbrueche(seite: Page): Promise<number> { |
| 81 | return seite.evaluate(() => (window as unknown as { __abbrueche?: number }).__abbrueche ?? 0); |
| 82 | } |
| 83 | |
| 84 | async function gesprochenes(seite: Page): Promise<{ text: string; lang: string }[]> { |
| 85 | return seite.evaluate( |
| 86 | () => |
| 87 | (window as unknown as { __gesprochen?: { text: string; lang: string }[] }).__gesprochen ?? [], |
| 88 | ); |
| 89 | } |
| 90 | |
| 91 | test.describe('Vorlesen', () => { |
| 92 | test('bietet die Sprachausgabe an, obwohl noch keine Stimme gemeldet ist', async () => { |
| 93 | /* Der Fallstrick in Person: Vor dem ersten `speak()` ist die Liste leer. |
| 94 | Der Schalter muss trotzdem bedienbar sein. */ |
| 95 | const stimmenVorher = await fenster.evaluate(() => window.speechSynthesis.getVoices().length); |
| 96 | expect(stimmenVorher).toBe(0); |
| 97 | |
| 98 | const schalter = fenster.getByRole('checkbox', { |
| 99 | name: 'Fragen und Antworten vorlesen lassen', |
| 100 | }); |
| 101 | |
| 102 | await expect(schalter).toBeEnabled(); |
| 103 | await expect(schalter).not.toBeChecked(); |
| 104 | }); |
| 105 | |
| 106 | test('liest die Frage vor und lässt sich wieder anhalten', async () => { |
| 107 | await mithoeren(fenster); |
| 108 | |
| 109 | await fenster |
| 110 | .getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }) |
| 111 | .setChecked(true); |
| 112 | |
| 113 | await fenster.getByRole('button', { name: /Weiterlernen/u }).click(); |
| 114 | await expect(fenster.getByRole('heading', { level: 1, name: /, Frage \d/u })).toBeVisible(); |
| 115 | |
| 116 | const vorlesen = fenster.getByRole('button', { name: /Frage vorlesen/u }); |
| 117 | await expect(vorlesen).toBeVisible(); |
| 118 | await vorlesen.click(); |
| 119 | |
| 120 | const stuecke = await gesprochenes(fenster); |
| 121 | expect(stuecke.length).toBeGreaterThan(0); |
| 122 | expect(stuecke.every((stueck) => stueck.lang === 'de-DE')).toBe(true); |
| 123 | /* Kein Stück darf so lang sein, dass Chromium es nach 15 Sekunden |
| 124 | abschneidet. */ |
| 125 | expect(Math.max(...stuecke.map((stueck) => stueck.text.length))).toBeLessThanOrEqual(180); |
| 126 | |
| 127 | /* WCAG 1.4.2: Der Halt-Knopf steht an derselben Stelle. */ |
| 128 | const anhalten = fenster.getByRole('button', { name: /Vorlesen beenden/u }); |
| 129 | await expect(anhalten).toBeVisible(); |
| 130 | await anhalten.click(); |
| 131 | await expect(fenster.getByRole('button', { name: /Frage vorlesen/u })).toBeVisible(); |
| 132 | }); |
| 133 | |
| 134 | test('schreibt Paragrafen und Gesetzeskürzel für die Stimme aus', async () => { |
| 135 | /* Über den ganzen Katalog gesehen kommt „§“ in nahezu jeder Erklärung |
| 136 | vor. Geprüft wird deshalb nicht ein bestimmter Wortlaut, sondern dass |
| 137 | gesprochener Text keine Kürzel mehr enthält, die eine Stimme |
| 138 | buchstabieren müsste. */ |
| 139 | const stuecke = await gesprochenes(fenster); |
| 140 | const gesamt = stuecke.map((stueck) => stueck.text).join(' '); |
| 141 | |
| 142 | expect(gesamt).not.toContain('§'); |
| 143 | expect(gesamt).not.toMatch(/\bAbs\.|\bNr\.\s|\bWaffG\b/u); |
| 144 | }); |
| 145 | |
| 146 | test('meldet nach dem ersten Sprechen die Systemstimmen', async () => { |
| 147 | /* Der eigentliche Befund dieser Datei, festgehalten als Test: Erst jetzt |
| 148 | ist die Liste gefüllt. Ist sie es nicht, hat diese Maschine keine |
| 149 | Stimme installiert – das ist kein Fehler der Anwendung, deshalb wird |
| 150 | hier nur berichtet und nicht abgebrochen. */ |
| 151 | const stimmen = await fenster.evaluate(() => |
| 152 | window.speechSynthesis.getVoices().map((stimme) => `${stimme.lang} ${stimme.name}`), |
| 153 | ); |
| 154 | |
| 155 | console.info(`Stimmen nach dem ersten speak(): ${stimmen.join(', ') || '(keine)'}`); |
| 156 | expect(Array.isArray(stimmen)).toBe(true); |
| 157 | }); |
| 158 | }); |
| 159 | |
| 160 | test.describe('Vorlesen und Fragenwechsel', () => { |
| 161 | /* |
| 162 | Gemeldet von einem Nutzer: „Wenn eine Frage oder Antwort vorgelesen wird |
| 163 | und es wird die nächste Frage ausgewählt, dann wird immer noch die alte |
| 164 | Frage oder Antwort vorgelesen.“ |
| 165 | |
| 166 | Hier läuft die richtige Engine – geprüft wird, dass ihr das Schweigen |
| 167 | auch wirklich aufgetragen wird, und dass die Oberfläche danach nicht mehr |
| 168 | anbietet, etwas zu beenden, das zur vorigen Frage gehörte. |
| 169 | */ |
| 170 | test('bricht die Ausgabe beim Wechsel zur nächsten Frage ab', async () => { |
| 171 | await mithoeren(fenster); |
| 172 | await zumStart(fenster); |
| 173 | |
| 174 | await fenster |
| 175 | .getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }) |
| 176 | .setChecked(true); |
| 177 | await fenster.getByRole('button', { name: /Weiterlernen/u }).click(); |
| 178 | await expect(fenster.getByRole('heading', { level: 1, name: /, Frage \d/u })).toBeVisible(); |
| 179 | |
| 180 | await fenster.getByRole('button', { name: /Frage vorlesen/u }).click(); |
| 181 | await expect(fenster.getByRole('button', { name: /Vorlesen beenden/u })).toBeVisible(); |
| 182 | const vorher = await abbrueche(fenster); |
| 183 | |
| 184 | /* Beide Fragetypen bedienen: Der Katalog hat 104 offene Fragen unter 575. */ |
| 185 | const auswahl = fenster.getByRole('checkbox').or(fenster.getByRole('radio')); |
| 186 | if ((await auswahl.count()) > 0) { |
| 187 | await auswahl.first().check(); |
| 188 | await fenster.getByRole('button', { name: 'Antwort bestätigen', exact: true }).click(); |
| 189 | } else { |
| 190 | await fenster.getByRole('button', { name: 'Musterantwort anzeigen' }).click(); |
| 191 | await fenster.getByRole('button', { name: 'Gewusst', exact: true }).click(); |
| 192 | } |
| 193 | await fenster.getByRole('button', { name: /^Nächste Frage$/u }).click(); |
| 194 | |
| 195 | expect(await abbrueche(fenster)).toBeGreaterThan(vorher); |
| 196 | await expect(fenster.getByRole('button', { name: /Vorlesen beenden/u })).toHaveCount(0); |
| 197 | await expect(fenster.getByRole('button', { name: /Frage vorlesen/u })).toBeVisible(); |
| 198 | |
| 199 | /* Zustand hinterlassen, wie er vorgefunden wurde. „Sitzung beenden“ |
| 200 | führt auf die Zusammenfassung, nicht auf den Startbildschirm – der |
| 201 | Schalter steht erst einen Schritt weiter. */ |
| 202 | await zumStart(fenster); |
| 203 | await fenster |
| 204 | .getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }) |
| 205 | .setChecked(false); |
| 206 | }); |
| 207 | }); |