// @vitest-environment jsdom import { useState } from 'react' import { render, screen, act, cleanup } from '@testing-library/react' import { describe, it, expect, afterEach } from 'vitest' import { useBackClose } from './useBackClose' /** * **Was diese Tests abdecken – und was ausdrücklich nicht.** * * Abgedeckt ist die Rangfolge: Eine Zurück-Geste schließt genau die oberste * Ansicht, und ein selbst ausgelöster Sprung schließt gar nichts. Der zweite * Punkt ist nicht theoretisch – er hat hier einen echten Fehler gefangen: Die * erste Fassung der Behebung zählte selbst ausgelöste Sprünge mit, und der * Zähler blieb stehen, wenn ein Sprung gar nicht ausgeführt wurde. Die * **nächste** echte Geste wäre dann verschluckt worden, und statt die Ansicht * zu schließen, hätte man die Anwendung verlassen. * * **Nicht abgedeckt ist der Fehler, der den Anlass gab** (16.09.2026): Aus der * PDF-Ansicht führte „Edit" in den Editor, und statt ihn zu öffnen, schloss * sich die Datei. Ursache war ein `history.back()` aus dem Aufräumer der alten * Ansicht, das verzögert eintraf und den Eintrag der neuen verbrauchte. * * Nachgestellt bekommt man das hier **nicht**: jsdom verwirft einen noch nicht * ausgeführten Sprung, sobald `pushState` folgt – das verhängnisvolle * `popstate` entsteht dort also nie. Nachgeprüft, indem die alte, kaputte * Fassung wieder eingesetzt wurde: Alle Tests blieben grün. Chrome auf dem * Telefon verhält sich anders, und dort war der Fehler zu sehen. * * Wer diesen Fall absichern will, braucht einen echten Browser (Playwright). * Bis dahin ist der Beleg ein Mensch mit einem Telefon – festgehalten in * TESTING.md. */ /** Wartet, bis der Browser sein `popstate` nachgereicht hat. */ async function verlaufSetztSich() { await act(async () => { await new Promise(r => setTimeout(r, 60)) }) } function Ansicht({ name, onClose }: { name: string; onClose: () => void }) { useBackClose({ active: true, onClose }) return
{name}
} describe('useBackClose', () => { // Testing Library hängt die Ansichten nach jedem Test aus. Deren Aufräumer // stößt einen Verlaufsschritt an, der erst danach ankommt – und im nächsten // Test ein fremdes `popstate` wäre. Also selbst abräumen und abwarten: // erst die Aushäng-Effekte laufen lassen, dann das `popstate` einsammeln. afterEach(async () => { cleanup() await new Promise(r => setTimeout(r, 0)) await new Promise(r => setTimeout(r, 80)) }) it('schließt auf die Zurück-Geste des Nutzers', async () => { function Eine() { const [offen, setOffen] = useState(true) return offen ? setOffen(false)} /> :
zu
} render() expect(screen.getByText('Offen')).toBeTruthy() await act(async () => { window.history.back() }) await verlaufSetztSich() expect(screen.getByText('zu')).toBeTruthy() }) it('schließt immer nur die oberste von zwei Ansichten', async () => { function Zwei() { const [unten, setUnten] = useState(true) const [oben, setOben] = useState(true) return ( <> {unten && setUnten(false)} />} {oben && setOben(false)} />} ) } render() await act(async () => { window.history.back() }) await verlaufSetztSich() // Eine Geste, eine Ansicht. Ohne die Rangfolge im Stapel fielen beide auf // einmal weg – und der Nutzer stünde zwei Ebenen tiefer, als er gewischt // hat. expect(screen.queryByText('oben')).toBeNull() expect(screen.queryByText('unten')).toBeTruthy() await act(async () => { window.history.back() }) await verlaufSetztSich() expect(screen.queryByText('unten')).toBeNull() }) it('verschluckt die Geste nicht, nachdem eine Ansicht in eine andere überging', async () => { // Der Übergang Vorschau → „Edit" → Editor: Die alte Ansicht geht, die neue // kommt im selben Rendern. Ihr Aufräumer darf den Verlauf dann **nicht** // anfassen – und vor allem nichts merken, was er später verschlucken // würde. Genau daran ist die erste Fassung gescheitert: Der Schalter blieb // stehen, und die nächste echte Geste verpuffte. function Wechsel() { const [wo, setWo] = useState<'vorschau' | 'editor' | 'nichts'>('vorschau') return ( <> {wo === 'vorschau' && setWo('nichts')} />} {wo === 'editor' && setWo('nichts')} />} ) } render() await act(async () => { screen.getByText('edit').click() }) await verlaufSetztSich() expect(screen.queryByText('Editor')).toBeTruthy() // Und jetzt die Zurück-Geste. Sie muss ankommen. await act(async () => { window.history.back() }) await verlaufSetztSich() expect(screen.queryByText('Editor')).toBeNull() }) it('verschluckt die nächste Geste nicht, wenn eine Ansicht per Knopf ging', async () => { // Genau der Fall, an dem die erste Fassung scheiterte: Die obere Ansicht // geht über einen Knopf zu – und danach muss eine echte Geste wieder // greifen, statt vom Aufräumer der oberen verschluckt zu werden. function Zwei() { const [unten, setUnten] = useState(true) const [oben, setOben] = useState(true) return ( <> {unten && setUnten(false)} />} {oben && setOben(false)} />} ) } render() await act(async () => { screen.getByText('schliessen').click() }) await verlaufSetztSich() expect(screen.queryByText('oben')).toBeNull() expect(screen.queryByText('unten')).toBeTruthy() await act(async () => { window.history.back() }) await verlaufSetztSich() expect(screen.queryByText('unten')).toBeNull() }) })