diff --git a/frontend/scripts/capture-network-fixture.mjs b/frontend/scripts/capture-network-fixture.mjs index eb91ca18..a4272692 100644 --- a/frontend/scripts/capture-network-fixture.mjs +++ b/frontend/scripts/capture-network-fixture.mjs @@ -56,12 +56,16 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const FIXTURE_PATH = `${HERE}/../src/lib/person/genealogy/__fixtures__/stammbaum.json`; // Sanity floors — calibrated against the canonical dataset (May 2026: 62 -// nodes, 5 generations, 28 SPOUSE_OF edges). The point is catching a silently -// empty backend, not strict size validation; raise these only if the -// canonical graph grows substantially. +// nodes, 5 generations, 28 SPOUSE_OF edges, 1 multi-spouse person). The point +// is catching a silently empty backend or a structurally-regressed snapshot, +// not strict size validation; raise these only if the canonical graph grows +// substantially. The same gates are unit-tested at +// src/lib/person/genealogy/__fixtures__/validateFixture.ts — keep both +// definitions in sync. const MIN_NODES = 50; const MIN_GENERATIONS = 5; const MIN_SPOUSE_OF_EDGES = 1; +const MIN_MULTI_SPOUSE_PERSONS = 1; function parseSetCookies(headers) { const out = new Map(); @@ -112,6 +116,7 @@ function validate(network) { const edges = Array.isArray(network.edges) ? network.edges : []; const spouseEdges = edges.filter((e) => e.relationType === 'SPOUSE_OF'); const generations = new Set(nodes.map((n) => n.generation).filter((g) => g != null)); + const multiSpousePersons = countMultiSpousePersons(spouseEdges); const failures = []; if (nodes.length < MIN_NODES) { @@ -123,6 +128,12 @@ function validate(network) { if (spouseEdges.length < MIN_SPOUSE_OF_EDGES) { failures.push(`expected >= ${MIN_SPOUSE_OF_EDGES} SPOUSE_OF edges, got ${spouseEdges.length}`); } + if (multiSpousePersons < MIN_MULTI_SPOUSE_PERSONS) { + failures.push( + `expected >= ${MIN_MULTI_SPOUSE_PERSONS} person with multiple SPOUSE_OF edges, ` + + `got ${multiSpousePersons}` + ); + } if (failures.length > 0) { throw new Error(`Sanity gates failed:\n - ${failures.join('\n - ')}`); } @@ -131,10 +142,31 @@ function validate(network) { nodes: nodes.length, edges: edges.length, spouseEdges: spouseEdges.length, - generations: [...generations].sort((a, b) => a - b) + generations: [...generations].sort((a, b) => a - b), + multiSpousePersons }; } +function countMultiSpousePersons(spouseEdges) { + const partners = new Map(); + for (const e of spouseEdges) { + if (!e.personId || !e.relatedPersonId) continue; + mapAddToSet(partners, e.personId, e.relatedPersonId); + mapAddToSet(partners, e.relatedPersonId, e.personId); + } + let count = 0; + for (const set of partners.values()) { + if (set.size >= 2) count += 1; + } + return count; +} + +function mapAddToSet(map, key, value) { + const s = map.get(key); + if (s) s.add(value); + else map.set(key, new Set([value])); +} + function writeFixture(network) { mkdirSync(dirname(FIXTURE_PATH), { recursive: true }); writeFileSync(FIXTURE_PATH, JSON.stringify(network, null, '\t') + '\n', 'utf8'); @@ -148,7 +180,7 @@ async function main() { const stats = validate(network); writeFixture(network); console.error( - `Wrote ${FIXTURE_PATH}\n nodes: ${stats.nodes}\n edges: ${stats.edges}\n SPOUSE_OF edges: ${stats.spouseEdges}\n generations: G${stats.generations.join(', G')}` + `Wrote ${FIXTURE_PATH}\n nodes: ${stats.nodes}\n edges: ${stats.edges}\n SPOUSE_OF edges: ${stats.spouseEdges}\n multi-spouse persons: ${stats.multiSpousePersons}\n generations: G${stats.generations.join(', G')}` ); } diff --git a/frontend/src/lib/person/genealogy/__fixtures__/validateFixture.test.ts b/frontend/src/lib/person/genealogy/__fixtures__/validateFixture.test.ts new file mode 100644 index 00000000..de49e8fb --- /dev/null +++ b/frontend/src/lib/person/genealogy/__fixtures__/validateFixture.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { validateFixture } from './validateFixture'; +import canonicalFixture from './stammbaum.json'; + +// The fixture validator is the load-bearing contract for the canonical +// Stammbaum snapshot: every gate here corresponds to an invariant that +// buildLayout.test.ts relies on. Adding or removing a gate without updating +// these tests is the failure we want to catch. + +function networkWithNodes(count: number) { + return { + nodes: Array.from({ length: count }, (_, i) => ({ id: `n${i}`, generation: i % 6 })), + edges: [] + }; +} + +function spouseEdge(a: string, b: string) { + return { + id: `${a}|${b}`, + personId: a, + relatedPersonId: b, + personDisplayName: '', + relatedPersonDisplayName: '', + relationType: 'SPOUSE_OF' + }; +} + +describe('validateFixture', () => { + it('passes_for_the_canonical_fixture', () => { + expect(() => validateFixture(canonicalFixture)).not.toThrow(); + }); + + it('rejects_a_fixture_below_the_min_node_floor', () => { + expect(() => validateFixture(networkWithNodes(10))).toThrow(/>= 50 nodes/); + }); + + it('rejects_a_fixture_with_no_multi_spouse_person', () => { + // 50 nodes, 5 generations, several SPOUSE_OF edges — but every spouse + // edge connects a different pair, so nobody has more than one partner. + // Without the multi-spouse floor this would silently pass and the + // canonical_fixture_assigns_a_position_to_every_node_with_multiple_spouses + // test in buildLayout.test.ts would degrade to vacuous truth. + const nodes = Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, generation: i % 5 })); + const edges = [spouseEdge('n0', 'n1'), spouseEdge('n2', 'n3'), spouseEdge('n4', 'n5')]; + expect(() => validateFixture({ nodes, edges })).toThrow( + />= 1 person with multiple SPOUSE_OF edges/ + ); + }); + + it('accepts_a_fixture_where_one_person_has_multiple_spouse_edges', () => { + const nodes = Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, generation: i % 5 })); + const edges = [spouseEdge('n0', 'n1'), spouseEdge('n0', 'n2')]; + expect(() => validateFixture({ nodes, edges })).not.toThrow(); + }); + + it('counts_multi_spouse_persons_via_either_edge_direction', () => { + const nodes = Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, generation: i % 5 })); + // n5 is the related party in both edges — still counts as multi-spouse. + const edges = [spouseEdge('n1', 'n5'), spouseEdge('n2', 'n5')]; + expect(() => validateFixture({ nodes, edges })).not.toThrow(); + }); +}); diff --git a/frontend/src/lib/person/genealogy/__fixtures__/validateFixture.ts b/frontend/src/lib/person/genealogy/__fixtures__/validateFixture.ts new file mode 100644 index 00000000..b184f382 --- /dev/null +++ b/frontend/src/lib/person/genealogy/__fixtures__/validateFixture.ts @@ -0,0 +1,85 @@ +// Sanity floors for the canonical Stammbaum fixture. Owned by the fixture +// directory rather than the capture script so the contract lives next to the +// JSON it constrains and is reachable from unit tests. +// +// Calibrated against the May-2026 canonical dataset (62 nodes, 5 generations, +// 28 SPOUSE_OF edges, 1 multi-spouse person). The point of these gates is +// catching a silently empty backend or a structurally regressed snapshot, not +// strict size validation; raise them only if the canonical graph grows +// substantially. + +export const MIN_NODES = 50; +export const MIN_GENERATIONS = 5; +export const MIN_SPOUSE_OF_EDGES = 1; +export const MIN_MULTI_SPOUSE_PERSONS = 1; + +type Edge = { relationType?: string; personId?: string; relatedPersonId?: string }; +type Node = { id?: string; generation?: number | null }; +export type NetworkShape = { nodes?: Node[]; edges?: Edge[] }; + +export type FixtureStats = { + nodes: number; + edges: number; + spouseEdges: number; + generations: number[]; + multiSpousePersons: number; +}; + +export function validateFixture(network: NetworkShape): FixtureStats { + const nodes = Array.isArray(network.nodes) ? network.nodes : []; + const edges = Array.isArray(network.edges) ? network.edges : []; + const spouseEdges = edges.filter((e) => e.relationType === 'SPOUSE_OF'); + const generations = new Set(nodes.map((n) => n.generation).filter((g) => g != null)); + const multiSpousePersons = countMultiSpousePersons(spouseEdges); + + const failures: string[] = []; + if (nodes.length < MIN_NODES) { + failures.push(`expected >= ${MIN_NODES} nodes, got ${nodes.length}`); + } + if (generations.size < MIN_GENERATIONS) { + failures.push(`expected >= ${MIN_GENERATIONS} distinct generations, got ${generations.size}`); + } + if (spouseEdges.length < MIN_SPOUSE_OF_EDGES) { + failures.push(`expected >= ${MIN_SPOUSE_OF_EDGES} SPOUSE_OF edges, got ${spouseEdges.length}`); + } + if (multiSpousePersons < MIN_MULTI_SPOUSE_PERSONS) { + // buildLayout.test.ts asserts the multi-spouse property on this + // fixture; a recapture that loses every multi-spouse person would + // silently make that test vacuous. Fail loudly instead. + failures.push( + `expected >= ${MIN_MULTI_SPOUSE_PERSONS} person with multiple SPOUSE_OF edges, ` + + `got ${multiSpousePersons}` + ); + } + if (failures.length > 0) { + throw new Error(`Sanity gates failed:\n - ${failures.join('\n - ')}`); + } + + return { + nodes: nodes.length, + edges: edges.length, + spouseEdges: spouseEdges.length, + generations: [...generations].sort((a, b) => (a as number) - (b as number)) as number[], + multiSpousePersons + }; +} + +function countMultiSpousePersons(spouseEdges: Edge[]): number { + const partners = new Map>(); + for (const e of spouseEdges) { + if (!e.personId || !e.relatedPersonId) continue; + addPartner(partners, e.personId, e.relatedPersonId); + addPartner(partners, e.relatedPersonId, e.personId); + } + let count = 0; + for (const set of partners.values()) { + if (set.size >= 2) count += 1; + } + return count; +} + +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])); +}