test+feat(stammbaum): preserve all SPOUSE_OF edges in layout (#361)

Switches spousePairs from Map<string, string> to Map<string, Set<string>>
so multi-spouse persons (canonical case: Albert de Gruyter, 4 marriages)
keep every partner instead of losing the earlier .set() values.

The behavioural discriminator (now exercised by
attaches_loose_multi_spouse_to_parented_partner_when_edge_order_clobbers)
is a loose person with both a parented and a loose spouse: the old map
clobbered to whichever edge landed last, so the loose-placement step could
miss the parented partner and merge the focal node into the wrong block.

Also closes the robustness gap NullX flagged: SPOUSE_OF edges referencing
IDs outside allNodes are dropped at ingestion instead of leaking into the
spouse-pulldown loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-28 20:03:52 +02:00
parent 36bd7e0414
commit 2a462d0a7c
2 changed files with 166 additions and 32 deletions

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { buildLayout, NODE_H, ROW_GAP } from './buildLayout';
import { buildLayout, NODE_W, NODE_H, COL_GAP, ROW_GAP } from './buildLayout';
import canonicalFixture from '../__fixtures__/stammbaum.json';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
@@ -111,3 +112,106 @@ describe('buildLayout — generation seeding (#689)', () => {
expect(yOf(layout, NEGATIVE_C)).toBe(2 * (NODE_H + ROW_GAP));
});
});
describe('buildLayout — multi-spouse + intra-family marriage (#361)', () => {
const FOCAL = '00000000-0000-0000-0000-000000000010';
const SPOUSE_X = '00000000-0000-0000-0000-000000000011';
const SPOUSE_Y = '00000000-0000-0000-0000-000000000012';
const UNKNOWN = '00000000-0000-0000-0000-000000000099';
it('preserves_both_marriages_when_person_has_two_SPOUSE_OF_edges', () => {
// Before #361 the spouse map was Map<string, string>; the second
// .set() clobbered the first, so a person with N spouses (Albert de
// Gruyter, 4) silently lost N-1 of them. Asserting that every spouse
// has a layout position is the minimal presence check.
const layout = buildLayout(
[node(FOCAL, 'Focal', 3), node(SPOUSE_X, 'Alice'), node(SPOUSE_Y, 'Bob')],
[spouseEdge(FOCAL, SPOUSE_X, 'fx'), spouseEdge(FOCAL, SPOUSE_Y, 'fy')]
);
expect(layout.positions.get(FOCAL)).toBeDefined();
expect(layout.positions.get(SPOUSE_X)).toBeDefined();
expect(layout.positions.get(SPOUSE_Y)).toBeDefined();
});
it('ignores_SPOUSE_OF_edge_with_unknown_relatedPersonId', () => {
// Robustness gap flagged by NullX during persona review: an edge
// pointing to a UUID not in the node list must not crash buildLayout
// and must not introduce a phantom node into the positions map.
const buildIt = () =>
buildLayout([node(FOCAL, 'Focal', 3)], [spouseEdge(FOCAL, UNKNOWN, 'fu')]);
expect(buildIt).not.toThrow();
const layout = buildIt();
expect(layout.positions.get(FOCAL)).toBeDefined();
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
// (4 marriages); the assertion stays valid as the graph grows.
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 multi = [...partners.entries()].filter(([, set]) => set.size >= 2);
expect(multi.length).toBeGreaterThan(0);
for (const [id, set] of multi) {
expect(layout.positions.get(id)).toBeDefined();
for (const partnerId of set) {
expect(layout.positions.get(partnerId)).toBeDefined();
}
}
});
});
function addPartner(map: Map<string, Set<string>>, key: string, value: string) {
const s = map.get(key);
if (s) s.add(value);
else map.set(key, new Set([value]));
}