/** * axe-core je Ansicht **und je Farbschema**, im echten Fenster. * * ## Was hier gemessen wird * * {@link PRUEFSTUECKE} führt die Anwendung an jede Stelle, an der es etwas zu * messen gibt, und lässt axe-core dort laufen. Fünfzehn Schritte, siebzehn * Zustände: Startbildschirm (drei Zustände), Kapitelwahl, Glossar, Gesetze, * Suche mit * Trefferliste, „Über diese Software“, Hilfedialog, Lernsitzung (drei * Zustände), Auswertung der Lernsitzung, Prüfungswahl, Prüfungslauf, * Rückfrage vor der Abgabe, Prüfungsauswertung, Selbstbewertung offener * Fragen. Damit ist jede der zehn Ansichtsarten aus * `src/renderer/src/lernen/typen.ts` erfasst. * * Die beiden **Tore vor dem Startbildschirm** – „Wer lernt?“ und die Frage * nach Kapitel IV – stehen am Ende in einer eigenen Anwendung: Sie erscheinen * nur auf einem frischen Profilverzeichnis, und `appStarten` beantwortet die * zweite von ihnen für alle übrigen Prüfungen. * * ## Warum im Fenster und nicht in jsdom * * Die Unit-Tests prüfen Baumeigenschaften: Namen, Rollen, Überschriften, * Landmarken, Beschriftungen. Alles, was **gerechnetes Layout** braucht, kann * jsdom nicht: Farbkontrast, Zielgrößen, tatsächliche Fokusreihenfolge. Hier * rechnet Chromium – erst hier lässt sich WCAG 2.2 AA wirklich messen. * * Deshalb läuft {@link fensterOptionen} mit `target-size` ausdrücklich * eingeschaltet. axe-core liefert die Regel abgeschaltet aus, und der Tag * `wcag22aa` schaltet sie nicht ein – nachgemessen an axe-core 4.13.0 ist sie * die einzige Regel an diesem Tag. * * ## Welcher Regelsatz je Farbschema – und warum * * Prüfplan 1.4.3 verlangt, alle drei Farbschemata **einzeln** zu messen. Zuvor * lief dafür genau eine Regel (`color-contrast`) an genau einer Ansicht (dem * Startbildschirm, in `barrierefreiheit.spec.ts`); der volle Regelsatz * existierte ausschließlich im Vorgabeschema. * * Jetzt läuft in **jedem** der drei Schemata an **jeder** Ansicht der volle * Satz WCAG 2.2 AA. * * Die Wahl war eine andere. Ein Schemawechsel schreibt ausschließlich * CSS-Variablen um (`src/renderer/src/styles/tokens.css`, `:root[data-thema=…]`); * Markup, Namen, Rollen, Überschriften und `aria-*`-Zustände sind in allen drei * Schemata dasselbe DOM. Es hätte also nahegelegen, außerhalb des * Vorgabeschemas nur die farb- und geometrieabhängigen Regeln laufen zu lassen * (`color-contrast`, `link-in-text-block`, `target-size` – der hohe Kontrast * verdoppelt `--rahmenbreite`). Nachgemessen kostet dieser Verzicht aber * nichts, was ihn lohnte: **33,9 s** mit den drei Regeln gegen **36,4 s** mit * dem vollen Satz, bei 47 Prüfungen. Für 2,5 Sekunden eine Abkürzung zu * begründen, die man später verteidigen muss, ist ein schlechtes Geschäft – * zumal die Annahme „dasselbe DOM“ genau so lange gilt, wie niemand eine * schemaabhängige Darstellung einbaut. * * ## Was ein grüner Lauf NICHT heißt * * Nicht „barrierefrei“. Automatische Prüfung findet nur, was sich maschinell * entscheiden lässt – bei weitem nicht alles. Die Szenarien an NVDA, JAWS und * VoiceOver in `docs/barrierefreiheit-pruefplan.md` ersetzt diese Suite nicht * und soll es nicht. * * Ausdrücklich **nicht** gemessen, obwohl es zur Anwendung gehört: * * - das Windows-Kontrastdesign (`forced-colors: active`). Dort bestimmt das * Betriebssystem die Palette; Chromium meldet das Signal im Testlauf nicht. * - die Fehlerzustände (kein Katalog, unlesbare Datenbank, fehlgeschlagene * Sicherung) und die Rückfragen der Profilverwaltung. * - die Druckausgaben (`@media print`) – axe misst den Bildschirmzustand. * - ob ein Alternativtext inhaltlich richtig ist, ob die Fokusreihenfolge * sinnvoll ist und ob eine Live-Region im echten Screenreader ankommt. * * Und: Was axe **nicht entscheiden** kann, bricht den Lauf nicht, wird aber * mitsamt Fundstelle genannt (siehe `unentschieden` in `axe-hilfe.ts`). * Nachgesehen betrifft das im ganzen Lauf ausschließlich `aria-hidden` * gesetzte Schriftzeichen: den Pfeil in `.knopf--zurueck`, die Häkchen und * Kreise der Fragenübersicht samt ihrer Legende, das Urteilssymbol der * Prüfungsauswertung und dieselben Symbole in der Verlaufstabelle. axe kann * ihren Kontrast nicht sicher rechnen; ihre Aussage steht in jedem dieser * Fälle zusätzlich im Text – im zugänglichen Namen der Schaltfläche * („Frage 2 von 5, offen“) oder im Wort daneben (WCAG 1.4.1). Der Text selbst * wird regulär gemessen. */ import { type ElectronApplication, type Page } from '@playwright/test'; import { THEME_BESCHRIFTUNGEN, type ThemeAufgeloest } from '../src/shared/theme'; import { fensterOptionen } from '../src/shared/wcag'; import { axePruefen, befunde, unentschieden } from './axe-hilfe'; import { amStart, animationenAbwarten, appStarten, appStartenOhneZuschnitt, bauPruefen, themaSetzen, } 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(); }); /** Alle tatsächlich gerenderten Schemata – `system` ist hier bereits aufgelöst. */ const SCHEMATA: readonly ThemeAufgeloest[] = ['hell', 'dunkel', 'hochkontrast']; /** * Ausstiege aus jeder Ansicht, in der Reihenfolge, in der sie zu drücken sind. * * Die Rückfragen stehen zuoberst: Steht eine offen, liegt darunter noch der * Knopf, der sie geöffnet hat („Simulation abbrechen“). Wer den zuerst fände, * drückte im Kreis. */ const AUSSTIEGE: readonly RegExp[] = [ /^Abbrechen und verwerfen$/u, /^Ohne Auswertung verwerfen$/u, /^Auswertung anzeigen$/u, /^Simulation abbrechen$/u, /^Sitzung beenden$/u, /^Zum Start$/u, ]; /** * Zurück auf den Startbildschirm, egal wo der vorige Test aufgehört hat. * * Dieselbe Bewegung wie in `ueberschriften.spec.ts`: Die Suiten teilen sich * ein Fenster, weil ein Neustart je Test die Laufzeit vervielfachte. Seit hier * auch Prüfungsläufe geprüft werden, muss der Weg mehr kennen als „Zum Start“ * – ein laufender Bogen verlangt zwei Klicks, und der Hilfedialog liegt über * allem und schluckt sie. */ async function zumStart(): Promise { if ((await fenster.getByRole('dialog').count()) > 0) { await fenster.keyboard.press('Escape'); await fenster.waitForTimeout(150); } for (let i = 0; i < 8; i++) { if (await amStart(fenster)) { return; } let geklickt = false; for (const weg of AUSSTIEGE) { const knopf = fenster.getByRole('button', { name: weg }); if ((await knopf.count()) > 0 && (await knopf.first().isVisible())) { await knopf.first().click(); await fenster.waitForTimeout(250); geklickt = true; break; } } if (!geklickt) { return; } } } /** * Prüft die aktuelle Ansicht und meldet Verstöße lesbar. * * `animationenAbwarten` ist Pflicht: Ohne das misst axe die Farben mitten im * Übergang und meldet Kontrastfehler, die es im Ruhezustand nicht gibt. */ async function ansichtPruefen(seite: Page, name: string): Promise { await animationenAbwarten(seite); const ergebnis = await axePruefen(seite, fensterOptionen()); const gefunden = befunde(ergebnis); const offen = unentschieden(ergebnis); /* Unentschiedenes bricht den Lauf nicht, verschwindet aber auch nicht stillschweigend – siehe `unentschieden` in axe-hilfe.ts. Genannt wird auch die Fundstelle: Eine Zeile, die nur „color-contrast“ sagt, lässt niemanden nachsehen, ob dahinter etwas steckt. */ if (offen.length > 0) { console.info( `${name}: ${String(offen.length)} von axe nicht entscheidbare Stelle(n):\n` + offen.map((b) => ` ${b.regel}: ${b.betroffen.join(', ')}`).join('\n'), ); } expect( gefunden, `${name}: ${String(gefunden.length)} Verstoß/Verstöße.\n` + gefunden .map((b) => ` ${b.regel} (${b.wirkung}): ${b.hilfe}\n ${b.betroffen.join('\n ')}`) .join('\n'), ).toEqual([]); } /** Startet „Weiterlernen“ und wartet, bis die erste Frage steht. */ async function lernsitzungStarten(): Promise { await fenster.getByRole('button', { name: /Weiterlernen/u }).click(); await fenster .getByRole('button', { name: /^(Antwort bestätigen|Musterantwort anzeigen)$/u }) .waitFor(); } /** * Beantwortet die stehende Frage – gleich welcher Art. * * Auswahlfragen bekommen die erste Option, offene Fragen die Selbstbewertung * „Nicht gewusst“. Die Sitzung wird zufällig zusammengestellt; welche Art * kommt, steht nicht vorher fest. */ async function frageBeantworten(): Promise { const auswahlfeld = fenster.getByRole('radio').or(fenster.getByRole('checkbox')); if ((await auswahlfeld.count()) > 0) { await auswahlfeld.first().click(); await fenster.getByRole('button', { name: 'Antwort bestätigen' }).click(); } else { await fenster.getByRole('button', { name: 'Musterantwort anzeigen' }).click(); await fenster.getByRole('button', { name: 'Nicht gewusst' }).click(); } await fenster.locator('.ergebnis').first().waitFor(); } /** * Antwortet, bis eine Antwort **nicht** richtig war – höchstens fünf Fragen. * * Erst dann führt die Auswertung die Fehlerliste und den Knopf „Fehler * wiederholen ( … )“; beides wäre sonst nur zufällig geprüft. Welche Option * richtig ist, weiß der Test nicht – deshalb der Versuch statt der Rechnung. * Nach der fünften Frage wird abgebrochen: Auch die Auswertung ohne Fehler ist * ein gültiger Zustand, nur ein ärmerer. */ async function bisFehlerAntworten(): Promise { for (let i = 0; i < 5; i++) { await frageBeantworten(); if ((await fenster.locator('.ergebnis--richtig').count()) === 0) { return; } const weiter = fenster.getByRole('button', { name: /^Nächste Frage$/u }); if ((await weiter.count()) === 0) { return; } await weiter.first().click(); await fenster .getByRole('button', { name: /^(Antwort bestätigen|Musterantwort anzeigen)$/u }) .waitFor(); } } /** * Stellt einen kurzen Bogen ein und startet ihn. * * Fünf Fragen (das Minimum aus `GRENZEN.fragenMin`) und 60 Minuten: kurz genug * für einen Testlauf, lang genug, dass die Uhr während der Messung nicht * abläuft und die Prüfung sich selbst abgibt. * * `offenProzent` steuert, was nach der Abgabe kommt: Bei 0 folgt unmittelbar * die Auswertung, bei 100 zuerst die Selbstbewertung der offenen Fragen. Beide * Wege sind eigene Ansichten und werden einzeln gemessen. */ async function pruefungStarten(offenProzent: number): Promise { await fenster.getByRole('button', { name: /Prüfungssimulation vorbereiten/u }).click(); await fenster.getByRole('radio', { name: /Selbst einstellen/u }).check(); /* `exact` bleibt gesetzt: Die Beschreibung des Profils „Selbst einstellen“ zählt weiterhin auf, was frei zu wählen ist („Fragenzahl, Zeit, Bestehensgrenze und Anteil offener Fragen frei wählen.“). Ohne genaue Übereinstimmung träfe die Suche den Radioknopf mit. */ await fenster.getByLabel('Fragen im Bogen', { exact: true }).fill('5'); await fenster.getByLabel('Bearbeitungszeit', { exact: true }).fill('60'); await fenster.getByLabel('Anteil offener Fragen', { exact: true }).fill(String(offenProzent)); await fenster.getByRole('button', { name: 'Simulation starten' }).click(); await fenster.getByRole('button', { name: 'Simulation abgeben' }).waitFor(); } /** Ein Prüfschritt: hinführen und messen, was dort zu messen ist. */ interface Pruefstueck { readonly name: string; readonly messen: (pruefen: (zustand: string) => Promise) => Promise; } const PRUEFSTUECKE: readonly Pruefstueck[] = [ { name: 'Startbildschirm', messen: async (pruefen) => { await zumStart(); await pruefen('Startbildschirm'); }, }, { name: 'Startbildschirm mit eingeschalteter Sprachausgabe', messen: async (pruefen) => { /* Die beiden Zusatzschalter zum Vorlesen erscheinen erst, wenn die Sprachausgabe an ist – ohne diesen Fall bekäme axe sie nie zu sehen. Genau so entstehen Ansichten, die niemand geprüft hat. */ await zumStart(); const schalter = fenster.getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen', }); await schalter.scrollIntoViewIfNeeded(); await schalter.setChecked(true); await fenster .getByRole('checkbox', { name: 'Frage und Rückmeldung von selbst vorlesen' }) .waitFor(); await pruefen('Startbildschirm mit Sprachausgabe'); // Zustand hinterlassen, wie er vorgefunden wurde. await schalter.setChecked(false); }, }, { name: 'Startbildschirm mit Rückfrage vor dem Ersetzen', messen: async (pruefen) => { /* Die Rückfrage vor dem Einspielen ist der folgenreichste Bereich der ganzen Anwendung – und im Vorgabezustand nicht sichtbar. Ohne diesen Fall bekäme axe sie nie zu sehen. Der Öffnen-Dialog wird im Hauptprozess ersetzt; geprüft wird alles, was danach kommt. */ await zumStart(); await app?.evaluate(({ dialog }) => { (dialog as unknown as { showOpenDialog: unknown }).showOpenDialog = () => Promise.resolve({ canceled: true, filePaths: [] }); }); const knopf = fenster.getByRole('button', { name: 'Sicherung auswählen und ansehen …' }); await knopf.scrollIntoViewIfNeeded(); await pruefen('Startbildschirm mit Sicherungskarte'); }, }, { name: 'Kapitelwahl', messen: async (pruefen) => { await zumStart(); await fenster.getByRole('button', { name: /Kapitel wählen/u }).click(); await fenster.getByRole('heading', { name: 'Kapitel wählen', level: 1 }).waitFor(); await pruefen('Kapitelwahl'); }, }, { name: 'Glossar', messen: async (pruefen) => { await zumStart(); await fenster.getByRole('button', { name: /Fachbegriffe nachschlagen/u }).click(); await fenster.getByRole('heading', { name: 'Glossar', exact: true }).waitFor(); await pruefen('Glossar'); }, }, { name: 'Gesetze lesen', messen: async (pruefen) => { /* Mit aufgeklapptem Wortlaut: Erst dann steht der amtliche Text da, und erst dann lässt sich der Kontrast der Herkunftszeile und der Trefferbegründung wirklich messen. */ await zumStart(); await fenster.getByRole('button', { name: /In den Gesetzen nachlesen/u }).click(); await fenster.getByRole('heading', { name: 'Gesetze lesen', level: 1 }).waitFor(); await fenster.getByLabel('In den Gesetzen suchen').fill('Notwehr'); await fenster.waitForTimeout(400); await pruefen('Gesetze lesen'); }, }, { name: 'Fragen durchsuchen', messen: async (pruefen) => { /* Mit Trefferliste: Erst dort gibt es Marken, Aufklapper und Knöpfe in Zahl – und erst dort lassen sich Kontrast der Fundstellenmarkierung und Zielgröße der Bereichsradios wirklich messen. */ await zumStart(); await fenster.getByRole('button', { name: /Fragen durchsuchen/u }).click(); await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); await fenster.getByLabel('Suchbegriff').fill('Anscheinswaffe'); await fenster.waitForTimeout(400); await pruefen('Fragen durchsuchen'); }, }, { name: 'Über diese Software', messen: async (pruefen) => { await zumStart(); await fenster.getByRole('button', { name: /Lizenzen und Herkunft anzeigen/u }).click(); await fenster.getByRole('heading', { name: 'Über diese Software' }).waitFor(); await pruefen('Über diese Software'); }, }, { name: 'Hilfe', messen: async (pruefen) => { /* Der Dialog liegt über der Ansicht, nicht an ihrer Stelle. axe misst hier deshalb beides zugleich – und genau so bekommt es der Nutzer auch zu sehen. */ await zumStart(); await fenster.getByRole('contentinfo').getByRole('button', { name: 'Hilfe' }).click(); await fenster.getByRole('dialog', { name: 'Hilfe' }).waitFor(); await pruefen('Hilfe'); await fenster.keyboard.press('Escape'); await fenster.getByRole('dialog', { name: 'Hilfe' }).waitFor({ state: 'hidden' }); }, }, { name: 'Lernsitzung', messen: async (pruefen) => { await zumStart(); await lernsitzungStarten(); await pruefen('Lernsitzung'); }, }, { name: 'Lernsitzung mit geöffnetem Meldeblock', messen: async (pruefen) => { /* Der Block ist zugeklappt, solange ihn niemand öffnet – axe bekäme ihn im Vorgabezustand also nie zu sehen. Genau so entstehen Ansichten, die niemand geprüft hat. */ await zumStart(); await lernsitzungStarten(); const aufklapper = fenster.getByText('Fehler zu dieser Frage melden'); await aufklapper.scrollIntoViewIfNeeded(); await aufklapper.click(); await fenster.getByLabel('Meldetext').waitFor(); await pruefen('Lernsitzung mit Meldeblock'); }, }, { name: 'Lernsitzung nach der Antwort', messen: async (pruefen) => { /* Der Ergebniszustand hat eigene Bedienelemente und eine eigene Farbgebung (richtig/falsch/verpasst) – er ist damit ein eigener Prüffall, nicht derselbe Bildschirm mit mehr Text. */ await zumStart(); await lernsitzungStarten(); await frageBeantworten(); await pruefen('Lernsitzung nach der Antwort'); }, }, { name: 'Auswertung der Lernsitzung', messen: async (pruefen) => { /* Trefferquote, Fortschrittsbalken, Zahlenliste und die Liste der verpatzten Fragen – nichts davon steht in der Sitzung selbst. */ await zumStart(); await lernsitzungStarten(); await bisFehlerAntworten(); await fenster.getByRole('button', { name: 'Sitzung beenden' }).click(); await fenster.getByRole('heading', { name: /^Auswertung: /u, level: 1 }).waitFor(); await pruefen('Auswertung der Lernsitzung'); }, }, { name: 'Prüfungswahl', messen: async (pruefen) => { /* Mit aufgeklapptem „Selbst einstellen“: Die vier Zahlenfelder samt ihren Fehlerhinweisen (`aria-invalid`) gibt es nur bei einem anpassbaren Profil. */ await zumStart(); await fenster.getByRole('button', { name: /Prüfungssimulation vorbereiten/u }).click(); await fenster .getByRole('heading', { name: 'Prüfungssimulation vorbereiten', level: 1 }) .waitFor(); await fenster.getByRole('radio', { name: /Selbst einstellen/u }).check(); await fenster.getByLabel('Fragen im Bogen', { exact: true }).waitFor(); await pruefen('Prüfungswahl'); }, }, { name: 'Prüfungslauf, Rückfrage vor der Abgabe und Prüfungsauswertung', messen: async (pruefen) => { /* Drei Zustände in einem Schritt, weil sie an einem Bogen hängen: Der Lauf lässt sich nicht abgeben, ohne ihn vorher zu starten. Sie einzeln zu prüfen hieße, den Bogen dreimal zusammenzustellen. Der Bogen besteht ausschließlich aus Auswahlfragen (0 Prozent offen) – dann folgt auf die Abgabe unmittelbar die Auswertung. Die Selbstbewertung offener Fragen ist ein eigener Prüfschritt. */ await zumStart(); await pruefungStarten(0); /* Eine Antwort setzen: Die Fragenübersicht unterscheidet beantwortete von offenen Fragen, und diese Unterscheidung soll nicht allein an der Farbe hängen. Ohne eine Antwort gäbe es nichts zu unterscheiden. */ await fenster.getByRole('radio').or(fenster.getByRole('checkbox')).first().click(); await pruefen('Prüfungslauf'); await fenster.getByRole('button', { name: 'Simulation abgeben' }).click(); await fenster.getByRole('heading', { name: 'Simulation wirklich abgeben?' }).waitFor(); await pruefen('Prüfung: Rückfrage vor der Abgabe'); await fenster.getByRole('button', { name: 'Jetzt abgeben und auswerten' }).click(); await fenster .getByRole('heading', { name: 'Auswertung der Prüfungssimulation', level: 1 }) .waitFor(); /* Der Verlauf früherer Läufe kommt aus der Datenbank nach. Ohne dieses Warten misst axe den Ladehinweis statt der Tabelle. */ await fenster.getByText('Der Verlauf wird geladen …').waitFor({ state: 'hidden' }); await pruefen('Prüfungsauswertung'); }, }, { name: 'Prüfung: Selbstbewertung offener Fragen', messen: async (pruefen) => { /* Eigene Ansicht mit eigener Bauform: je offener Frage eine Auswahlgruppe mit `fieldset`/`legend`, dazu die eigene Antwort und die Musterantwort. Ein Bogen aus lauter offenen Fragen (100 Prozent) erzwingt sie. Nicht beantwortet wird bewusst nichts: Auch „Sie haben nichts eingetragen“ ist ein Zustand, den jemand vorgelesen bekommt. */ await zumStart(); await pruefungStarten(100); await fenster.getByRole('button', { name: 'Simulation abgeben' }).click(); await fenster.getByRole('button', { name: 'Jetzt abgeben und auswerten' }).click(); await fenster.getByRole('heading', { name: 'Offene Fragen selbst bewerten' }).waitFor(); await pruefen('Prüfung: Selbstbewertung offener Fragen'); // Ohne diesen Klick bliebe der Lauf in der Nachbewertung stehen – dort // gibt es keinen der üblichen Ausstiege. await fenster.getByRole('button', { name: 'Auswertung anzeigen' }).click(); await fenster .getByRole('heading', { name: 'Auswertung der Prüfungssimulation', level: 1 }) .waitFor(); }, }, ]; for (const schema of SCHEMATA) { test.describe(`Farbschema „${THEME_BESCHRIFTUNGEN[schema]}“ (WCAG 2.2 AA)`, () => { test.beforeAll(async () => { /* Der Umschalter steht nur auf dem Startbildschirm; das Schema gilt danach für alle Ansichten dieses Blocks. */ await zumStart(); await themaSetzen(fenster, schema); }); for (const stueck of PRUEFSTUECKE) { test(stueck.name, async () => { await stueck.messen(async (zustand) => { await ansichtPruefen(fenster, `${zustand} [${schema}]`); }); }); } }); } /** * Die beiden Tore vor dem Startbildschirm. * * Eigene Anwendung mit eigenem Profilverzeichnis: `appStarten` beantwortet die * Frage nach Kapitel IV für alle übrigen Prüfungen, und „Wer lernt?“ erscheint * erst ab dem zweiten Lernprofil. */ test.describe('Tore vor dem Startbildschirm', () => { test.describe.configure({ mode: 'serial' }); let torApp: ElectronApplication | undefined; let torFenster: Page; test.beforeAll(async () => { // `bauPruefen` steht einmal je Datei, oben – hier wäre es nur eine // zweite Antwort auf dieselbe Frage. ({ app: torApp, fenster: torFenster } = await appStartenOhneZuschnitt()); }); test.afterAll(async () => { await torApp?.close(); }); /** * Farbschema setzen, wo der Umschalter nicht erreichbar ist. * * Die drei Schemata stehen auf dem Startbildschirm – also hinter dem Tor. * Wer die Frage beantwortet, um an den Umschalter zu kommen, bekommt sie * nicht wieder: Sie erscheint einmal je Profilverzeichnis. Geschrieben wird * deshalb genau das Paar, das auch `useThema.auswahlSetzen` schreibt – * Einstellung über die Brücke und `localStorage` –, danach wird neu geladen. * Kein anderer Weg als der der Anwendung, nur ohne den Klick. */ async function themaOhneUmschalter(thema: ThemeAufgeloest): Promise { await torFenster.evaluate(async (wert: string) => { window.localStorage.setItem('waffensachkunde.thema', wert); const bruecke = ( window as unknown as { lernApp?: { einstellungenSchreiben: (a: { thema: string }) => Promise }; } ).lernApp; await bruecke?.einstellungenSchreiben({ thema: wert }); }, thema); await torFenster.reload(); await torFenster.waitForSelector('h1', { state: 'visible' }); await expect(torFenster.locator('html')).toHaveAttribute('data-thema', thema); await animationenAbwarten(torFenster); } test('Frage nach Kapitel IV, in allen drei Farbschemata', async () => { for (const schema of SCHEMATA) { await themaOhneUmschalter(schema); await expect(torFenster.getByRole('heading', { name: /Lernen Sie .* mit\?/u })).toBeVisible(); await ansichtPruefen(torFenster, `Zuschnittfrage [${schema}]`); } }); test('Frage „Wer lernt?“, in allen drei Farbschemata', async () => { // Erst durch das erste Tor, dann ein zweites Profil – ohne das erscheint // die Frage nicht, und das ist Absicht (siehe `Profilfrage.tsx`). await torFenster.getByRole('button', { name: 'Weiter', exact: true }).click(); await torFenster.getByRole('heading', { name: 'Heute lernen' }).waitFor(); await torFenster.getByRole('button', { name: 'Neues Profil anlegen' }).click(); await torFenster.getByLabel('Name des neuen Profils').fill('Zweiter'); await torFenster.getByRole('button', { name: 'Profil anlegen' }).click(); await expect(torFenster.locator('.profile__liste > li')).toHaveCount(2); for (const schema of SCHEMATA) { await themaSetzen(torFenster, schema); /* Das Tor steht beim Aufbau, nicht beim Umschalten – deshalb neu laden. Die Wahl überlebt das (Einstellungen und localStorage). */ await torFenster.reload(); await torFenster.waitForSelector('h1', { state: 'visible' }); await expect(torFenster.getByRole('heading', { name: 'Wer lernt?' })).toBeVisible(); await ansichtPruefen(torFenster, `Profilfrage [${schema}]`); // Zurück auf den Startbildschirm, wo der Umschalter steht. await torFenster.getByRole('button', { name: 'Weiter', exact: true }).click(); await torFenster.getByRole('heading', { name: 'Heute lernen' }).waitFor(); } }); });