diff --git a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.test.ts new file mode 100644 index 00000000..c200a6ce --- /dev/null +++ b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import StammbaumSidePanel from './StammbaumSidePanel.svelte'; + +vi.mock('$app/navigation', () => ({ + beforeNavigate: () => {}, + afterNavigate: () => {}, + goto: vi.fn(), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + preloadCode: vi.fn(), + preloadData: vi.fn(), + pushState: vi.fn(), + replaceState: vi.fn(), + disableScrollHandling: vi.fn(), + onNavigate: () => () => {} +})); + +afterEach(cleanup); + +const baseNode = (overrides: Record = {}) => ({ + id: 'p-1', + displayName: 'Anna Schmidt', + familyMember: true, + ...overrides +}); + +describe('StammbaumSidePanel', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url: RequestInfo | URL) => { + const u = url.toString(); + if (u.includes('/relationships') && !u.includes('/inferred')) { + return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (u.includes('/inferred-relationships')) { + return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return new Response('[]', { status: 200 }); + }); + }); + + afterEach(() => { + fetchSpy?.mockRestore(); + }); + + it('renders the heading from node displayName', async () => { + render(StammbaumSidePanel, { + props: { node: baseNode(), onClose: () => {} } + }); + + await expect.element(page.getByRole('heading', { name: 'Anna Schmidt' })).toBeVisible(); + }); + + it('shows birth/death years when set', async () => { + render(StammbaumSidePanel, { + props: { + node: baseNode({ birthYear: 1899, deathYear: 1950 }), + onClose: () => {} + } + }); + + expect(document.body.textContent).toContain('1899'); + expect(document.body.textContent).toContain('1950'); + }); + + it('renders ?– when birthYear is missing but deathYear is set', async () => { + render(StammbaumSidePanel, { + props: { node: baseNode({ deathYear: 1950 }), onClose: () => {} } + }); + + expect(document.body.textContent).toMatch(/\?–/); + }); + + it('does not render the years line when both are missing', async () => { + render(StammbaumSidePanel, { + props: { node: baseNode(), onClose: () => {} } + }); + + // no years line visible — should not see the question-mark fallback + expect(document.body.textContent).not.toMatch(/\?–\?/); + }); + + it('calls onClose when the close button is clicked', async () => { + const onClose = vi.fn(); + render(StammbaumSidePanel, { + props: { node: baseNode(), onClose } + }); + + const closeBtn = document.querySelector('button[aria-label]') as HTMLButtonElement; + closeBtn.click(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onClose when Escape is pressed on window', async () => { + const onClose = vi.fn(); + render(StammbaumSidePanel, { + props: { node: baseNode(), onClose } + }); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('does not call onClose for non-Escape keys', async () => { + const onClose = vi.fn(); + render(StammbaumSidePanel, { + props: { node: baseNode(), onClose } + }); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('renders the person detail link', async () => { + render(StammbaumSidePanel, { + props: { node: baseNode(), onClose: () => {} } + }); + + const link = document.querySelector('a[href="/persons/p-1"]'); + expect(link).not.toBeNull(); + }); + + it('shows the empty placeholder when there are no direct relationships', async () => { + render(StammbaumSidePanel, { + props: { node: baseNode(), onClose: () => {} } + }); + + await new Promise((r) => setTimeout(r, 100)); + await expect.element(page.getByText(/keine beziehungen bekannt/i)).toBeVisible(); + }); + + it('shows the error banner when both fetch calls fail', async () => { + fetchSpy.mockResolvedValue(new Response('error', { status: 500 })); + + render(StammbaumSidePanel, { + props: { node: baseNode(), onClose: () => {} } + }); + + await new Promise((r) => setTimeout(r, 100)); + const alert = document.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + }); + + it('shows the AddRelationshipForm only when canWrite is true', async () => { + render(StammbaumSidePanel, { + props: { node: baseNode(), onClose: () => {}, canWrite: true } + }); + + await new Promise((r) => setTimeout(r, 100)); + // AddRelationshipForm exposes a "Beziehung hinzufügen" toggle button + const addButtons = Array.from(document.querySelectorAll('button')).filter((b) => + b.textContent?.toLowerCase().includes('hinzufügen') + ); + expect(addButtons.length).toBeGreaterThan(0); + }); + + it('hides the AddRelationshipForm when canWrite is false', async () => { + render(StammbaumSidePanel, { + props: { node: baseNode(), onClose: () => {}, canWrite: false } + }); + + await new Promise((r) => setTimeout(r, 100)); + const addButtons = Array.from(document.querySelectorAll('button')).filter((b) => + b.textContent?.toLowerCase().includes('hinzufügen') + ); + expect(addButtons.length).toBe(0); + }); +});