test(genealogy): cover StammbaumSidePanel branches

Heading from displayName, birth/death years rendering with all
branch combinations, close button, Escape keypress closing,
non-Escape ignored, person detail link, empty placeholder, error
banner on fetch failure, AddRelationshipForm visibility toggle.

12 tests covering ~25 branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-10 04:20:10 +02:00
committed by marcel
parent f9b62982f6
commit c9a14b6e90

View File

@@ -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<string, unknown> = {}) => ({
id: 'p-1',
displayName: 'Anna Schmidt',
familyMember: true,
...overrides
});
describe('StammbaumSidePanel', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
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);
});
});