import { describe, it, expect, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; import StammbaumTree from './StammbaumTree.svelte'; import type { PanZoomState } from './panZoom'; import { DIMMED_OPACITY } from './layout/highlightLineage'; const ID_A = '00000000-0000-0000-0000-000000000001'; const ID_B = '00000000-0000-0000-0000-000000000002'; function parseViewBox(svg: SVGElement): [number, number, number, number] { const parts = svg.getAttribute('viewBox')!.split(/\s+/).map(Number); return [parts[0], parts[1], parts[2], parts[3]]; } function rectsCentroid(svg: SVGElement): { x: number; y: number } { const rects = Array.from(svg.querySelectorAll('rect')); let sx = 0; let sy = 0; let n = 0; for (const r of rects) { const x = parseFloat(r.getAttribute('x') ?? '0'); const y = parseFloat(r.getAttribute('y') ?? '0'); const w = parseFloat(r.getAttribute('width') ?? '0'); const h = parseFloat(r.getAttribute('height') ?? '0'); // Skip the narrow accent stripe. if (w < 10) continue; // Each node rect lives inside . const g = r.closest('g[transform]'); const transform = g?.getAttribute('transform') ?? ''; const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/); const tx = match ? parseFloat(match[1]) : 0; const ty = match ? parseFloat(match[2]) : 0; sx += tx + x + w / 2; sy += ty + y + h / 2; n++; } return { x: sx / n, y: sy / n }; } describe('StammbaumTree viewBox', () => { it('offsets the viewBox origin by the pan state (#692)', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, panZoom: { x: 100, y: 40, z: 1 }, showGutter: false, onSelect: () => {} }); const svg = document.querySelector('svg')!; const [x, y, w, h] = parseViewBox(svg); // Same dimensions as the unpanned default (z=1)… expect(w).toBe(1200); expect(h).toBe(800); // …but the viewBox centre is the content centroid shifted by the pan // offset (at pan {0,0} the centre sits on the centroid — see the test // below). This avoids hard-coding the layout's absolute coordinates. const c = rectsCentroid(svg); expect(x + w / 2 - c.x).toBeCloseTo(100, 6); expect(y + h / 2 - c.y).toBeCloseTo(40, 6); }); it('uses the minimum size and centers a single node', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const svg = document.querySelector('svg')!; const [x, y, w, h] = parseViewBox(svg); // Single 160x56 node fits inside the 1200x800 minimum viewBox. expect(w).toBe(1200); expect(h).toBe(800); // Whatever absolute coordinates the layout uses, the viewBox must // centre on the rendered content. const c = rectsCentroid(svg); expect(x + w / 2).toBeCloseTo(c.x, 1); expect(y + h / 2).toBeCloseTo(c.y, 1); }); it('renders only orthogonal segments when two parents share two children', async () => { const PARENT_A = '00000000-0000-0000-0000-00000000000a'; const PARENT_B = '00000000-0000-0000-0000-00000000000b'; const CHILD_1 = '00000000-0000-0000-0000-00000000000c'; const CHILD_2 = '00000000-0000-0000-0000-00000000000d'; render(StammbaumTree, { nodes: [ { id: PARENT_A, displayName: 'Walter', familyMember: true }, { id: PARENT_B, displayName: 'Eugenie', familyMember: true }, { id: CHILD_1, displayName: 'Clara', familyMember: true }, { id: CHILD_2, displayName: 'Hans', familyMember: true } ], edges: [ { id: 'sp', personId: PARENT_A, relatedPersonId: PARENT_B, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', relationType: 'SPOUSE_OF' }, { id: 'p1a', personId: PARENT_A, relatedPersonId: CHILD_1, personDisplayName: 'Walter', relatedPersonDisplayName: 'Clara', relationType: 'PARENT_OF' }, { id: 'p1b', personId: PARENT_B, relatedPersonId: CHILD_1, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Clara', relationType: 'PARENT_OF' }, { id: 'p2a', personId: PARENT_A, relatedPersonId: CHILD_2, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', relationType: 'PARENT_OF' }, { id: 'p2b', personId: PARENT_B, relatedPersonId: CHILD_2, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', relationType: 'PARENT_OF' } ], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const lines = Array.from(document.querySelectorAll('svg line')); // Every parent-child segment must be either vertical (x1==x2) or // horizontal (y1==y2) — no slanted segments allowed. const slanted = lines.filter( (l) => l.getAttribute('x1') !== l.getAttribute('x2') && l.getAttribute('y1') !== l.getAttribute('y2') ); expect(slanted).toHaveLength(0); // Sibling bar must exist and span the children: at least one // horizontal line whose x1 != x2 and y1 == y2. const horizontalBars = lines.filter( (l) => l.getAttribute('y1') === l.getAttribute('y2') && l.getAttribute('x1') !== l.getAttribute('x2') ); expect(horizontalBars.length).toBeGreaterThanOrEqual(1); }); it('positions a single child at the midpoint of its two parents (vertical drop)', async () => { const PARENT_A = '00000000-0000-0000-0000-00000000000a'; const PARENT_B = '00000000-0000-0000-0000-00000000000b'; const CHILD = '00000000-0000-0000-0000-00000000000c'; render(StammbaumTree, { nodes: [ { id: PARENT_A, displayName: 'Walter', familyMember: true }, { id: PARENT_B, displayName: 'Eugenie', familyMember: true }, { id: CHILD, displayName: 'Hans', familyMember: true } ], edges: [ { id: 'sp', personId: PARENT_A, relatedPersonId: PARENT_B, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', relationType: 'SPOUSE_OF' }, { id: 'p1', personId: PARENT_A, relatedPersonId: CHILD, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', relationType: 'PARENT_OF' }, { id: 'p2', personId: PARENT_B, relatedPersonId: CHILD, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', relationType: 'PARENT_OF' } ], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const lines = Array.from(document.querySelectorAll('svg line')); // No slanted segments. With one child, no horizontal sibling bar // is needed because midX == child.center.x. const slanted = lines.filter( (l) => l.getAttribute('x1') !== l.getAttribute('x2') && l.getAttribute('y1') !== l.getAttribute('y2') ); expect(slanted).toHaveLength(0); }); it('places a loose spouse adjacent to their partner and demotes their child a generation', async () => { // Walter ↔ Eugenie (gen 0); their children Hans + Clara (gen 1). // Hans ↔ Hilde (Hilde has no parents in graph). Hans + Hilde have // child Lili. Hilde must sit next to Hans, and Lili must be on a // row below Hans/Hilde — not on the same row. const WALTER = '00000000-0000-0000-0000-000000000001'; const EUGENIE = '00000000-0000-0000-0000-000000000002'; const HANS = '00000000-0000-0000-0000-000000000003'; const CLARA = '00000000-0000-0000-0000-000000000004'; const HILDE = '00000000-0000-0000-0000-000000000005'; const LILI = '00000000-0000-0000-0000-000000000006'; render(StammbaumTree, { nodes: [ { id: WALTER, displayName: 'Walter', familyMember: true }, { id: EUGENIE, displayName: 'Eugenie', familyMember: true }, { id: HANS, displayName: 'Hans', familyMember: true }, { id: CLARA, displayName: 'Clara', familyMember: true }, { id: HILDE, displayName: 'Hilde', familyMember: true }, { id: LILI, displayName: 'Lili', familyMember: true } ], edges: [ { id: 's1', personId: WALTER, relatedPersonId: EUGENIE, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', relationType: 'SPOUSE_OF' }, { id: 'p1', personId: WALTER, relatedPersonId: HANS, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', relationType: 'PARENT_OF' }, { id: 'p2', personId: EUGENIE, relatedPersonId: HANS, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', relationType: 'PARENT_OF' }, { id: 'p3', personId: WALTER, relatedPersonId: CLARA, personDisplayName: 'Walter', relatedPersonDisplayName: 'Clara', relationType: 'PARENT_OF' }, { id: 'p4', personId: EUGENIE, relatedPersonId: CLARA, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Clara', relationType: 'PARENT_OF' }, { id: 's2', personId: HANS, relatedPersonId: HILDE, personDisplayName: 'Hans', relatedPersonDisplayName: 'Hilde', relationType: 'SPOUSE_OF' }, { id: 'p5', personId: HANS, relatedPersonId: LILI, personDisplayName: 'Hans', relatedPersonDisplayName: 'Lili', relationType: 'PARENT_OF' }, { id: 'p6', personId: HILDE, relatedPersonId: LILI, personDisplayName: 'Hilde', relatedPersonDisplayName: 'Lili', relationType: 'PARENT_OF' } ], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const ys = new Map(); for (const g of Array.from(document.querySelectorAll('g[transform]'))) { const aria = g.getAttribute('aria-label') ?? ''; const transform = g.getAttribute('transform') ?? ''; const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/); if (!match) continue; ys.set(aria.split(',')[0], parseFloat(match[2])); } // Lili must be on a deeper row than Hans / Hilde. expect(ys.get('Lili')).toBeGreaterThan(ys.get('Hans')!); expect(ys.get('Hans')).toEqual(ys.get('Hilde')); // Hans and Hilde must be horizontally adjacent (|Δx| == NODE_W + COL_GAP). const xs = new Map(); for (const g of Array.from(document.querySelectorAll('g[transform]'))) { const aria = g.getAttribute('aria-label') ?? ''; const transform = g.getAttribute('transform') ?? ''; const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/); if (!match) continue; xs.set(aria.split(',')[0], parseFloat(match[1])); } expect(Math.abs(xs.get('Hans')! - xs.get('Hilde')!)).toBe(160 + 40); // All parent-child segments must be orthogonal. const lines = Array.from(document.querySelectorAll('svg line')); const slanted = lines.filter( (l) => l.getAttribute('x1') !== l.getAttribute('x2') && l.getAttribute('y1') !== l.getAttribute('y2') ); expect(slanted).toHaveLength(0); }); it('renders the marriage-line midpoint dot at r=6 for WCAG 1.4.11 informational contrast (#361)', async () => { // Once the dot stacks to disambiguate multiple marriages it carries // meaning, so it moves from "decorative" to "informational" and the // 3:1 contrast rule requires the larger 12 px diameter. render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Anna', familyMember: true }, { id: ID_B, displayName: 'Bertha', familyMember: true } ], edges: [ { id: 'e1', personId: ID_A, relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', relationType: 'SPOUSE_OF' } ], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const dot = document.querySelector('svg circle'); expect(dot).not.toBeNull(); expect(dot!.getAttribute('r')).toBe('6'); // Cycle-2 follow-up from Sara: codify the colour-token side of the // WCAG 1.4.11 contrast contract at the unit level. The permanent axe- // core gate lives in #692; this assertion prevents an accidental // "neutralise the dot" diff (e.g. swap to var(--c-ink-3) or a literal // light token) from stripping the 3:1 contrast guarantee before #692 // ships. expect(dot!.getAttribute('fill')).toBe('var(--c-primary)'); }); it('centers two spouse nodes within the minimum viewBox', async () => { render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Anna', familyMember: true }, { id: ID_B, displayName: 'Bertha', familyMember: true } ], edges: [ { id: 'e1', personId: ID_A, relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', relationType: 'SPOUSE_OF' } ], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const svg = document.querySelector('svg')!; const [x, y, w, h] = parseViewBox(svg); expect(w).toBe(1200); expect(h).toBe(800); const c = rectsCentroid(svg); expect(x + w / 2).toBeCloseTo(c.x, 1); expect(y + h / 2).toBeCloseTo(c.y, 1); }); }); describe('StammbaumTree node rendering branches', () => { it('renders the selected node with primary fill (selected branch)', async () => { render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Anna', familyMember: true }, { id: ID_B, displayName: 'Bertha', familyMember: true } ], edges: [], selectedId: ID_A, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const rects = Array.from(document.querySelectorAll('rect')); const primaryRects = rects.filter((r) => r.getAttribute('fill') === 'var(--c-primary)'); expect(primaryRects.length).toBeGreaterThan(0); }); it('renders birth/death years line when set', async () => { render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Anna', familyMember: true, birthYear: 1899, deathYear: 1950 } ], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); expect(document.body.textContent).toContain('1899'); expect(document.body.textContent).toContain('1950'); }); it('renders ?– for missing birthYear with deathYear set', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true, deathYear: 1950 }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); expect(document.body.textContent).toMatch(/\?–/); }); it('omits the years line when neither birthYear nor deathYear is set', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); expect(document.body.textContent).not.toMatch(/\?–\?/); }); it('calls onSelect when a node is clicked', async () => { const onSelect = vi.fn(); render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect }); const node = document.querySelector('g[role="button"]') as SVGGElement; node.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(onSelect).toHaveBeenCalledWith(ID_A); }); it('handles Enter keypress on node like click', async () => { const onSelect = vi.fn(); render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect }); const node = document.querySelector('g[role="button"]') as SVGGElement; const evt = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }); node.dispatchEvent(evt); expect(onSelect).toHaveBeenCalledWith(ID_A); }); it('handles Space keypress on node like click', async () => { const onSelect = vi.fn(); render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect }); const node = document.querySelector('g[role="button"]') as SVGGElement; node.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); expect(onSelect).toHaveBeenCalledWith(ID_A); }); }); describe('StammbaumTree keyboard pan/zoom (#692)', () => { const renderTree = (onPanZoom: (state: PanZoomState) => void) => render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onPanZoom, onSelect: () => {} }); it('zooms in on "+" and out on "-" by the keyboard step (OQ-002)', async () => { const onPanZoom = vi.fn(); renderTree(onPanZoom); const svg = document.querySelector('svg')!; svg.dispatchEvent(new KeyboardEvent('keydown', { key: '+', bubbles: true })); expect(onPanZoom).toHaveBeenCalledTimes(1); expect(onPanZoom.mock.calls[0][0].z).toBeCloseTo(1.1, 6); onPanZoom.mockClear(); svg.dispatchEvent(new KeyboardEvent('keydown', { key: '-', bubbles: true })); expect(onPanZoom.mock.calls[0][0].z).toBeCloseTo(0.9, 6); }); it('pans right/down on arrow keys (REQ-PAN-004)', async () => { const onPanZoom = vi.fn(); renderTree(onPanZoom); const svg = document.querySelector('svg')!; svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); expect(onPanZoom.mock.calls[0][0].x).toBeGreaterThan(0); onPanZoom.mockClear(); svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); expect(onPanZoom.mock.calls[0][0].y).toBeGreaterThan(0); }); it('pans on a pointer drag and suppresses the trailing node click (US-PAN-001)', async () => { const onPanZoom = vi.fn(); const onSelect = vi.fn(); render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, // Zoomed in so panning is permitted (clampPan allows movement at z>1). panZoom: { x: 0, y: 0, z: 2 }, onPanZoom, onSelect }); const svg = document.querySelector('svg')! as SVGSVGElement; const node = document.querySelector('g[role="button"]') as SVGGElement; const opts = (x: number) => ({ pointerId: 1, clientX: x, clientY: 100, bubbles: true }); svg.dispatchEvent(new PointerEvent('pointerdown', opts(100))); svg.dispatchEvent(new PointerEvent('pointermove', opts(160))); // Assert on the move's emission *before* releasing: inertia starts on // pointerup and could otherwise perturb the last recorded call. expect(onPanZoom).toHaveBeenCalled(); // Dragging right reveals content to the left → pan x decreases. expect(onPanZoom.mock.calls.at(-1)![0].x).toBeLessThan(0); svg.dispatchEvent(new PointerEvent('pointerup', opts(160))); // The synthetic click after a real drag must not select the node. node.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(onSelect).not.toHaveBeenCalled(); }); it('recentres on a node when centreOnId is set, auto-zooming to legible (US-PAN-005)', async () => { const onPanZoom = vi.fn(); render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Anna', familyMember: true }, { id: ID_B, displayName: 'Bertha', familyMember: true } ], edges: [ { id: 'sp', personId: ID_A, relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', relationType: 'SPOUSE_OF' } ], selectedId: null, panZoom: { x: 0, y: 0, z: 0.5 }, centreOnId: ID_A, onPanZoom, onSelect: () => {} }); await vi.waitFor(() => expect(onPanZoom).toHaveBeenCalled()); const view = onPanZoom.mock.calls.at(-1)![0]; // Anna sits left of the two-node midpoint → pan x is negative. expect(view.x).toBeLessThan(0); // Zoomed out below legible → snapped up to 1. expect(view.z).toBe(1); }); it('omits the edge-fade mask at fit (z = 1) (#692)', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); expect(document.querySelector('svg')!.getAttribute('style') ?? '').not.toContain('mask-image'); }); it('applies the edge-fade mask when zoomed past fit (#692)', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 2 }, onSelect: () => {} }); expect(document.querySelector('svg')!.getAttribute('style') ?? '').toContain('mask-image'); }); it('does not call onSelect for other keys', async () => { const onSelect = vi.fn(); render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect }); const node = document.querySelector('g[role="button"]') as SVGGElement; node.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })); expect(onSelect).not.toHaveBeenCalled(); }); it('renders dashed spouse line when toYear is set (divorced)', async () => { render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Anna', familyMember: true }, { id: ID_B, displayName: 'Bertha', familyMember: true } ], edges: [ { id: 'e1', personId: ID_A, relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', relationType: 'SPOUSE_OF', toYear: 1925 } ], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const dashed = Array.from(document.querySelectorAll('line')).filter((l) => l.hasAttribute('stroke-dasharray') ); expect(dashed.length).toBeGreaterThan(0); }); it('renders solid spouse line when no toYear (still married)', async () => { render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Anna', familyMember: true }, { id: ID_B, displayName: 'Bertha', familyMember: true } ], edges: [ { id: 'e1', personId: ID_A, relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', relationType: 'SPOUSE_OF' } ], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const lines = Array.from(document.querySelectorAll('line')); const dashedLines = lines.filter((l) => l.getAttribute('stroke-dasharray')); expect(dashedLines.length).toBe(0); }); it('renders single-parent connector lines when no spouse pair', async () => { const PARENT = '00000000-0000-0000-0000-00000000aaa1'; const CHILD = '00000000-0000-0000-0000-00000000bbb1'; render(StammbaumTree, { nodes: [ { id: PARENT, displayName: 'Parent', familyMember: true }, { id: CHILD, displayName: 'Child', familyMember: true } ], edges: [ { id: 'e-p', personId: PARENT, relatedPersonId: CHILD, personDisplayName: 'Parent', relatedPersonDisplayName: 'Child', relationType: 'PARENT_OF' } ], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const lines = document.querySelectorAll('line'); expect(lines.length).toBeGreaterThanOrEqual(2); }); it('focuses a node and renders the focus ring on focus event', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const node = document.querySelector('g[role="button"]') as SVGGElement; node.dispatchEvent(new FocusEvent('focus', { bubbles: true })); await new Promise((r) => setTimeout(r, 30)); const focusRing = Array.from(document.querySelectorAll('rect')).find( (r) => r.getAttribute('stroke') === 'var(--c-focus-ring)' ); expect(focusRing).toBeDefined(); }); it('removes the focus ring on blur', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const node = document.querySelector('g[role="button"]') as SVGGElement; node.dispatchEvent(new FocusEvent('focus', { bubbles: true })); await new Promise((r) => setTimeout(r, 30)); node.dispatchEvent(new FocusEvent('blur', { bubbles: true })); await new Promise((r) => setTimeout(r, 30)); const focusRing = Array.from(document.querySelectorAll('rect')).find( (r) => r.getAttribute('stroke') === 'var(--c-focus-ring)' ); expect(focusRing).toBeUndefined(); }); it('aria-label includes node displayName and life dates', async () => { render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Anna Schmidt', familyMember: true, birthYear: 1900, deathYear: 1980 } ], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const node = document.querySelector('g[role="button"]'); expect(node?.getAttribute('aria-label')).toContain('Anna Schmidt'); expect(node?.getAttribute('aria-label')).toContain('1900'); }); it('aria-expanded reflects selected state', async () => { render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Anna', familyMember: true }, { id: ID_B, displayName: 'Bertha', familyMember: true } ], edges: [], selectedId: ID_A, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const nodes = document.querySelectorAll('g[role="button"]'); const a = nodes[0] as SVGGElement; const b = nodes[1] as SVGGElement; const aSelected = a.getAttribute('aria-expanded') === 'true'; const bSelected = b.getAttribute('aria-expanded') === 'true'; // Exactly one should be aria-expanded=true (the selected one) expect([aSelected, bSelected].filter(Boolean).length).toBe(1); }); it('accent stripe rect appears only on selected node', async () => { render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Anna', familyMember: true }, { id: ID_B, displayName: 'Bertha', familyMember: true } ], edges: [], selectedId: ID_A, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); const accentRects = Array.from(document.querySelectorAll('rect')).filter( (r) => r.getAttribute('fill') === 'var(--c-accent)' ); expect(accentRects.length).toBe(1); }); }); describe('StammbaumTree generation rail (#689, #692)', () => { const railLabels = () => Array.from(document.querySelectorAll('[role="text"]')).map((el) => el.getAttribute('aria-label') ); it('renders a G{n} label per occupied generation row on the pinned rail', async () => { render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Walter', familyMember: true, generation: 2 }, { id: ID_B, displayName: 'Herbert', familyMember: true, generation: 3 } ], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); await vi.waitFor(() => { const labels = railLabels(); expect(labels).toContain('Generation 2'); expect(labels).toContain('Generation 3'); }); }); it('labels the chip so screen readers announce "Generation" and shows the G{n} glyph', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {} }); await vi.waitFor(() => { const g3 = Array.from(document.querySelectorAll('[role="text"]')).find( (el) => el.getAttribute('aria-label') === 'Generation 3' ); expect(g3).toBeDefined(); expect(g3!.textContent).toMatch(/G\s*3/); }); }); it('keeps showing generation labels on the pinned rail even on mobile (showGutter false)', async () => { // The rail is viewport-independent (the #692 point); only the desktop // stripe underlay is gated on the gutter breakpoint. render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, onSelect: () => {}, showGutter: false }); await vi.waitFor(() => expect(railLabels()).toContain('Generation 3')); }); }); describe('StammbaumTree lineage highlight (#703)', () => { // A three-generation family. Selecting "Vater" highlights his pedigree // (Grossvater, Grossmutter), his descendant (Kind), and the spouses of those // blood people (Mutter, his married-in wife). "Tante" is a collateral sibling // of Vater and must dim. type Edge = { id: string; personId: string; relatedPersonId: string; personDisplayName: string; relatedPersonDisplayName: string; relationType: 'PARENT_OF' | 'SPOUSE_OF'; }; const edge = ( personId: string, relatedPersonId: string, relationType: 'PARENT_OF' | 'SPOUSE_OF' ): Edge => ({ id: `${personId}-${relationType}-${relatedPersonId}`, personId, relatedPersonId, personDisplayName: '', relatedPersonDisplayName: '', relationType }); const NODES = [ { id: 'gf', displayName: 'Grossvater', familyMember: true }, { id: 'gm', displayName: 'Grossmutter', familyMember: true }, { id: 'vater', displayName: 'Vater', familyMember: true }, { id: 'mutter', displayName: 'Mutter', familyMember: true }, { id: 'kind', displayName: 'Kind', familyMember: true }, { id: 'tante', displayName: 'Tante', familyMember: true } ]; const EDGES = [ edge('gf', 'gm', 'SPOUSE_OF'), edge('gf', 'vater', 'PARENT_OF'), edge('gm', 'vater', 'PARENT_OF'), edge('gf', 'tante', 'PARENT_OF'), edge('gm', 'tante', 'PARENT_OF'), edge('vater', 'mutter', 'SPOUSE_OF'), edge('vater', 'kind', 'PARENT_OF'), edge('mutter', 'kind', 'PARENT_OF') ]; // The lineage dim lives on the inner content group (outline + labels); the // card fill renders outside it at full strength so connectors beneath never // bleed through a dimmed node. function nodeOpacity(displayName: string): string | null { const content = nodeContentGroup(displayName); return content.getAttribute('opacity'); } function nodeContentGroup(displayName: string): Element { const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`); if (!g) throw new Error(`No node group rendered for ${displayName}`); const content = g.querySelector('g.lineage-fade'); if (!content) throw new Error(`No content group rendered for ${displayName}`); return content; } /** The opaque card fill is a direct-child rect of the node, outside the dim group. */ function cardFillIsOpaque(displayName: string): boolean { const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`); if (!g) throw new Error(`No node group rendered for ${displayName}`); const fill = g.querySelector(':scope > rect'); if (!fill) throw new Error(`No card-fill rect rendered for ${displayName}`); return fill.getAttribute('opacity') === null && fill.getAttribute('fill-opacity') === null; } const DIM = String(DIMMED_OPACITY); // Connector groups render as direct children; the node content groups // (also .lineage-fade) are nested inside g[role="button"], so the child // combinator scopes cleanly to connectors. function dimmedConnectorCount(): number { return Array.from(document.querySelectorAll('svg > g.lineage-fade')).filter( (g) => g.getAttribute('opacity') === DIM ).length; } it('renders every node at full strength when nothing is selected (AC1)', () => { render(StammbaumTree, { nodes: NODES, edges: EDGES, selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, showGutter: false, onSelect: () => {} }); for (const n of NODES) expect(nodeOpacity(n.displayName)).toBeNull(); }); it('keeps the bloodline + spouses full and dims collaterals when a person is selected (AC2)', () => { render(StammbaumTree, { nodes: NODES, edges: EDGES, selectedId: 'vater', panZoom: { x: 0, y: 0, z: 1 }, showGutter: false, onSelect: () => {} }); // Anchor, ancestors, descendant, and the spouses of blood people stay full. for (const name of ['Vater', 'Grossvater', 'Grossmutter', 'Kind', 'Mutter']) { expect(nodeOpacity(name)).toBeNull(); } // The collateral sibling dims — but its card fill stays opaque so the // connectors drawn beneath it do not show through (the dim is on the // outline + labels only). expect(nodeOpacity('Tante')).toBe(DIM); expect(cardFillIsOpaque('Tante')).toBe(true); }); it('recomputes the highlight for a newly selected person and clears the previous one (AC6)', async () => { const { rerender } = render(StammbaumTree, { nodes: NODES, edges: EDGES, selectedId: 'vater', panZoom: { x: 0, y: 0, z: 1 }, showGutter: false, onSelect: () => {} }); expect(nodeOpacity('Tante')).toBe(DIM); expect(nodeOpacity('Vater')).toBeNull(); // Select Tante: her lineage is now active (her parents stay full), while // Vater's descendant branch (Kind, Mutter) drops out of the active set. await rerender({ nodes: NODES, edges: EDGES, selectedId: 'tante', panZoom: { x: 0, y: 0, z: 1 }, showGutter: false, onSelect: () => {} }); expect(nodeOpacity('Tante')).toBeNull(); expect(nodeOpacity('Kind')).toBe(DIM); expect(nodeOpacity('Mutter')).toBe(DIM); }); it('returns the whole tree to full strength when the selection is cleared (AC7)', async () => { const { rerender } = render(StammbaumTree, { nodes: NODES, edges: EDGES, selectedId: 'vater', panZoom: { x: 0, y: 0, z: 1 }, showGutter: false, onSelect: () => {} }); expect(nodeOpacity('Tante')).toBe(DIM); await rerender({ nodes: NODES, edges: EDGES, selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, showGutter: false, onSelect: () => {} }); for (const n of NODES) expect(nodeOpacity(n.displayName)).toBeNull(); }); it('leaves every connector at full strength when nothing is selected (AC5)', () => { render(StammbaumTree, { nodes: NODES, edges: EDGES, selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, showGutter: false, onSelect: () => {} }); expect(dimmedConnectorCount()).toBe(0); }); it('dims exactly the connector feeding the collateral child at the render layer (AC5)', () => { render(StammbaumTree, { nodes: NODES, edges: EDGES, selectedId: 'vater', panZoom: { x: 0, y: 0, z: 1 }, showGutter: false, onSelect: () => {} }); // Every connector among the bloodline + spouses stays full strength; only // the vertical joining the active parent pair (Grossvater+Grossmutter) to // the dimmed collateral child (Tante) renders at DIMMED_OPACITY. This proves // the render wiring — not just the isConnectorActive predicate — // and exercises the shared parent-pair per-child path. expect(dimmedConnectorCount()).toBe(1); }); }); describe('StammbaumTree keyboard tab order (#718)', () => { // Tab order follows DOM source order, so the node elements // must render in reading order — top generation first, left-to-right within a // row — regardless of the order nodes arrive in the `nodes` array (which is // backend/DB order). Scope to in-SVG role="button" elements: the canvas and // the controls/side panel are also focusable, so a whole-page Tab count would // be brittle (see the issue's testing note). const WALTER = '00000000-0000-0000-0000-0000000000a1'; const EUGENIE = '00000000-0000-0000-0000-0000000000a2'; const CLARA = '00000000-0000-0000-0000-0000000000a3'; const HANS = '00000000-0000-0000-0000-0000000000a4'; // Walter ↔ Eugenie (gen 0); their children Clara + Hans (gen 1). The tidy-tree // layout (#724) orders a couple's run by structural ownership (earliest birth // year, then a deterministic id tie-break), not alphabetically — with no birth // years here Walter (id …a1) owns the run and Eugenie sits to his right. So the // deterministic visual order is Walter, Eugenie (top row) then Clara, Hans. const FAMILY_EDGES = [ { id: 'sp', personId: WALTER, relatedPersonId: EUGENIE, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', relationType: 'SPOUSE_OF' }, { id: 'p1', personId: WALTER, relatedPersonId: CLARA, personDisplayName: 'Walter', relatedPersonDisplayName: 'Clara', relationType: 'PARENT_OF' }, { id: 'p2', personId: EUGENIE, relatedPersonId: CLARA, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Clara', relationType: 'PARENT_OF' }, { id: 'p3', personId: WALTER, relatedPersonId: HANS, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', relationType: 'PARENT_OF' }, { id: 'p4', personId: EUGENIE, relatedPersonId: HANS, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', relationType: 'PARENT_OF' } ]; function nodeLabelsInDomOrder(): string[] { const svg = document.querySelector('svg')!; return Array.from(svg.querySelectorAll('g[role="button"]')).map( (g) => (g.getAttribute('aria-label') ?? '').split(',')[0] ); } it('renders node tab stops in reading order, not backend array order', () => { // Deliberately scrambled (not reading order): Hans (gen 1), Eugenie // (gen 0), Clara (gen 1), Walter (gen 0). Source order alone would tab // gen1 → gen0 → gen1 → gen0. render(StammbaumTree, { nodes: [ { id: HANS, displayName: 'Hans', familyMember: true }, { id: EUGENIE, displayName: 'Eugenie', familyMember: true }, { id: CLARA, displayName: 'Clara', familyMember: true }, { id: WALTER, displayName: 'Walter', familyMember: true } ], edges: FAMILY_EDGES, selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, showGutter: false, onSelect: () => {} }); // Top generation left-to-right, then next generation left-to-right. expect(nodeLabelsInDomOrder()).toEqual(['Walter', 'Eugenie', 'Clara', 'Hans']); }); it('orders tab stops by rendered position regardless of input order', () => { // Self-validating against the actual layout: DOM order of the node groups // must equal the same groups sorted by (y, then x). Independent of the // alphabetical assumption above, so it survives layout-internal changes. render(StammbaumTree, { nodes: [ { id: CLARA, displayName: 'Clara', familyMember: true }, { id: WALTER, displayName: 'Walter', familyMember: true }, { id: HANS, displayName: 'Hans', familyMember: true }, { id: EUGENIE, displayName: 'Eugenie', familyMember: true } ], edges: FAMILY_EDGES, selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, showGutter: false, onSelect: () => {} }); const svg = document.querySelector('svg')!; const placed = Array.from(svg.querySelectorAll('g[role="button"]')).map((g) => { const t = g.getAttribute('transform') ?? ''; const m = t.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/); return { label: (g.getAttribute('aria-label') ?? '').split(',')[0], x: m ? parseFloat(m[1]) : Infinity, y: m ? parseFloat(m[2]) : Infinity }; }); const readingOrder = [...placed].sort((a, b) => a.y - b.y || a.x - b.x); expect(placed.map((n) => n.label)).toEqual(readingOrder.map((n) => n.label)); }); });