Files
familienarchiv/frontend/src/lib/person/genealogy/layout/familyForest.test.ts
Marcel add619d81d feat(stammbaum): order siblings/branches by birthYear NULLS LAST, displayName, id (#724)
Net-new ordering coverage: roots and every unit's children sort by birthYear
ASC (undated last), then displayName, then stable id — so horizontal x never
depends on Map iteration order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00

142 lines
5.0 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { pickStructuralOwner, buildFamilyForest, type Unit } from './familyForest';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type Person = { id: string; birthYear?: number };
function person(id: string, opts: { birthYear?: number; generation?: number } = {}): PersonNodeDTO {
const n: PersonNodeDTO = { id, displayName: id, familyMember: true };
if (opts.birthYear != null) n.birthYear = opts.birthYear;
if (opts.generation != null) n.generation = opts.generation;
return n;
}
function parent(p: string, c: string): RelationshipDTO {
return {
id: `${p}>${c}`,
personId: p,
relatedPersonId: c,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'PARENT_OF'
};
}
function spouse(a: string, b: string, fromYear?: number): RelationshipDTO {
return {
id: `${a}~${b}`,
personId: a,
relatedPersonId: b,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF',
...(fromYear != null ? { fromYear } : {})
};
}
// Find the unit (anywhere in the forest) whose primary id matches.
function findUnit(roots: Unit[], primaryId: string): Unit | undefined {
for (const r of roots) {
if (r.id === primaryId) return r;
const found = findUnit(r.children, primaryId);
if (found) return found;
}
return undefined;
}
describe('pickStructuralOwner', () => {
const p = (id: string, birthYear?: number): Person => ({ id, birthYear });
it('picks the earlier-born spouse as structural owner', () => {
expect(pickStructuralOwner(p('a', 1900), p('b', 1920))).toBe('a');
expect(pickStructuralOwner(p('a', 1920), p('b', 1900))).toBe('b');
});
it('sorts a missing birthYear last (the dated spouse owns)', () => {
expect(pickStructuralOwner(p('a'), p('b', 1900))).toBe('b');
expect(pickStructuralOwner(p('a', 1900), p('b'))).toBe('a');
});
it('breaks ties on stable id when birth years match or are both missing', () => {
expect(pickStructuralOwner(p('zzz', 1900), p('aaa', 1900))).toBe('aaa');
expect(pickStructuralOwner(p('zzz'), p('aaa'))).toBe('aaa');
});
});
describe('buildFamilyForest — loose-spouse absorption', () => {
it('absorbs a parentless spouse into the partner run; their child anchors to the couple', () => {
// A (founder) ⚭ S (married-in, no parents). Their child C. S has no
// ancestor subtree of its own, but C still hangs off the couple.
const forest = buildFamilyForest(
[
person('A', { birthYear: 1900 }),
person('S', { birthYear: 1905 }),
person('C', { birthYear: 1930 })
],
[spouse('A', 'S'), parent('A', 'C'), parent('S', 'C')]
);
// One root unit (A), with S absorbed — S is not its own root.
expect(forest.roots.map((r) => r.id)).toEqual(['A']);
const a = findUnit(forest.roots, 'A')!;
expect(a.members).toEqual(['A', 'S']);
// The child anchors through the couple unit.
expect(a.children.map((u) => u.id)).toEqual(['C']);
// S is parentless, so no displaced cross-link.
expect(forest.crossLinks).toEqual([]);
});
it('keeps all marriages of a multi-spouse founder in one run (marriage-year order)', () => {
// Albert-like: one founder, three parentless wives. All absorbed into the
// founder run, ordered by marriage year NULLS LAST then displayName/id.
const forest = buildFamilyForest(
[
person('alb', { birthYear: 1829 }),
person('w1925', { birthYear: 1900 }),
person('wNull', { birthYear: 1901 }),
person('w1910', { birthYear: 1902 })
],
[spouse('alb', 'w1925', 1925), spouse('alb', 'wNull'), spouse('alb', 'w1910', 1910)]
);
expect(forest.roots.map((r) => r.id)).toEqual(['alb']);
const alb = findUnit(forest.roots, 'alb')!;
// Founder first, then spouses by marriage year (1910, 1925, null last).
expect(alb.members).toEqual(['alb', 'w1910', 'w1925', 'wNull']);
});
});
describe('buildFamilyForest — sibling/branch ordering', () => {
it('orders children by birthYear ASC, NULLS LAST, then displayName, then id', () => {
// Provided out of order; the comparator must reorder to 1910, 1920, then
// the two undated by displayName/id (NULLS LAST).
const undatedB: PersonNodeDTO = { id: 'u-b', displayName: 'Zoe', familyMember: true };
const undatedA: PersonNodeDTO = { id: 'u-a', displayName: 'Anna', familyMember: true };
const forest = buildFamilyForest(
[
person('P', { birthYear: 1880 }),
person('c1920', { birthYear: 1920 }),
undatedB,
person('c1910', { birthYear: 1910 }),
undatedA
],
[parent('P', 'c1920'), parent('P', 'u-b'), parent('P', 'c1910'), parent('P', 'u-a')]
);
const p = findUnit(forest.roots, 'P')!;
// 1910, 1920 (dated ASC), then undated by displayName (Anna < Zoe).
expect(p.children.map((u) => u.id)).toEqual(['c1910', 'c1920', 'u-a', 'u-b']);
});
it('orders roots by the same rule', () => {
const forest = buildFamilyForest(
[person('late', { birthYear: 1900 }), person('early', { birthYear: 1850 })],
[]
);
expect(forest.roots.map((r) => r.id)).toEqual(['early', 'late']);
});
});