Cycle-2 follow-up from Sara. The existing assertion `Math.abs(posA2.x - posB2.x) === NODE_W + COL_GAP` proves adjacency in the current integer-slot packer but would silently pass if a future refactor moved to fractional offsets with a third node squatting at a non-slot x between the spouses. The added loop closes that contract. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
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'];
|
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
|
|
|
const PARENT = '00000000-0000-0000-0000-000000000001';
|
|
const CHILD = '00000000-0000-0000-0000-000000000002';
|
|
const SPOUSE_A = '00000000-0000-0000-0000-000000000003';
|
|
const SPOUSE_B = '00000000-0000-0000-0000-000000000004';
|
|
const NEGATIVE_A = '00000000-0000-0000-0000-000000000005';
|
|
const NEGATIVE_B = '00000000-0000-0000-0000-000000000006';
|
|
const NEGATIVE_C = '00000000-0000-0000-0000-000000000007';
|
|
|
|
function node(id: string, displayName: string, generation: number | null = null): PersonNodeDTO {
|
|
return generation == null
|
|
? { id, displayName, familyMember: true }
|
|
: { id, displayName, familyMember: true, generation };
|
|
}
|
|
|
|
function parentEdge(parentId: string, childId: string, id = parentId + childId): RelationshipDTO {
|
|
return {
|
|
id,
|
|
personId: parentId,
|
|
relatedPersonId: childId,
|
|
personDisplayName: '',
|
|
relatedPersonDisplayName: '',
|
|
relationType: 'PARENT_OF'
|
|
};
|
|
}
|
|
|
|
function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO {
|
|
return {
|
|
id,
|
|
personId: a,
|
|
relatedPersonId: b,
|
|
personDisplayName: '',
|
|
relatedPersonDisplayName: '',
|
|
relationType: 'SPOUSE_OF'
|
|
};
|
|
}
|
|
|
|
function yOf(layout: ReturnType<typeof buildLayout>, id: string): number {
|
|
const p = layout.positions.get(id);
|
|
if (!p) throw new Error(`No position for ${id}`);
|
|
return p.y;
|
|
}
|
|
|
|
describe('buildLayout — generation seeding (#689)', () => {
|
|
it('Herbert Cram regression: two parented G=3 spouses share the same row', () => {
|
|
// Both Herbert (G 3) and Clara (G 3) are parented children of their respective
|
|
// G 2 ancestors. They are spouses. Before #689 the iterative longest-path put
|
|
// Herbert one row deeper than Clara via the spouse-pulldown of his loose parent.
|
|
// With imported generation as a strict seed both render at the same y.
|
|
const layout = buildLayout(
|
|
[node(SPOUSE_A, 'Herbert', 3), node(SPOUSE_B, 'Clara', 3)],
|
|
[spouseEdge(SPOUSE_A, SPOUSE_B)]
|
|
);
|
|
|
|
expect(yOf(layout, SPOUSE_A)).toBe(yOf(layout, SPOUSE_B));
|
|
});
|
|
|
|
it('strict-seed override: imported generation pins rank even when parent edges imply deeper', () => {
|
|
// PARENT has no explicit generation → falls back to 0. CHILD is parented under
|
|
// PARENT but has imported generation = 3. The seeded rank wins; the heuristic
|
|
// must not push CHILD to rank 1.
|
|
const layout = buildLayout(
|
|
[node(PARENT, 'Parent'), node(CHILD, 'Child', 3)],
|
|
[parentEdge(PARENT, CHILD)]
|
|
);
|
|
|
|
expect(yOf(layout, CHILD)).toBe(3 * (NODE_H + ROW_GAP));
|
|
});
|
|
|
|
it('fallback inherits seeded parent rank: G 2 parent → null-gen child lands at rank 3', () => {
|
|
// CHILD has no imported generation. PARENT has generation = 2. The fallback
|
|
// reads PARENT's rank from the unified rank map (2) and computes 2 + 1 = 3.
|
|
const layout = buildLayout(
|
|
[node(PARENT, 'Parent', 2), node(CHILD, 'Child')],
|
|
[parentEdge(PARENT, CHILD)]
|
|
);
|
|
|
|
expect(yOf(layout, CHILD)).toBe(3 * (NODE_H + ROW_GAP));
|
|
});
|
|
|
|
it('normalise is a no-op when all ranks are non-negative', () => {
|
|
// Seeded ranks [3, 4, 5] → y must reflect [3, 4, 5] without any shift.
|
|
const G3 = '00000000-0000-0000-0000-000000000031';
|
|
const G4 = '00000000-0000-0000-0000-000000000032';
|
|
const G5 = '00000000-0000-0000-0000-000000000033';
|
|
const layout = buildLayout(
|
|
[node(G3, 'three', 3), node(G4, 'four', 4), node(G5, 'five', 5)],
|
|
[]
|
|
);
|
|
|
|
expect(yOf(layout, G3)).toBe(3 * (NODE_H + ROW_GAP));
|
|
expect(yOf(layout, G4)).toBe(4 * (NODE_H + ROW_GAP));
|
|
expect(yOf(layout, G5)).toBe(5 * (NODE_H + ROW_GAP));
|
|
});
|
|
|
|
it('normalise shifts negative seeds so min rank becomes 0', () => {
|
|
// Seeded ranks [-1, 0, 1] → after shift they render at [0, 1, 2] y-rows.
|
|
const layout = buildLayout(
|
|
[node(NEGATIVE_A, 'minus-one', -1), node(NEGATIVE_B, 'zero', 0), node(NEGATIVE_C, 'one', 1)],
|
|
[]
|
|
);
|
|
|
|
expect(yOf(layout, NEGATIVE_A)).toBe(0);
|
|
expect(yOf(layout, NEGATIVE_B)).toBe(1 * (NODE_H + ROW_GAP));
|
|
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('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]));
|
|
}
|
|
|
|
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('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);
|
|
|
|
// Tighter contract (Sara's cycle-2 follow-up): no third node may sit
|
|
// at an x strictly between the two spouses on the same y. The integer-
|
|
// slot adjacency check above (==NODE_W+COL_GAP) is correct today but
|
|
// would silently pass if a future layout change introduced fractional
|
|
// offsets and placed a node at a non-slot x between the spouses.
|
|
const minX = Math.min(posA2.x, posB2.x);
|
|
const maxX = Math.max(posA2.x, posB2.x);
|
|
for (const [id, p] of layout.positions) {
|
|
if (id === A2 || id === B2) continue;
|
|
if (p.y !== posA2.y) continue;
|
|
expect(p.x <= minX || p.x >= maxX).toBe(true);
|
|
}
|
|
});
|
|
|
|
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[];
|
|
|
|
// Precondition: this test asserts the *fallback* branch of the
|
|
// multi-spouse sort (fromYear ASC NULLS LAST, displayName ASC), which
|
|
// only collapses to alphabetical-by-displayName when every SPOUSE_OF
|
|
// row is null on fromYear. The day any canonical row gets a year
|
|
// backfilled, this test would silently start asserting year-order;
|
|
// fail fast instead so the maintainer either updates the test or
|
|
// splits into a year-branch / name-branch pair.
|
|
const spouseEdgesWithYear = fixtureEdges.filter(
|
|
(e) => e.relationType === 'SPOUSE_OF' && e.fromYear != null
|
|
);
|
|
expect(
|
|
spouseEdgesWithYear,
|
|
'Precondition violated: a canonical SPOUSE_OF row now carries fromYear. Update this test (or split into year-branch / name-branch).'
|
|
).toHaveLength(0);
|
|
|
|
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;
|
|
}
|
|
});
|
|
});
|