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>
This commit is contained in:
Marcel
2026-06-04 13:21:17 +02:00
committed by marcel
parent a46c3b416b
commit add619d81d
2 changed files with 44 additions and 0 deletions

View File

@@ -108,3 +108,34 @@ describe('buildFamilyForest — loose-spouse absorption', () => {
expect(alb.members).toEqual(['alb', 'w1910', 'w1925', 'wNull']); 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']);
});
});

View File

@@ -174,6 +174,19 @@ export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO
else unitObj.get(hp)!.children.push(unitObj.get(p)!); else unitObj.get(hp)!.children.push(unitObj.get(p)!);
} }
// Sibling/branch order: birthYear ASC NULLS LAST → displayName → id. Applied
// to roots and to every unit's children so x never depends on Map order.
const branchOrder = (u: Unit, v: Unit) => {
const a = byId.get(u.id)!;
const b = byId.get(v.id)!;
const ya = a.birthYear ?? Number.POSITIVE_INFINITY;
const yb = b.birthYear ?? Number.POSITIVE_INFINITY;
if (ya !== yb) return ya - yb;
return a.displayName.localeCompare(b.displayName) || (u.id < v.id ? -1 : 1);
};
roots.sort(branchOrder);
for (const u of unitObj.values()) u.children.sort(branchOrder);
// --- Cross-links: displaced parent → absorbed-spouse edges. --- // --- Cross-links: displaced parent → absorbed-spouse edges. ---
const crossLinks: CrossLink[] = []; const crossLinks: CrossLink[] = [];
for (const k of absorbedInto.keys()) { for (const k of absorbedInto.keys()) {