test+feat(stammbaum): merge sibling blocks across same-rank spouse edge (#361)
AC2 — intra-family marriage. When two parented persons at the same imported generation are spouses but live in separate sibling blocks (each under their own parent), the block-packer used to leave them split, drawing a long spouse line that crossed through any intervening siblings. The new step 3.5 detects that case, moves the focal members to the join boundary (A's spouse rightmost in A's block, B's spouse leftmost in B's), and concatenates B's members onto A's; the combined block centres on the average of the two parents' midpoints. Latent against today's data (no intra-family marriage in the canonical fixture); covered by a synthetic two-family scenario in buildLayout.test.ts. Packer growth stays comfortably under Markus's 80-LoC extraction threshold, so packBlocks.ts is not yet warranted. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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 canonicalFixture from '../__fixtures__/stammbaum.json';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
@@ -234,6 +234,40 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
|
|||||||
expect(x1925).toBeLessThan(xNull);
|
expect(x1925).toBeLessThan(xNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('intra_family_marriage_places_both_spouses_adjacent_across_sibling_blocks', () => {
|
||||||
|
// AC2 (#361). Two parented persons at the same imported generation,
|
||||||
|
// each in a separate sibling block under their own parent, marry each
|
||||||
|
// other. Before the fix the block-packer left them split, drawing a
|
||||||
|
// long spouse line across an intervening sibling. After the fix the
|
||||||
|
// two blocks merge with the spouses sitting on the join boundary.
|
||||||
|
const A1 = '00000000-0000-0000-0000-0000000000d1';
|
||||||
|
const B1 = '00000000-0000-0000-0000-0000000000d2';
|
||||||
|
const A2 = '00000000-0000-0000-0000-0000000000d3';
|
||||||
|
const A3 = '00000000-0000-0000-0000-0000000000d4';
|
||||||
|
const B2 = '00000000-0000-0000-0000-0000000000d5';
|
||||||
|
|
||||||
|
const layout = buildLayout(
|
||||||
|
[
|
||||||
|
node(A1, 'A1', 0),
|
||||||
|
node(B1, 'B1', 0),
|
||||||
|
node(A2, 'A2', 1),
|
||||||
|
node(A3, 'A3', 1),
|
||||||
|
node(B2, 'B2', 1)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
parentEdge(A1, A2, 'p1'),
|
||||||
|
parentEdge(A1, A3, 'p2'),
|
||||||
|
parentEdge(B1, B2, 'p3'),
|
||||||
|
spouseEdge(A2, B2, 'sp')
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const posA2 = layout.positions.get(A2)!;
|
||||||
|
const posB2 = layout.positions.get(B2)!;
|
||||||
|
expect(posA2.y).toBe(posB2.y);
|
||||||
|
expect(Math.abs(posA2.x - posB2.x)).toBe(NODE_W + COL_GAP);
|
||||||
|
});
|
||||||
|
|
||||||
it('canonical_fixture_multi_spouse_falls_through_to_displayName_when_no_fromYear', () => {
|
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
|
// Real-data assertion: 0 of 28 SPOUSE_OF rows in the canonical fixture
|
||||||
// have fromYear populated, so the sort collapses to alphabetical by
|
// have fromYear populated, so the sort collapses to alphabetical by
|
||||||
|
|||||||
@@ -277,6 +277,41 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
|
|||||||
}
|
}
|
||||||
for (const key of removed) blocksByKey.delete(key);
|
for (const key of removed) blocksByKey.delete(key);
|
||||||
|
|
||||||
|
// Step 3.5 (#361 AC2): Intra-family marriage. Two parented members at
|
||||||
|
// the same rank in different sibling blocks who marry each other are
|
||||||
|
// merged into one block — A's siblings on the left, the spouses on
|
||||||
|
// the join boundary, B's siblings on the right — so the spouse line
|
||||||
|
// stays short and no other node sits between them.
|
||||||
|
const mergedKeys = new Set<string>();
|
||||||
|
for (const [aKey, aBlock] of blocksByKey) {
|
||||||
|
if (aKey.startsWith('__loose__')) continue;
|
||||||
|
if (mergedKeys.has(aKey)) continue;
|
||||||
|
for (const aMember of aBlock.members) {
|
||||||
|
if (!aMember.parented) continue;
|
||||||
|
const partners = spousePairs.get(aMember.id);
|
||||||
|
if (!partners) continue;
|
||||||
|
for (const partnerId of partners) {
|
||||||
|
const partnerLookup = memberLookup.get(partnerId);
|
||||||
|
if (!partnerLookup || !partnerLookup.parented) continue;
|
||||||
|
if (partnerLookup.key === aKey) continue;
|
||||||
|
if (partnerLookup.key.startsWith('__loose__')) continue;
|
||||||
|
if (mergedKeys.has(partnerLookup.key)) continue;
|
||||||
|
const bBlock = blocksByKey.get(partnerLookup.key)!;
|
||||||
|
// A's spouse to the right-most slot in A's block; B's spouse
|
||||||
|
// to the left-most slot in B's block; then concatenate.
|
||||||
|
moveMemberToEnd(aBlock.members, aMember.id);
|
||||||
|
moveMemberToStart(bBlock.members, partnerId);
|
||||||
|
for (const m of bBlock.members) {
|
||||||
|
memberLookup.set(m.id, { key: aKey, parented: m.parented });
|
||||||
|
}
|
||||||
|
aBlock.members.push(...bBlock.members);
|
||||||
|
aBlock.center = (aBlock.center + bBlock.center) / 2;
|
||||||
|
mergedKeys.add(partnerLookup.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of mergedKeys) blocksByKey.delete(key);
|
||||||
|
|
||||||
// Step 4: centre each block on its anchor (parented members) and pack.
|
// Step 4: centre each block on its anchor (parented members) and pack.
|
||||||
const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
|
const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
|
||||||
let cursorRight = -Infinity;
|
let cursorRight = -Infinity;
|
||||||
@@ -346,3 +381,17 @@ function mapAddToSet<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
|
|||||||
function spousePairKey(a: string, b: string): string {
|
function spousePairKey(a: string, b: string): string {
|
||||||
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveMemberToEnd<T extends { id: string }>(members: T[], id: string) {
|
||||||
|
const idx = members.findIndex((m) => m.id === id);
|
||||||
|
if (idx < 0 || idx === members.length - 1) return;
|
||||||
|
const [m] = members.splice(idx, 1);
|
||||||
|
members.push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveMemberToStart<T extends { id: string }>(members: T[], id: string) {
|
||||||
|
const idx = members.findIndex((m) => m.id === id);
|
||||||
|
if (idx <= 0) return;
|
||||||
|
const [m] = members.splice(idx, 1);
|
||||||
|
members.unshift(m);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user