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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user