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, 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; 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>(); 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>, 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>(); 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; } }); });