Files
familienarchiv/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts
Marcel da1984b916 feat(stammbaum): keyboard pan/zoom on the canvas (#692)
+/- zoom by the fixed step and arrow keys pan by a tenth of the visible
extent, emitted via onPanZoom. Provides the keyboard-only alternative path
required by NFR-A11Y-002. Nodes keep their own Enter/Space selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:39:55 +02:00

804 lines
24 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';
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 origin is shifted by the pan offset.
const unpannedX = -(1200 / 2 - 160 / 2); // single 160-wide node centred
expect(x).toBeCloseTo(unpannedX + 100, 6);
expect(y).toBeCloseTo(-(800 / 2 - 56 / 2) + 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: ReturnType<typeof vi.fn>) =>
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('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 gutter (#689)', () => {
it('renders a G{n} label per occupied generation row when at least one node carries generation', async () => {
// showGutter overrides the matchMedia detection so the test never
// depends on the vitest-browser iframe viewport width.
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: () => {},
showGutter: true
});
const labels = Array.from(document.querySelectorAll('g[role="text"]')).map((g) =>
g.getAttribute('aria-label')
);
expect(labels).toContain('Generation 2');
expect(labels).toContain('Generation 3');
});
it('wraps the visible G3 text inside an aria-labelled group so screen readers announce "Generation"', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {},
showGutter: true
});
const g3 = Array.from(document.querySelectorAll('g[role="text"]')).find(
(g) => g.getAttribute('aria-label') === 'Generation 3'
);
expect(g3).toBeDefined();
expect(g3!.querySelector('text')!.textContent).toMatch(/G\s*3/);
});
it('omits the gutter when showGutter is false (mobile breakpoint case)', async () => {
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
});
const labelGroups = Array.from(document.querySelectorAll('g[role="text"]'));
expect(labelGroups).toHaveLength(0);
});
});