/** * Voreinstellung der Tastenkürzel bei erkanntem Hilfsmittel – und ihr Ort. * * Hintergrund der Voreinstellung: Im Lesemodus von NVDA und JAWS sind die * Ziffern 1 bis 6 für die Navigation nach Überschriftenebenen belegt. Kürzel, * die dieselben Tasten belegen, gehen dort ins Leere oder stören – * ausgerechnet beim Beantworten einer Frage, dem wichtigsten Ablauf der * Anwendung. * * Hintergrund des Ortes: Bis 0.21.0 lag die Entscheidung im `localStorage` * des Renderers, also außerhalb von Einstellungen und Profil. Seither geht * sie über `einstellungen:lesen`/`einstellungen:schreiben` nach * `einstellungen.json` – denselben Weg wie „Vorlesen“ und die Anzeigegröße. * Geprüft wird der echte Hook, nicht eine Nachbildung seiner Regel. */ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { useKuerzelEinstellung } from '../src/renderer/src/hooks/useTastenkuerzel'; import { EINSTELLUNGEN_STANDARD, type Einstellungen } from '../src/shared/ipc'; /** Der frühere Ablageort – nur noch für die einmalige Übernahme. */ const ALTER_SCHLUESSEL = 'waffensachkunde.tastenkuerzel'; /** * Bildet den Anwendungskern nach: eine „Datei“, die gelesen und beschrieben * wird. `schreibfehler` stellt einen vollen Datenträger nach. * * Zurück kommt die Liste der Teiländerungen, die den Kern erreicht haben – * daran zeigt sich, ob wirklich `einstellungen.json` bedient wird. */ function kern( gespeichert: Partial = {}, { schreibfehler = false }: { schreibfehler?: boolean } = {}, ): { readonly geschrieben: Partial[] } { let stand: Einstellungen = { ...EINSTELLUNGEN_STANDARD, ...gespeichert }; const geschrieben: Partial[] = []; vi.stubGlobal('lernApp', { einstellungenLesen: vi.fn(() => Promise.resolve(stand)), einstellungenSchreiben: vi.fn((aenderung: Partial) => { if (schreibfehler) { return Promise.reject(new Error('Die Einstellungen konnten nicht gespeichert werden.')); } geschrieben.push(aenderung); stand = { ...stand, ...aenderung }; return Promise.resolve(stand); }), }); return { geschrieben }; } /* `tests/setup.ts` leert vor jedem Test den localStorage; die Brücke muss hier weg, sonst sähe der Test „ohne Anwendungskern“ die Attrappe des vorigen. */ afterEach(() => { vi.unstubAllGlobals(); }); describe('Voreinstellung der Tastenkürzel', () => { it('lässt die Kürzel an, solange kein Hilfsmittel läuft', async () => { kern(); const { result } = renderHook(() => useKuerzelEinstellung(false)); await waitFor(() => { expect(result.current.aktiv).toBe(true); }); expect(result.current.wegenHilfsmittelAus).toBe(false); }); it('schaltet sie ohne eigene Entscheidung ab, wenn ein Hilfsmittel läuft', async () => { kern(); const { result } = renderHook(() => useKuerzelEinstellung(true)); await waitFor(() => { expect(result.current.wegenHilfsmittelAus).toBe(true); }); expect(result.current.aktiv).toBe(false); }); it('achtet eine ausdrückliche Zusage auch bei laufendem Hilfsmittel', async () => { // Wer die Kürzel bewusst eingeschaltet hat, soll sie behalten. kern({ tastenkuerzel: true }); const { result } = renderHook(() => useKuerzelEinstellung(true)); await waitFor(() => { expect(result.current.aktiv).toBe(true); }); // Die Begründung „wegen Hilfsmittel“ wäre hier schlicht falsch. expect(result.current.wegenHilfsmittelAus).toBe(false); }); it('achtet eine ausdrückliche Absage auch ohne Hilfsmittel', async () => { kern({ tastenkuerzel: false }); const { result } = renderHook(() => useKuerzelEinstellung(false)); await waitFor(() => { expect(result.current.aktiv).toBe(false); }); expect(result.current.wegenHilfsmittelAus).toBe(false); }); it('begründet nichts, solange der Anwendungskern noch antwortet', () => { /* Sonst blitzte der Hinweis „Es läuft ein Hilfsmittel …“ am Schalter auf, um gleich wieder zu verschwinden – bei jemandem, der die Kürzel längst eingeschaltet hat. */ kern({ tastenkuerzel: true }); const { result } = renderHook(() => useKuerzelEinstellung(true)); expect(result.current.wegenHilfsmittelAus).toBe(false); }); it('überschreibt eine eigene Entscheidung nicht, wenn später ein Hilfsmittel startet', async () => { kern(); const { result, rerender } = renderHook( ({ hilfsmittel }: { hilfsmittel: boolean }) => useKuerzelEinstellung(hilfsmittel), { initialProps: { hilfsmittel: false } }, ); await waitFor(() => { expect(result.current.aktiv).toBe(true); }); act(() => { result.current.setzen(true); }); expect(result.current.aktiv).toBe(true); // Screenreader startet mitten in der Sitzung. rerender({ hilfsmittel: true }); expect(result.current.aktiv).toBe(true); expect(result.current.wegenHilfsmittelAus).toBe(false); }); it('greift die Voreinstellung auf, wenn das Hilfsmittel ohne eigene Wahl startet', async () => { kern(); const { result, rerender } = renderHook( ({ hilfsmittel }: { hilfsmittel: boolean }) => useKuerzelEinstellung(hilfsmittel), { initialProps: { hilfsmittel: false } }, ); await waitFor(() => { expect(result.current.aktiv).toBe(true); }); rerender({ hilfsmittel: true }); expect(result.current.aktiv).toBe(false); expect(result.current.wegenHilfsmittelAus).toBe(true); }); }); describe('Ablage der Entscheidung', () => { it('schreibt die Wahl in die Einstellungen, nicht in den localStorage', async () => { const { geschrieben } = kern(); const { result } = renderHook(() => useKuerzelEinstellung(false)); await waitFor(() => { expect(result.current.aktiv).toBe(true); }); act(() => { result.current.setzen(false); }); await waitFor(() => { expect(geschrieben).toEqual([{ tastenkuerzel: false }]); }); expect(window.localStorage.getItem(ALTER_SCHLUESSEL)).toBeNull(); }); it('liegt nach einem Neustart wieder an', async () => { const { geschrieben } = kern(); const erster = renderHook(() => useKuerzelEinstellung(false)); await waitFor(() => { expect(erster.result.current.aktiv).toBe(true); }); act(() => { erster.result.current.setzen(false); }); await waitFor(() => { expect(geschrieben).toHaveLength(1); }); erster.unmount(); /* Neustart: dieselbe Attrappe, also dieselbe „Datei“ – ein neuer Hook liest sie erneut. */ const { result } = renderHook(() => useKuerzelEinstellung(false)); await waitFor(() => { expect(result.current.aktiv).toBe(false); }); }); it('meldet, wenn die Wahl nicht gesichert werden konnte', async () => { /* Der Schalter springt trotzdem um – aber dass er die Sitzung nicht überlebt, muss gesagt werden statt verschwiegen. */ vi.spyOn(console, 'warn').mockImplementation(() => undefined); kern({}, { schreibfehler: true }); const { result } = renderHook(() => useKuerzelEinstellung(false)); await waitFor(() => { expect(result.current.aktiv).toBe(true); }); act(() => { result.current.setzen(false); }); expect(result.current.aktiv).toBe(false); await waitFor(() => { expect(result.current.meldung).toMatch(/nicht dauerhaft gespeichert/u); }); }); }); describe('Übernahme der bisherigen Wahl aus dem localStorage', () => { it('übernimmt einen alten Wert beim ersten Start nach der Umstellung', async () => { /* Wer die Kürzel vor der Umstellung abgeschaltet hat, soll sie nicht beim nächsten Start wieder eingeschaltet vorfinden. */ window.localStorage.setItem(ALTER_SCHLUESSEL, 'aus'); const { geschrieben } = kern(); const { result } = renderHook(() => useKuerzelEinstellung(false)); await waitFor(() => { expect(result.current.aktiv).toBe(false); }); await waitFor(() => { expect(geschrieben).toEqual([{ tastenkuerzel: false }]); }); // Danach ist der alte Eintrag weg und wird nie wieder gelesen. expect(window.localStorage.getItem(ALTER_SCHLUESSEL)).toBeNull(); }); it('übernimmt auch ein ausdrückliches „an“', async () => { window.localStorage.setItem(ALTER_SCHLUESSEL, 'an'); const { geschrieben } = kern(); const { result } = renderHook(() => useKuerzelEinstellung(true)); await waitFor(() => { expect(result.current.aktiv).toBe(true); }); expect(geschrieben).toEqual([{ tastenkuerzel: true }]); expect(result.current.wegenHilfsmittelAus).toBe(false); }); it('lässt die Datei gewinnen, sobald sie einen Wert hat', async () => { /* Nach der Übernahme wird der alte Ort nicht mehr gelesen – auch dann nicht, wenn dort noch etwas steht (etwa nach einem gescheiterten Löschversuch oder auf einem zweiten Gerät). */ window.localStorage.setItem(ALTER_SCHLUESSEL, 'aus'); const { geschrieben } = kern({ tastenkuerzel: true }); const { result } = renderHook(() => useKuerzelEinstellung(false)); await waitFor(() => { expect(result.current.aktiv).toBe(true); }); expect(geschrieben).toEqual([]); expect(window.localStorage.getItem(ALTER_SCHLUESSEL)).toBeNull(); }); it('behält den alten Wert, wenn das Übernehmen scheitert', async () => { /* Löschen vor dem erfolgreichen Schreiben wäre der eine Fehler, der die Entscheidung endgültig vernichtet. Der nächste Start versucht es stattdessen erneut. */ vi.spyOn(console, 'warn').mockImplementation(() => undefined); window.localStorage.setItem(ALTER_SCHLUESSEL, 'aus'); kern({}, { schreibfehler: true }); const { result } = renderHook(() => useKuerzelEinstellung(false)); await waitFor(() => { expect(result.current.aktiv).toBe(false); }); await waitFor(() => { expect(window.localStorage.getItem(ALTER_SCHLUESSEL)).toBe('aus'); }); }); it('kommt ohne Anwendungskern mit der Voreinstellung aus', () => { /* Kein Preload (Vitest ohne Attrappe, Vorschau im Browser): Dann gibt es nichts nachzuladen, und der Anfangswert ist schon der Endwert. */ const { result } = renderHook(() => useKuerzelEinstellung(true)); expect(result.current.aktiv).toBe(false); expect(result.current.wegenHilfsmittelAus).toBe(true); }); });