Files
familienarchiv/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts
Marcel d1ed9c022f
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m39s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI / Unit & Component Tests (push) Successful in 3m19s
CI / OCR Service Tests (push) Successful in 23s
CI / fail2ban Regex (push) Has been cancelled
CI / Semgrep Security Scan (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
nightly / deploy-staging (push) Successful in 1m55s
test(stammbaum): fix #718 tab-order test for tidy-tree layout (#724)
The #718 keyboard-tab-order test hardcoded the visual order
['Eugenie','Walter','Clara','Hans'] on the assumption that buildLayout
sorts each generation alphabetically. #724 replaced that with the
tidy-tree layout, which orders a couple's run by structural ownership
(earliest birth year, then a deterministic id tie-break) — so Walter
(id …a1) now owns the run and Eugenie renders to his right.

Both PRs were green independently; the stale assertion only surfaced
once #718 and #724 landed together on main. Correct the expected reading
order to ['Walter','Eugenie','Clara','Hans'] and refresh the now-wrong
'alphabetical' comment. The companion self-validating test (DOM order ==
sorted by y,x) already guarded the real property, so only the hardcoded
assertion needed updating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:00:59 +02:00

1211 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <g transform="translate(...)">.
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<string, number>();
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<string, number>();
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 <svg> 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 <g opacity> 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 <g role="button"> 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));
});
});