import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import axe from 'axe-core'; import { jsdomOptionen } from '../src/shared/wcag'; import { useState } from 'react'; import { describe, expect, it } from 'vitest'; import { ThemaWahl } from '../src/renderer/src/components/ThemaWahl'; import { type ThemeAuswahl } from '../src/shared/theme'; /** Minimale Hülle, die die Auswahl wie in der echten App zustandsbehaftet hält. */ function Pruefstand({ start = 'system' }: { start?: ThemeAuswahl }): React.JSX.Element { const [auswahl, setzeAuswahl] = useState(start); return ( <>

Farbschema

); } /** * Wie oben, aber mit einem zweiten Bedienelement daneben. * * Auf dem Startbildschirm steht die Farbschemawahl zwischen elf weiteren * Einstellungen; jede von ihnen baut die Karte neu auf. Genau dieser * Nachbar fehlte im Prüfstand – und deshalb ist nie aufgefallen, dass ein * stehengebliebener Fokuswunsch beim nächsten Neuaufbau zuschlägt. */ function PruefstandMitNachbar(): React.JSX.Element { const [auswahl, setzeAuswahl] = useState('system'); const [an, setzeAn] = useState(false); return ( <>

Farbschema

); } describe('ThemaWahl – ARIA-Struktur', () => { it('rendert eine benannte Radiogruppe mit allen vier Optionen', () => { render(); const gruppe = screen.getByRole('radiogroup', { name: 'Farbschema' }); expect(gruppe).toBeInTheDocument(); const optionen = screen.getAllByRole('radio'); expect(optionen.map((o) => o.textContent.replace(/[●○]/gu, '').trim())).toEqual([ 'Systemvorgabe', 'Hell', 'Dunkel', 'Hoher Kontrast', ]); }); it('markiert genau eine Option als ausgewählt', () => { render(); expect(screen.getByRole('radio', { name: /Dunkel/u })).toBeChecked(); expect( screen.getAllByRole('radio').filter((o) => o.getAttribute('aria-checked') === 'true'), ).toHaveLength(1); }); it('nutzt Roving Tabindex: nur die aktive Option ist per Tab erreichbar', () => { render(); const optionen = screen.getAllByRole('radio'); const tabIndizes = optionen.map((o) => o.getAttribute('tabindex')); expect(tabIndizes).toEqual(['-1', '0', '-1', '-1']); }); }); describe('ThemaWahl – Tastaturbedienung nach ARIA APG', () => { it('wählt mit Tab und Pfeiltasten ohne Maus', async () => { const nutzer = userEvent.setup(); render(); await nutzer.tab(); expect(screen.getByRole('radio', { name: /Systemvorgabe/u }).matches(':focus')).toBe(true); await nutzer.keyboard('{ArrowRight}'); expect(screen.getByRole('radio', { name: /Hell/u })).toBeChecked(); await nutzer.keyboard('{ArrowRight}'); expect(screen.getByRole('radio', { name: /Dunkel/u })).toBeChecked(); await nutzer.keyboard('{ArrowLeft}'); expect(screen.getByRole('radio', { name: /Hell/u })).toBeChecked(); }); it('springt mit Pos1 und Ende an Anfang und Ende', async () => { const nutzer = userEvent.setup(); render(); await nutzer.tab(); await nutzer.keyboard('{End}'); expect(screen.getByRole('radio', { name: /Hoher Kontrast/u })).toBeChecked(); await nutzer.keyboard('{Home}'); expect(screen.getByRole('radio', { name: /Systemvorgabe/u })).toBeChecked(); }); it('läuft an den Rändern rundherum weiter', async () => { const nutzer = userEvent.setup(); render(); await nutzer.tab(); await nutzer.keyboard('{ArrowLeft}'); expect(screen.getByRole('radio', { name: /Hoher Kontrast/u })).toBeChecked(); await nutzer.keyboard('{ArrowRight}'); expect(screen.getByRole('radio', { name: /Systemvorgabe/u })).toBeChecked(); }); it('wählt die fokussierte Option mit der Leertaste', async () => { const nutzer = userEvent.setup(); render(); await nutzer.tab(); await nutzer.keyboard(' '); expect(screen.getByRole('radio', { name: /Hell/u })).toBeChecked(); }); }); describe('ThemaWahl – Barrierefreiheits-Rauchtest (axe-core)', () => { it('meldet keine Verstöße gegen WCAG 2.1 A/AA', async () => { const { container } = render(); const ergebnis = await axe.run(container, jsdomOptionen()); const zusammenfassung = ergebnis.violations.map((v) => `${v.id}: ${v.help}`); expect(zusammenfassung).toEqual([]); }); }); describe('ThemaWahl – der Fokus bleibt, wo er hingehört', () => { it('reißt den Fokus nicht aus einem anderen Bedienelement', async () => { /* Befund der Prüfrunde zu 0.27.2. Der Kommentar über der Referenz sagt zu: „Fokus nur nach Tastaturnavigation setzen, nicht bei jedem Render.“ Der Effekt darunter lief aber ohne Abhängigkeitsliste, und der Merkwert wurde ausschließlich in diesem Effekt geleert. Wählt man die bereits aktive Option, bekommt React zweimal denselben Zustand, bricht ohne Render ab — und der Merkwert bleibt stehen. Der nächste beliebige Neuaufbau holte den Fokus dann aus dem gerade bedienten Element zurück auf die Farbschema-Option. */ const nutzer = userEvent.setup(); render(); const system = screen.getByRole('radio', { name: /Systemvorgabe/u }); system.focus(); await nutzer.keyboard(' '); const nachbar = screen.getByRole('checkbox', { name: 'Nachbar' }); await nutzer.click(nachbar); expect(nachbar).toHaveFocus(); }); it('setzt den Fokus bei der Pfeilnavigation weiterhin auf die neue Option', async () => { /* Die Gegenprobe: Die Behebung darf die Tastaturnavigation nicht stilllegen — das Muster verlangt den Fokuswechsel (APG, Radiogruppe). */ const nutzer = userEvent.setup(); render(); screen.getByRole('radio', { name: /Systemvorgabe/u }).focus(); await nutzer.keyboard('{ArrowRight}'); expect(screen.getByRole('radio', { name: /Hell/u })).toHaveFocus(); }); });