Sara/Elicit flagged that AC5 was proven only at the isConnectorActive predicate level. Add render-layer assertions: no connector group carries a dim opacity when nothing is selected, and selecting Vater dims exactly the vertical feeding the collateral child Tante. Exercises the shared parent-pair per-child <g opacity> wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1089 lines
34 KiB
TypeScript
1089 lines
34 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
||
import { render } from 'vitest-browser-svelte';
|
||
import StammbaumTree from './StammbaumTree.svelte';
|
||
import type { PanZoomState } from './panZoom';
|
||
import { DIMMED_OPACITY } from './layout/highlightLineage';
|
||
|
||
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 viewBox centre is the content centroid shifted by the pan
|
||
// offset (at pan {0,0} the centre sits on the centroid — see the test
|
||
// below). This avoids hard-coding the layout's absolute coordinates.
|
||
const c = rectsCentroid(svg);
|
||
expect(x + w / 2 - c.x).toBeCloseTo(100, 6);
|
||
expect(y + h / 2 - c.y).toBeCloseTo(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: (state: PanZoomState) => void) =>
|
||
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('pans on a pointer drag and suppresses the trailing node click (US-PAN-001)', async () => {
|
||
const onPanZoom = vi.fn();
|
||
const onSelect = vi.fn();
|
||
render(StammbaumTree, {
|
||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||
edges: [],
|
||
selectedId: null,
|
||
// Zoomed in so panning is permitted (clampPan allows movement at z>1).
|
||
panZoom: { x: 0, y: 0, z: 2 },
|
||
onPanZoom,
|
||
onSelect
|
||
});
|
||
const svg = document.querySelector('svg')! as SVGSVGElement;
|
||
const node = document.querySelector('g[role="button"]') as SVGGElement;
|
||
|
||
const opts = (x: number) => ({ pointerId: 1, clientX: x, clientY: 100, bubbles: true });
|
||
svg.dispatchEvent(new PointerEvent('pointerdown', opts(100)));
|
||
svg.dispatchEvent(new PointerEvent('pointermove', opts(160)));
|
||
|
||
// Assert on the move's emission *before* releasing: inertia starts on
|
||
// pointerup and could otherwise perturb the last recorded call.
|
||
expect(onPanZoom).toHaveBeenCalled();
|
||
// Dragging right reveals content to the left → pan x decreases.
|
||
expect(onPanZoom.mock.calls.at(-1)![0].x).toBeLessThan(0);
|
||
|
||
svg.dispatchEvent(new PointerEvent('pointerup', opts(160)));
|
||
|
||
// The synthetic click after a real drag must not select the node.
|
||
node.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
expect(onSelect).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('recentres on a node when centreOnId is set, auto-zooming to legible (US-PAN-005)', async () => {
|
||
const onPanZoom = vi.fn();
|
||
render(StammbaumTree, {
|
||
nodes: [
|
||
{ id: ID_A, displayName: 'Anna', familyMember: true },
|
||
{ id: ID_B, displayName: 'Bertha', familyMember: true }
|
||
],
|
||
edges: [
|
||
{
|
||
id: 'sp',
|
||
personId: ID_A,
|
||
relatedPersonId: ID_B,
|
||
personDisplayName: 'Anna',
|
||
relatedPersonDisplayName: 'Bertha',
|
||
relationType: 'SPOUSE_OF'
|
||
}
|
||
],
|
||
selectedId: null,
|
||
panZoom: { x: 0, y: 0, z: 0.5 },
|
||
centreOnId: ID_A,
|
||
onPanZoom,
|
||
onSelect: () => {}
|
||
});
|
||
|
||
await vi.waitFor(() => expect(onPanZoom).toHaveBeenCalled());
|
||
const view = onPanZoom.mock.calls.at(-1)![0];
|
||
// Anna sits left of the two-node midpoint → pan x is negative.
|
||
expect(view.x).toBeLessThan(0);
|
||
// Zoomed out below legible → snapped up to 1.
|
||
expect(view.z).toBe(1);
|
||
});
|
||
|
||
it('omits the edge-fade mask at fit (z = 1) (#692)', async () => {
|
||
render(StammbaumTree, {
|
||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||
edges: [],
|
||
selectedId: null,
|
||
panZoom: { x: 0, y: 0, z: 1 },
|
||
onSelect: () => {}
|
||
});
|
||
expect(document.querySelector('svg')!.getAttribute('style') ?? '').not.toContain('mask-image');
|
||
});
|
||
|
||
it('applies the edge-fade mask when zoomed past fit (#692)', async () => {
|
||
render(StammbaumTree, {
|
||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||
edges: [],
|
||
selectedId: null,
|
||
panZoom: { x: 0, y: 0, z: 2 },
|
||
onSelect: () => {}
|
||
});
|
||
expect(document.querySelector('svg')!.getAttribute('style') ?? '').toContain('mask-image');
|
||
});
|
||
|
||
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 rail (#689, #692)', () => {
|
||
const railLabels = () =>
|
||
Array.from(document.querySelectorAll('[role="text"]')).map((el) =>
|
||
el.getAttribute('aria-label')
|
||
);
|
||
|
||
it('renders a G{n} label per occupied generation row on the pinned rail', async () => {
|
||
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: () => {}
|
||
});
|
||
|
||
await vi.waitFor(() => {
|
||
const labels = railLabels();
|
||
expect(labels).toContain('Generation 2');
|
||
expect(labels).toContain('Generation 3');
|
||
});
|
||
});
|
||
|
||
it('labels the chip so screen readers announce "Generation" and shows the G{n} glyph', async () => {
|
||
render(StammbaumTree, {
|
||
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
||
edges: [],
|
||
selectedId: null,
|
||
panZoom: { x: 0, y: 0, z: 1 },
|
||
onSelect: () => {}
|
||
});
|
||
|
||
await vi.waitFor(() => {
|
||
const g3 = Array.from(document.querySelectorAll('[role="text"]')).find(
|
||
(el) => el.getAttribute('aria-label') === 'Generation 3'
|
||
);
|
||
expect(g3).toBeDefined();
|
||
expect(g3!.textContent).toMatch(/G\s*3/);
|
||
});
|
||
});
|
||
|
||
it('keeps showing generation labels on the pinned rail even on mobile (showGutter false)', async () => {
|
||
// The rail is viewport-independent (the #692 point); only the desktop
|
||
// stripe underlay is gated on the gutter breakpoint.
|
||
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
|
||
});
|
||
|
||
await vi.waitFor(() => expect(railLabels()).toContain('Generation 3'));
|
||
});
|
||
});
|
||
|
||
describe('StammbaumTree lineage highlight (#703)', () => {
|
||
// A three-generation family. Selecting "Vater" highlights his pedigree
|
||
// (Grossvater, Grossmutter), his descendant (Kind), and the spouses of those
|
||
// blood people (Mutter, his married-in wife). "Tante" is a collateral sibling
|
||
// of Vater and must dim.
|
||
type Edge = {
|
||
id: string;
|
||
personId: string;
|
||
relatedPersonId: string;
|
||
personDisplayName: string;
|
||
relatedPersonDisplayName: string;
|
||
relationType: 'PARENT_OF' | 'SPOUSE_OF';
|
||
};
|
||
const edge = (
|
||
personId: string,
|
||
relatedPersonId: string,
|
||
relationType: 'PARENT_OF' | 'SPOUSE_OF'
|
||
): Edge => ({
|
||
id: `${personId}-${relationType}-${relatedPersonId}`,
|
||
personId,
|
||
relatedPersonId,
|
||
personDisplayName: '',
|
||
relatedPersonDisplayName: '',
|
||
relationType
|
||
});
|
||
|
||
const NODES = [
|
||
{ id: 'gf', displayName: 'Grossvater', familyMember: true },
|
||
{ id: 'gm', displayName: 'Grossmutter', familyMember: true },
|
||
{ id: 'vater', displayName: 'Vater', familyMember: true },
|
||
{ id: 'mutter', displayName: 'Mutter', familyMember: true },
|
||
{ id: 'kind', displayName: 'Kind', familyMember: true },
|
||
{ id: 'tante', displayName: 'Tante', familyMember: true }
|
||
];
|
||
const EDGES = [
|
||
edge('gf', 'gm', 'SPOUSE_OF'),
|
||
edge('gf', 'vater', 'PARENT_OF'),
|
||
edge('gm', 'vater', 'PARENT_OF'),
|
||
edge('gf', 'tante', 'PARENT_OF'),
|
||
edge('gm', 'tante', 'PARENT_OF'),
|
||
edge('vater', 'mutter', 'SPOUSE_OF'),
|
||
edge('vater', 'kind', 'PARENT_OF'),
|
||
edge('mutter', 'kind', 'PARENT_OF')
|
||
];
|
||
|
||
// The lineage dim lives on the inner content group (outline + labels); the
|
||
// card fill renders outside it at full strength so connectors beneath never
|
||
// bleed through a dimmed node.
|
||
function nodeOpacity(displayName: string): string | null {
|
||
const content = nodeContentGroup(displayName);
|
||
return content.getAttribute('opacity');
|
||
}
|
||
function nodeContentGroup(displayName: string): Element {
|
||
const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
|
||
if (!g) throw new Error(`No node group rendered for ${displayName}`);
|
||
const content = g.querySelector('g.lineage-fade');
|
||
if (!content) throw new Error(`No content group rendered for ${displayName}`);
|
||
return content;
|
||
}
|
||
/** The opaque card fill is a direct-child rect of the node, outside the dim group. */
|
||
function cardFillIsOpaque(displayName: string): boolean {
|
||
const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
|
||
if (!g) throw new Error(`No node group rendered for ${displayName}`);
|
||
const fill = g.querySelector(':scope > rect');
|
||
if (!fill) throw new Error(`No card-fill rect rendered for ${displayName}`);
|
||
return fill.getAttribute('opacity') === null && fill.getAttribute('fill-opacity') === null;
|
||
}
|
||
const DIM = String(DIMMED_OPACITY);
|
||
|
||
// Connector groups render as direct <svg> children; the node content groups
|
||
// (also .lineage-fade) are nested inside g[role="button"], so the child
|
||
// combinator scopes cleanly to connectors.
|
||
function dimmedConnectorCount(): number {
|
||
return Array.from(document.querySelectorAll('svg > g.lineage-fade')).filter(
|
||
(g) => g.getAttribute('opacity') === DIM
|
||
).length;
|
||
}
|
||
|
||
it('renders every node at full strength when nothing is selected (AC1)', () => {
|
||
render(StammbaumTree, {
|
||
nodes: NODES,
|
||
edges: EDGES,
|
||
selectedId: null,
|
||
panZoom: { x: 0, y: 0, z: 1 },
|
||
showGutter: false,
|
||
onSelect: () => {}
|
||
});
|
||
|
||
for (const n of NODES) expect(nodeOpacity(n.displayName)).toBeNull();
|
||
});
|
||
|
||
it('keeps the bloodline + spouses full and dims collaterals when a person is selected (AC2)', () => {
|
||
render(StammbaumTree, {
|
||
nodes: NODES,
|
||
edges: EDGES,
|
||
selectedId: 'vater',
|
||
panZoom: { x: 0, y: 0, z: 1 },
|
||
showGutter: false,
|
||
onSelect: () => {}
|
||
});
|
||
|
||
// Anchor, ancestors, descendant, and the spouses of blood people stay full.
|
||
for (const name of ['Vater', 'Grossvater', 'Grossmutter', 'Kind', 'Mutter']) {
|
||
expect(nodeOpacity(name)).toBeNull();
|
||
}
|
||
// The collateral sibling dims — but its card fill stays opaque so the
|
||
// connectors drawn beneath it do not show through (the dim is on the
|
||
// outline + labels only).
|
||
expect(nodeOpacity('Tante')).toBe(DIM);
|
||
expect(cardFillIsOpaque('Tante')).toBe(true);
|
||
});
|
||
|
||
it('recomputes the highlight for a newly selected person and clears the previous one (AC6)', async () => {
|
||
const { rerender } = render(StammbaumTree, {
|
||
nodes: NODES,
|
||
edges: EDGES,
|
||
selectedId: 'vater',
|
||
panZoom: { x: 0, y: 0, z: 1 },
|
||
showGutter: false,
|
||
onSelect: () => {}
|
||
});
|
||
expect(nodeOpacity('Tante')).toBe(DIM);
|
||
expect(nodeOpacity('Vater')).toBeNull();
|
||
|
||
// Select Tante: her lineage is now active (her parents stay full), while
|
||
// Vater's descendant branch (Kind, Mutter) drops out of the active set.
|
||
await rerender({
|
||
nodes: NODES,
|
||
edges: EDGES,
|
||
selectedId: 'tante',
|
||
panZoom: { x: 0, y: 0, z: 1 },
|
||
showGutter: false,
|
||
onSelect: () => {}
|
||
});
|
||
expect(nodeOpacity('Tante')).toBeNull();
|
||
expect(nodeOpacity('Kind')).toBe(DIM);
|
||
expect(nodeOpacity('Mutter')).toBe(DIM);
|
||
});
|
||
|
||
it('returns the whole tree to full strength when the selection is cleared (AC7)', async () => {
|
||
const { rerender } = render(StammbaumTree, {
|
||
nodes: NODES,
|
||
edges: EDGES,
|
||
selectedId: 'vater',
|
||
panZoom: { x: 0, y: 0, z: 1 },
|
||
showGutter: false,
|
||
onSelect: () => {}
|
||
});
|
||
expect(nodeOpacity('Tante')).toBe(DIM);
|
||
|
||
await rerender({
|
||
nodes: NODES,
|
||
edges: EDGES,
|
||
selectedId: null,
|
||
panZoom: { x: 0, y: 0, z: 1 },
|
||
showGutter: false,
|
||
onSelect: () => {}
|
||
});
|
||
for (const n of NODES) expect(nodeOpacity(n.displayName)).toBeNull();
|
||
});
|
||
|
||
it('leaves every connector at full strength when nothing is selected (AC5)', () => {
|
||
render(StammbaumTree, {
|
||
nodes: NODES,
|
||
edges: EDGES,
|
||
selectedId: null,
|
||
panZoom: { x: 0, y: 0, z: 1 },
|
||
showGutter: false,
|
||
onSelect: () => {}
|
||
});
|
||
expect(dimmedConnectorCount()).toBe(0);
|
||
});
|
||
|
||
it('dims exactly the connector feeding the collateral child at the render layer (AC5)', () => {
|
||
render(StammbaumTree, {
|
||
nodes: NODES,
|
||
edges: EDGES,
|
||
selectedId: 'vater',
|
||
panZoom: { x: 0, y: 0, z: 1 },
|
||
showGutter: false,
|
||
onSelect: () => {}
|
||
});
|
||
// Every connector among the bloodline + spouses stays full strength; only
|
||
// the vertical joining the active parent pair (Grossvater+Grossmutter) to
|
||
// the dimmed collateral child (Tante) renders at DIMMED_OPACITY. This proves
|
||
// the <g opacity> render wiring — not just the isConnectorActive predicate —
|
||
// and exercises the shared parent-pair per-child path.
|
||
expect(dimmedConnectorCount()).toBe(1);
|
||
});
|
||
});
|