Adds selected-node primary fill, birth/death year combinations, node click and Enter/Space/other-key handling, dashed/solid spouse line, single-parent connector, focus ring on focus + blur, aria labels and aria-expanded reflection, accent stripe on selected node. 13 new tests covering ~30 branches in the node-render path. Refs #496. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
651 lines
19 KiB
TypeScript
651 lines
19 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('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<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('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);
|
||
});
|
||
});
|
||
|
||
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,
|
||
zoom: 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,
|
||
zoom: 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,
|
||
zoom: 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,
|
||
zoom: 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,
|
||
zoom: 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,
|
||
zoom: 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,
|
||
zoom: 1,
|
||
onSelect
|
||
});
|
||
|
||
const node = document.querySelector('g[role="button"]') as SVGGElement;
|
||
node.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
|
||
expect(onSelect).toHaveBeenCalledWith(ID_A);
|
||
});
|
||
|
||
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,
|
||
zoom: 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,
|
||
zoom: 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,
|
||
zoom: 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,
|
||
zoom: 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,
|
||
zoom: 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,
|
||
zoom: 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,
|
||
zoom: 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,
|
||
zoom: 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,
|
||
zoom: 1,
|
||
onSelect: () => {}
|
||
});
|
||
|
||
const accentRects = Array.from(document.querySelectorAll('rect')).filter(
|
||
(r) => r.getAttribute('fill') === 'var(--c-accent)'
|
||
);
|
||
expect(accentRects.length).toBe(1);
|
||
});
|
||
});
|