From b36addde22fefed2b043ea7a3a29002d0d604195 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:53:44 +0200 Subject: [PATCH] =?UTF-8?q?test(stammbaum):=20cyclic=20input=20fails=20clo?= =?UTF-8?q?sed=20=E2=80=94=20finite=20layout,=20one=20position=20per=20nod?= =?UTF-8?q?e=20(#724)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An A<->B parent cycle and a founder reaching a re-entrant 3-cycle both return a finite layout (no frozen $derived) with every node placed exactly once. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/buildLayout.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) 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(); + }); +});