diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index 8a654003..a17fb183 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -651,3 +651,49 @@ describe('buildLayout — determinism (#724)', () => { } }); }); + +describe('buildLayout — termination + once-only on cyclic input (#724)', () => { + // buildLayout runs inside a $derived, so non-termination = a frozen tab. + // User-entered data can form parent cycles; the layout must fail closed — + // produce *a* finite layout, every node placed exactly once. + const A = '00000000-0000-0000-0000-0000000000a1'; + const B = '00000000-0000-0000-0000-0000000000a2'; + const C = '00000000-0000-0000-0000-0000000000a3'; + + it('A↔B parent cycle returns a finite layout with one position per node', () => { + let layout!: ReturnType; + expect(() => { + layout = buildLayout( + [node(A, 'A'), node(B, 'B')], + [parentEdge(A, B, 'ab'), parentEdge(B, A, 'ba')] + ); + }).not.toThrow(); + + expect(layout.positions.size).toBe(2); + expect(layout.positions.get(A)).toBeDefined(); + expect(layout.positions.get(B)).toBeDefined(); + }); + + it('two founders both reaching a re-entrant 3-cycle terminate with every node placed', () => { + // F is a founder parenting A; A→B→C→A is a parent cycle, and C also + // re-enters via F. The visited/once-only guarantees keep it finite. + const F = '00000000-0000-0000-0000-0000000000a0'; + let layout!: ReturnType; + expect(() => { + layout = buildLayout( + [node(F, 'F'), node(A, 'A'), node(B, 'B'), node(C, 'C')], + [ + parentEdge(F, A, 'fa'), + parentEdge(A, B, 'ab'), + parentEdge(B, C, 'bc'), + parentEdge(C, A, 'ca') + ] + ); + }).not.toThrow(); + + // Every node is placed exactly once (Map keys are unique by construction; + // the count equality proves none were dropped or duplicated). + expect(layout.positions.size).toBe(4); + for (const id of [F, A, B, C]) expect(layout.positions.get(id)).toBeDefined(); + }); +});