test+feat(stammbaum): order multi-spouses by fromYear then displayName (#361)

Replaces the alternating-side insertOnRight rule with a sort-and-splice
that places every loose spouse to the right of the parented focal in
(fromYear ASC NULLS LAST, displayName ASC) order. Mirrored in step 3 for
the all-loose chained merge so Albert de Gruyter's four marriages land
in deterministic alphabetical order today (no fromYear populated in the
canonical dataset) and switch automatically to year-order as the
transcription pipeline backfills marriage years.

PersonNodeDTO carries only displayName, not parsed first/last names, so
the tiebreaker uses displayName rather than the (lastName, firstName)
key in the original UX brief. The canonical alphabetical order matches
in both schemes — the rule activates the moment a multi-spouse case has
mixed display-name patterns.

Retires the temporary commit-3 scaffold
`attaches_loose_multi_spouse_to_parented_partner_when_edge_order_clobbers`
which became position-arithmetic-equivalent under the new right-of-focal
rule; the two new sort tests are stronger discriminators for the same
behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-28 20:14:23 +02:00
parent 2a462d0a7c
commit 557f37be54
2 changed files with 148 additions and 55 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildLayout, NODE_W, NODE_H, COL_GAP, ROW_GAP } from './buildLayout';
import { buildLayout, NODE_H, ROW_GAP } from './buildLayout';
import canonicalFixture from '../__fixtures__/stammbaum.json';
import type { components } from '$lib/generated/api';
@@ -147,43 +147,6 @@ describe('buildLayout — multi-spouse + intra-family marriage (#361)', () => {
expect(layout.positions.get(UNKNOWN)).toBeUndefined();
});
it('attaches_loose_multi_spouse_to_parented_partner_when_edge_order_clobbers', () => {
// The behavioural discriminator for the Map<string,string> -> Map<string,
// Set<string>> shape change. LF (loose) has two spouses: PARENTED
// (parented under an ancestor) and OTHER (loose). The PARENTED edge is
// inserted before the OTHER edge. Under the old map, the second .set()
// clobbered the first, so LF's recorded spouse was OTHER and LF fell
// into the "no parented spouse" branch — merged with OTHER far from
// PARENTED. Under the Set map, both spouses are retained and the
// loose-placement step picks the parented one, putting LF adjacent to
// PARENTED in the parented sibling block while OTHER stays alone.
const G0_ANCESTOR = '00000000-0000-0000-0000-0000000000b1';
const PARENTED = '00000000-0000-0000-0000-0000000000b2';
const LOOSE_FOCAL = '00000000-0000-0000-0000-0000000000b3';
const OTHER = '00000000-0000-0000-0000-0000000000b4';
const layout = buildLayout(
[
node(G0_ANCESTOR, 'Ancestor', 0),
node(PARENTED, 'Parented', 1),
node(LOOSE_FOCAL, 'LooseFocal', 1),
node(OTHER, 'OtherLoose', 1)
],
[
parentEdge(G0_ANCESTOR, PARENTED),
spouseEdge(LOOSE_FOCAL, PARENTED, 'lf-pt'),
spouseEdge(LOOSE_FOCAL, OTHER, 'lf-oth')
]
);
const posLF = layout.positions.get(LOOSE_FOCAL)!;
const posOTH = layout.positions.get(OTHER)!;
// With the fix, OTHER is isolated in its own loose block (far from LF).
// With the bug, LF and OTHER are merged into a dual-loose block (one
// node-width + col-gap apart).
expect(Math.abs(posLF.x - posOTH.x)).toBeGreaterThan(NODE_W + COL_GAP);
});
it('canonical_fixture_assigns_a_position_to_every_node_with_multiple_spouses', () => {
// Real-data structural assertion against the canonical Stammbaum
// snapshot. Today the only multi-spouse case is Albert de Gruyter
@@ -215,3 +178,94 @@ function addPartner(map: Map<string, Set<string>>, key: string, value: string) {
if (s) s.add(value);
else map.set(key, new Set([value]));
}
describe('buildLayout — multi-spouse ordering (#361)', () => {
const PARENT = '00000000-0000-0000-0000-0000000000c0';
const FOCAL = '00000000-0000-0000-0000-0000000000c1';
const SPOUSE_1925 = '00000000-0000-0000-0000-0000000000c2';
const SPOUSE_NULL = '00000000-0000-0000-0000-0000000000c3';
const SPOUSE_1910 = '00000000-0000-0000-0000-0000000000c4';
function spouseEdgeWithYear(
a: string,
b: string,
fromYear: number | undefined,
id = a + b
): RelationshipDTO {
return { ...spouseEdge(a, b, id), fromYear };
}
it('multi_spouses_ordered_by_fromYear_then_displayName', () => {
// Synthetic year-branch exercise. Focal X is parented (under PARENT)
// at G=1, with three loose spouses at years 1925, null, 1910. After
// the sort, the order to the right of X is: 1910, 1925, null —
// earliest first, NULLS LAST, displayName tiebreaker.
const layout = buildLayout(
[
node(PARENT, 'P', 0),
node(FOCAL, 'Focal', 1),
// Names chosen so alphabetical order does NOT match the
// year-sort order — otherwise the test couldn't tell the
// two sort keys apart.
node(SPOUSE_1925, 'Alpha'),
node(SPOUSE_NULL, 'Beta'),
node(SPOUSE_1910, 'Gamma')
],
[
parentEdge(PARENT, FOCAL),
spouseEdgeWithYear(FOCAL, SPOUSE_1925, 1925, 'ya'),
spouseEdgeWithYear(FOCAL, SPOUSE_NULL, undefined, 'yn'),
spouseEdgeWithYear(FOCAL, SPOUSE_1910, 1910, 'yg')
]
);
const pos = (id: string) => layout.positions.get(id)!;
const xFocal = pos(FOCAL).x;
const x1910 = pos(SPOUSE_1910).x;
const x1925 = pos(SPOUSE_1925).x;
const xNull = pos(SPOUSE_NULL).x;
// All spouses sit to the right of focal …
expect(x1910).toBeGreaterThan(xFocal);
expect(x1925).toBeGreaterThan(xFocal);
expect(xNull).toBeGreaterThan(xFocal);
// … in year-sort order.
expect(x1910).toBeLessThan(x1925);
expect(x1925).toBeLessThan(xNull);
});
it('canonical_fixture_multi_spouse_falls_through_to_displayName_when_no_fromYear', () => {
// Real-data assertion: 0 of 28 SPOUSE_OF rows in the canonical fixture
// have fromYear populated, so the sort collapses to alphabetical by
// displayName for the only multi-spouse person (Albert de Gruyter).
const fixtureNodes = canonicalFixture.nodes as unknown as PersonNodeDTO[];
const fixtureEdges = canonicalFixture.edges as unknown as RelationshipDTO[];
const layout = buildLayout(fixtureNodes, fixtureEdges);
const partners = new Map<string, Set<string>>();
for (const e of fixtureEdges) {
if (e.relationType !== 'SPOUSE_OF') continue;
addPartner(partners, e.personId, e.relatedPersonId);
addPartner(partners, e.relatedPersonId, e.personId);
}
const [multiPersonId, multiPartnerSet] =
[...partners.entries()].find(([, set]) => set.size >= 3) ?? [];
expect(multiPersonId).toBeDefined();
if (!multiPersonId || !multiPartnerSet) return;
const focalX = layout.positions.get(multiPersonId)!.x;
const partnerNames = new Map(
fixtureNodes.filter((n) => multiPartnerSet.has(n.id)).map((n) => [n.id, n.displayName])
);
// Spouses ordered alphabetically by displayName, all to the right of focal.
const sorted = [...multiPartnerSet].sort((a, b) =>
(partnerNames.get(a) ?? '').localeCompare(partnerNames.get(b) ?? '')
);
let prevX = focalX;
for (const id of sorted) {
const x = layout.positions.get(id)!.x;
expect(x).toBeGreaterThan(prevX);
prevX = x;
}
});
});