Files
GerbilManager/gerbil-manager-web/e2e/scroll-restore.spec.ts
Gulum 8aaddea4ef fix(tickets): Tab + Scrollposition überleben Browser-Zurück
Zwei Ursachen, warum es auf der Tickets-Seite nicht ging (am echten Firefox/Chromium
gegen die Live-App verifiziert):

1) Tab ging verloren: der gewählte Filter-Tab (z. B. „Geschlossen") war reiner
   Component-State und sprang nach „Zurück" (Remount) auf „Offen" zurück — also war man
   in der falschen, kurzen Liste. Jetzt wird der Tab in der Session gemerkt und
   wiederhergestellt (userPicked startet weiterhin false, damit ?focus=-Deep-Links
   weiter funktionieren).

2) Scroll-Save-Heuristik zu unzuverlässig: Beim Wegnavigieren schrumpft der Inhalt von
   .app-main → der Container klemmt auf eine kleinere Position → dieses scroll-Event
   überschrieb die gemerkte Stelle. Erkennung präzisiert: nur verwerfen, wenn Position
   UND scrollHeight gleichzeitig SINKEN (= Inhalt geschrumpft/Seitenwechsel). Eine echte
   Nutzer-Scrollung verkleinert die scrollHeight nie — damit kein fälschliches Verwerfen
   echter Positionen mehr (vorherige „Höhe geändert"-Heuristik schluckte zu viel).

Tests: scroll-restore.spec erweitert (realistisches 2-Schritt-Scrollen + Tickets-Tab
überlebt Zurück), desktop+phone grün; vitest 149 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:54:55 +02:00

129 lines
5.2 KiB
TypeScript

/**
* Scroll-Wiederherstellung (useScrollRestoration im AppShell).
*
* Regression: Liste runterscrollen → Eintrag öffnen → Browser-Zurück → die Position
* muss erhalten bleiben (vorher sprang sie auf 0, weil beim Wegnavigieren der kollabierende
* Inhalt ein scroll→0 auslöste, das die gemerkte Position überschrieb).
*
* Gescrollt wird der Container `.app-main` (nicht das Fenster). Läuft in beiden Projekten
* (desktop + phone) — die Rennmausliste ist in beiden Viewports hoch genug zum Scrollen.
*/
import { test, expect, skipUnlessMock, de } from './fixtures'
const SCROLLER = '.app-main'
async function scrollerTop(page: import('@playwright/test').Page) {
return page.evaluate((sel) => document.querySelector(sel)?.scrollTop ?? 0, SCROLLER)
}
/**
* Realistisch runterscrollen: ein Warm-up-Schritt (synchronisiert die gemerkte Höhe nach
* dem ersten Render) + der eigentliche Schritt — wie echtes Scrollen, das viele Events feuert.
*/
async function scrollDown(page: import('@playwright/test').Page, target: number) {
await page.evaluate((sel) => {
const el = document.querySelector(sel)
if (el) el.scrollTop = 60
}, SCROLLER)
await page.waitForTimeout(80)
await page.evaluate(
([sel, t]) => {
const el = document.querySelector(sel as string)
if (el) el.scrollTop = Math.min(t as number, el.scrollHeight - el.clientHeight)
},
[SCROLLER, target] as const,
)
await page.waitForTimeout(150) // rAF-gedrosseltes Speichern abwarten
}
test.describe('Scroll-Wiederherstellung', () => {
test.beforeEach(() => skipUnlessMock())
test('Rennmausliste: Position überlebt Detail-Öffnen + Browser-Zurück', async ({ page }) => {
await page.goto('/rennmaeuse')
// Warten bis Tierkarten-Links da sind.
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
// Container muss scrollbar sein, sonst ist der Test sinnlos.
const scrollable = await page.evaluate((sel) => {
const el = document.querySelector(sel)
return el ? el.scrollHeight > el.clientHeight + 50 : false
}, SCROLLER)
expect(scrollable, 'Liste muss scrollbar sein').toBeTruthy()
// Ein Stück runterscrollen.
await scrollDown(page, 400)
const before = await scrollerTop(page)
expect(before).toBeGreaterThan(50)
// Einen AKTUELL SICHTBAREN Tier-Link öffnen (kein Auto-Scroll, wie ein echter Klick).
const href = await page.evaluate(() => {
for (const a of document.querySelectorAll('a[href^="/rennmaeuse/"]')) {
const r = a.getBoundingClientRect()
if (r.top >= 0 && r.bottom <= window.innerHeight && a.getAttribute('href') !== '/rennmaeuse/neu') {
;(a as HTMLElement).click()
return a.getAttribute('href')
}
}
return null
})
expect(href).toBeTruthy()
await expect(page).toHaveURL(/\/rennmaeuse\/[^/]+$/)
// Zurück — Position muss (nahezu) wiederhergestellt sein.
await page.goBack()
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
await expect
.poll(async () => scrollerTop(page), { timeout: 4000, message: 'Scrollposition wiederhergestellt' })
.toBeGreaterThan(before - 30)
})
test('Rennmausliste: Position überlebt App-Hintergrund (visibilitychange)', async ({ page }) => {
await page.goto('/rennmaeuse')
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
await scrollDown(page, 400)
const before = await scrollerTop(page)
test.skip(before < 50, 'Liste in diesem Viewport nicht hoch genug')
// App in den Hintergrund (Handy-Sperre) und zurück simulieren.
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })
document.dispatchEvent(new Event('visibilitychange'))
})
// Inhalt „springt" (wie beim Aufwachen) künstlich nach oben …
await page.evaluate((sel) => {
const el = document.querySelector(sel)
if (el) el.scrollTop = 0
}, SCROLLER)
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' })
document.dispatchEvent(new Event('visibilitychange'))
})
// … und muss wiederhergestellt werden.
await expect
.poll(async () => scrollerTop(page), { timeout: 4000 })
.toBeGreaterThan(before - 30)
})
test('Tickets: gewählter Tab überlebt Browser-Zurück (nicht zurück auf „Offen")', async ({ page }) => {
const tt = de.feedback.tickets
await page.goto('/hilfe/tickets')
// In die „Geschlossen"-Ansicht wechseln.
await page.locator('.tickets-filter').filter({ hasText: tt.filters.closed }).click()
await expect(
page.locator('.tickets-filter--active').filter({ hasText: tt.filters.closed }),
).toBeVisible()
// Über die Brotkrümel weg und per Browser-Zurück wieder her.
await page.locator('.tickets-breadcrumb a[href="/hilfe"]').click()
await expect(page).toHaveURL(/\/hilfe$/)
await page.goBack()
// Tab muss weiterhin „Geschlossen" sein (vorher sprang er zurück auf „Offen").
await expect(
page.locator('.tickets-filter--active').filter({ hasText: tt.filters.closed }),
).toBeVisible()
})
})