diff --git a/frontend/src/routes/stammbaum/page.svelte.test.ts b/frontend/src/routes/stammbaum/page.svelte.test.ts index b9f58e20..578aff76 100644 --- a/frontend/src/routes/stammbaum/page.svelte.test.ts +++ b/frontend/src/routes/stammbaum/page.svelte.test.ts @@ -24,7 +24,28 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() })); -afterEach(cleanup); +// The page reads window.matchMedia('(max-width: 767px)').matches at init to +// decide whether to centre a tapped person above the bottom sheet (#703 AC8). +// Make the mock query-aware so only the mobile breakpoint flips; other media +// queries (e.g. prefers-reduced-motion) keep their benign default. +const originalMatchMedia = window.matchMedia; +function mockMatchMedia(isMobile: boolean) { + window.matchMedia = ((query: string) => ({ + matches: query.includes('max-width') ? isMobile : false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false + })) as unknown as typeof window.matchMedia; +} + +afterEach(() => { + cleanup(); + window.matchMedia = originalMatchMedia; +}); async function loadComponent() { return (await import('./+page.svelte')).default; @@ -35,6 +56,12 @@ const sampleNodes = [ { id: 'p-2', firstName: 'Bert', lastName: 'Schmidt', displayName: 'Bert Schmidt' } ]; +// Typed family nodes for the AC8 tests — familyMember is required on the DTO. +const familyNodes = [ + { id: 'p-1', displayName: 'Anna Schmidt', familyMember: true }, + { id: 'p-2', displayName: 'Bert Schmidt', familyMember: true } +]; + describe('stammbaum page', () => { it('shows the empty state when there are no family nodes', async () => { mockPage.url = new URL('http://localhost/stammbaum'); @@ -125,4 +152,44 @@ describe('stammbaum page', () => { expect(url.searchParams.has('cx')).toBe(true); expect(url.searchParams.has('cy')).toBe(true); }); + + // AC8 — the tapped person must clear the bottom sheet on a phone, but the + // desktop side panel is a flex sibling that never overlaps the canvas, so no + // centring should fire there. These tests prove the matchMedia gate around + // selectPerson, not just the recentreAbove geometry (covered in panZoom.test). + it('recentres the tapped person when matchMedia reports mobile (#703 AC8)', async () => { + mockMatchMedia(true); + mockPage.url = new URL('http://localhost/stammbaum'); + const Stammbaum = await loadComponent(); + render(Stammbaum, { + props: { data: { nodes: familyNodes, edges: [], initialView: DEFAULT_VIEW } } + }); + // Let the mount-time URL mirror settle, then isolate the tap's effect. + await vi.waitFor(() => expect(replaceState).toHaveBeenCalled()); + replaceState.mockClear(); + + await page.getByRole('button', { name: 'Anna Schmidt' }).click(); + + // The mobile tap recentres the canvas → the view changes → the ?cx&cy&z + // mirror effect re-fires. (Desktop, below, leaves the view untouched.) + await vi.waitFor(() => expect(replaceState).toHaveBeenCalled()); + }); + + it('does not recentre on tap when matchMedia reports desktop (#703 AC8)', async () => { + mockMatchMedia(false); + mockPage.url = new URL('http://localhost/stammbaum'); + const Stammbaum = await loadComponent(); + render(Stammbaum, { + props: { data: { nodes: familyNodes, edges: [], initialView: DEFAULT_VIEW } } + }); + await vi.waitFor(() => expect(replaceState).toHaveBeenCalled()); + replaceState.mockClear(); + + await page.getByRole('button', { name: 'Anna Schmidt' }).click(); + + // The tap registers — the desktop side panel opens — but no recentre fires, + // so the view never changes and the mirror effect stays silent. + await expect.element(page.getByRole('complementary')).toBeVisible(); + expect(replaceState).not.toHaveBeenCalled(); + }); });