import { describe, it, expect } from 'vitest'; import { render } from 'vitest-browser-svelte'; import StammbaumTree from './StammbaumTree.svelte'; 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('uses the minimum size and centers a single node', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], edges: [], selectedId: null, zoom: 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, zoom: 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, zoom: 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, zoom: 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('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, zoom: 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); }); });