From 54cc493230d00a096404f33e4b9ca24dcefe9fc4 Mon Sep 17 00:00:00 2001 From: Gulum Date: Tue, 23 Jun 2026 14:33:17 +0200 Subject: [PATCH] test(e2e): Scroll-Wiederherstellung der Rennmausliste (desktop + phone) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deckt die Regression ab, die zuletzt durchrutschte: - Liste scrollen → Tier öffnen → Browser-Zurück → Position bleibt erhalten. - Position überlebt App-Hintergrund/Wiederanzeige (visibilitychange). Läuft im phone-Projekt (390x844, Touch) UND desktop, am echten Scroll-Container .app-main. Co-Authored-By: Claude Opus 4.8 --- gerbil-manager-web/e2e/scroll-restore.spec.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 gerbil-manager-web/e2e/scroll-restore.spec.ts diff --git a/gerbil-manager-web/e2e/scroll-restore.spec.ts b/gerbil-manager-web/e2e/scroll-restore.spec.ts new file mode 100644 index 0000000..b6cb8e3 --- /dev/null +++ b/gerbil-manager-web/e2e/scroll-restore.spec.ts @@ -0,0 +1,96 @@ +/** + * 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 } from './fixtures' + +const SCROLLER = '.app-main' + +async function scrollerTop(page: import('@playwright/test').Page) { + return page.evaluate((sel) => document.querySelector(sel)?.scrollTop ?? 0, SCROLLER) +} + +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 page.evaluate((sel) => { + const el = document.querySelector(sel) + if (el) el.scrollTop = Math.min(400, el.scrollHeight - el.clientHeight) + }, SCROLLER) + await page.waitForTimeout(150) // rAF-gedrosseltes Speichern abwarten + 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 page.evaluate((sel) => { + const el = document.querySelector(sel) + if (el) el.scrollTop = Math.min(400, el.scrollHeight - el.clientHeight) + }, SCROLLER) + await page.waitForTimeout(150) + 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) + }) +})