import { describe, it, expect } from 'vitest'; import { buildLayout, NODE_H, ROW_GAP } from './buildLayout'; 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)); }); });