Files
Aether/frontend/src/hooks/useBackClose.test.tsx
T
marcandClaude Opus 5 94a803bf7a feat(ui): Meldungen mit Rückgängig, dazu jsdom und ein Fehler in der letzten Behebung
Vorher bestätigte nichts irgendetwas, und Fehler verschwanden still: Die
Handler fingen sie mit `catch { /* bleibt stehen */ }` ab. Wer eine Datei
löschen wollte und nicht durfte, sah genau dasselbe wie jemand, bei dem es
klappte – nichts.

Neu: components/Toasts.tsx. Kein Context, sondern Modulzustand, damit auch
api/client.ts melden kann, ohne Komponente zu sein. Löschen, Umbenennen und
Verschieben bekommen ein „Undo" – beim Löschen ist das der Papierkorb plus
einen Aufruf, kostet also fast nichts. Ordner löschen bekommt bewusst keines:
Der Ordner ist wirklich weg, nur seine Dateien liegen im Papierkorb, und ein
Knopf, der etwas anderes tut als er verspricht, wäre schlimmer als keiner.

Dazu die erste Animation im Projekt – und gleich die Regel dazu:
prefers-reduced-motion wird respektiert.

**jsdom ist jetzt in den devDependencies**, und der erste DOM-Test hat sofort
einen Fehler in der Behebung von vorgestern gefunden:

Der Zähler für selbst ausgelöste Verlaufsschritte setzt voraus, dass jedes
`back()` auch ein `popstate` nach sich zieht. Das gilt nicht – folgt im selben
Durchlauf ein `pushState`, verwirft der Browser den Sprung. Der Zähler blieb
dann stehen, und die nächste echte Zurück-Geste wurde verschluckt: Statt die
Ansicht zu schließen, hätte man die Anwendung verlassen.

Jetzt wird erst im nächsten Durchlauf entschieden und geprüft, ob der eigene
Eintrag noch obenauf liegt. Nur dann kommt es zum Sprung – und dann folgt das
`popstate` garantiert. Der Zuhörer hängt am Modul statt an der Ansicht; vorher
entfernte ihn deren Aufräumer, bevor der eigene Sprung ausgeführt war.

Ehrlich dokumentiert: Den ursprünglichen Fehler bilden diese Tests **nicht**
ab. jsdom verwirft den Sprung, das verhängnisvolle popstate entsteht dort nie
– mit der alten, kaputten Fassung blieben alle Tests grün. Dafür bräuchte es
einen echten Browser. Steht so in TESTING.md und im Test selbst.

Geprüft: tsc sauber, 41 Vitest-Tests (4 neue unter Mutation nachgewiesen),
alle 234 Prüfungen der sieben Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:05:12 +02:00

156 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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 <div>{name}</div>
}
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 ? <Ansicht name="Offen" onClose={() => setOffen(false)} /> : <div>zu</div>
}
render(<Eine />)
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 && <Ansicht name="unten" onClose={() => setUnten(false)} />}
{oben && <Ansicht name="oben" onClose={() => setOben(false)} />}
</>
)
}
render(<Zwei />)
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' && <Ansicht name="Vorschau" onClose={() => setWo('nichts')} />}
{wo === 'editor' && <Ansicht name="Editor" onClose={() => setWo('nichts')} />}
<button onClick={() => setWo('editor')}>edit</button>
</>
)
}
render(<Wechsel />)
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 && <Ansicht name="unten" onClose={() => setUnten(false)} />}
{oben && <Ansicht name="oben" onClose={() => setOben(false)} />}
<button onClick={() => setOben(false)}>schliessen</button>
</>
)
}
render(<Zwei />)
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()
})
})