test(genealogy): expand StammbaumTree node-rendering branch coverage

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>
This commit is contained in:
Marcel
2026-05-10 08:15:27 +02:00
committed by marcel
parent 6050773da5
commit 2694db3f28

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte';
@@ -347,3 +347,304 @@ describe('StammbaumTree viewBox', () => {
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);
});
});