From 445d2c6eaa2390d339ae523a95b74adf980063f9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:52:01 +0200 Subject: [PATCH] test(stammbaum): layout is deterministic under input reordering (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeded Fisher-Yates permutation of nodes and edges yields byte-identical positions — confirms every comparator ends in a stable id and nothing relies on Map iteration order. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/buildLayout.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index 82d8f11e..8a654003 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -614,3 +614,40 @@ describe('buildLayout — ancestor centring invariant (#724)', () => { expect(widest).toBeLessThanOrEqual(OLD_FULL_CANVAS / 4); }); }); + +// Deterministic Fisher–Yates using a seeded mulberry32 PRNG (never Math.random, +// which would make the determinism test itself non-reproducible). +function seededShuffle(input: T[], seed: number): T[] { + let s = seed >>> 0; + const rand = () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + const out = input.slice(); + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +describe('buildLayout — determinism (#724)', () => { + const fixtureNodes = canonicalFixture.nodes as unknown as PersonNodeDTO[]; + const fixtureEdges = canonicalFixture.edges as unknown as RelationshipDTO[]; + + it('produces identical positions regardless of node/edge input order', () => { + const base = buildLayout(fixtureNodes, fixtureEdges); + const shuffled = buildLayout( + seededShuffle(fixtureNodes, 1337), + seededShuffle(fixtureEdges, 4242) + ); + + expect(shuffled.positions.size).toBe(base.positions.size); + for (const [id, p] of base.positions) { + expect(shuffled.positions.get(id), `position for ${id} is order-independent`).toEqual(p); + } + }); +});