+/- 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>
804 lines
24 KiB
TypeScript
804 lines
24 KiB
TypeScript
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);
|
||
});
|
||
});
|