// @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()
})
})