From 4d030a9176acdde1e0d712b13b0db6309c1c44b4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 10 May 2026 08:15:27 +0200 Subject: [PATCH] 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 --- .../genealogy/StammbaumTree.svelte.test.ts | 303 +++++++++++++++++- 1 file changed, 302 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts index 7bf0ef1b..aa5c087a 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts @@ -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); + }); +});