Two distinct bugs surfaced once a 3-generation tree was loaded
(Walter+Eugenie → Hans+Clara, Hans married to Hilde with child Lili):
1. Generation BFS was non-iterative. Hilde was visited as a "root"
first, assigning Lili = gen 1, then Hilde was pulled to gen 1 to
match her spouse Hans — but Lili's depth was never recomputed,
leaving her on the same row as her parents. Replaced the BFS with
an iterative longest-path assignment that re-runs (max parent gen
+ 1) and the spouse-shared-row rule together until stable.
2. No spouse adjacency. Hilde (no parents in the graph) ended up in
her own block on the far left, with Hans + Clara to her right and
the spouse line drawn straight across Clara's box. Replaced the
per-parent-set grouping with a block model:
- sibling-blocks group children of the same parent set
- loose spouses attach on the outer edge of their partner's block
- dual-loose spouse pairs merge into one 2-person block
- each block is centred so its parented members' average sits
exactly under the parent midpoint, keeping all connectors at 90°
Adds a regression test for the full Walter/Eugenie/Hans/Clara/Hilde/
Lili scenario (Lili in a deeper row, Hans+Hilde adjacent, no slanted
segments) and rewrites the viewBox tests to be position-agnostic via
a rect-centroid helper that reads the per-node `<g transform>`.
Tracked the eventual move to dagre (multi-marriage / cross-cousin /
~50+ nodes) in #361.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
350 lines
10 KiB
TypeScript
350 lines
10 KiB
TypeScript
import { describe, it, expect } 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);
|
|
});
|
|
});
|