test+fix(stammbaum): capture script floors >= 1 multi-spouse person (#361)
@Markus + @Tobias + @Sara on PR #693: the multi-spouse property is load-bearing for buildLayout.test.ts (canonical_fixture_assigns_a_position _to_every_node_with_multiple_spouses + canonical_fixture_multi_spouse _falls_through_to_displayName_when_no_fromYear). A recapture against a dataset that lost every multi-spouse person would silently degrade those tests to vacuous truth. Add MIN_MULTI_SPOUSE_PERSONS=1 to the capture-script sanity gates. Extract the validator into a unit-testable TS module next to the fixture; the .mjs script keeps its inline copy (one-file local utility) but the contract is now covered by validateFixture.test.ts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -56,12 +56,16 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|||||||
const FIXTURE_PATH = `${HERE}/../src/lib/person/genealogy/__fixtures__/stammbaum.json`;
|
const FIXTURE_PATH = `${HERE}/../src/lib/person/genealogy/__fixtures__/stammbaum.json`;
|
||||||
|
|
||||||
// Sanity floors — calibrated against the canonical dataset (May 2026: 62
|
// Sanity floors — calibrated against the canonical dataset (May 2026: 62
|
||||||
// nodes, 5 generations, 28 SPOUSE_OF edges). The point is catching a silently
|
// nodes, 5 generations, 28 SPOUSE_OF edges, 1 multi-spouse person). The point
|
||||||
// empty backend, not strict size validation; raise these only if the
|
// is catching a silently empty backend or a structurally-regressed snapshot,
|
||||||
// canonical graph grows substantially.
|
// 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_NODES = 50;
|
||||||
const MIN_GENERATIONS = 5;
|
const MIN_GENERATIONS = 5;
|
||||||
const MIN_SPOUSE_OF_EDGES = 1;
|
const MIN_SPOUSE_OF_EDGES = 1;
|
||||||
|
const MIN_MULTI_SPOUSE_PERSONS = 1;
|
||||||
|
|
||||||
function parseSetCookies(headers) {
|
function parseSetCookies(headers) {
|
||||||
const out = new Map();
|
const out = new Map();
|
||||||
@@ -112,6 +116,7 @@ function validate(network) {
|
|||||||
const edges = Array.isArray(network.edges) ? network.edges : [];
|
const edges = Array.isArray(network.edges) ? network.edges : [];
|
||||||
const spouseEdges = edges.filter((e) => e.relationType === 'SPOUSE_OF');
|
const spouseEdges = edges.filter((e) => e.relationType === 'SPOUSE_OF');
|
||||||
const generations = new Set(nodes.map((n) => n.generation).filter((g) => g != null));
|
const generations = new Set(nodes.map((n) => n.generation).filter((g) => g != null));
|
||||||
|
const multiSpousePersons = countMultiSpousePersons(spouseEdges);
|
||||||
|
|
||||||
const failures = [];
|
const failures = [];
|
||||||
if (nodes.length < MIN_NODES) {
|
if (nodes.length < MIN_NODES) {
|
||||||
@@ -123,6 +128,12 @@ function validate(network) {
|
|||||||
if (spouseEdges.length < MIN_SPOUSE_OF_EDGES) {
|
if (spouseEdges.length < MIN_SPOUSE_OF_EDGES) {
|
||||||
failures.push(`expected >= ${MIN_SPOUSE_OF_EDGES} SPOUSE_OF edges, got ${spouseEdges.length}`);
|
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) {
|
if (failures.length > 0) {
|
||||||
throw new Error(`Sanity gates failed:\n - ${failures.join('\n - ')}`);
|
throw new Error(`Sanity gates failed:\n - ${failures.join('\n - ')}`);
|
||||||
}
|
}
|
||||||
@@ -131,10 +142,31 @@ function validate(network) {
|
|||||||
nodes: nodes.length,
|
nodes: nodes.length,
|
||||||
edges: edges.length,
|
edges: edges.length,
|
||||||
spouseEdges: spouseEdges.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) {
|
function writeFixture(network) {
|
||||||
mkdirSync(dirname(FIXTURE_PATH), { recursive: true });
|
mkdirSync(dirname(FIXTURE_PATH), { recursive: true });
|
||||||
writeFileSync(FIXTURE_PATH, JSON.stringify(network, null, '\t') + '\n', 'utf8');
|
writeFileSync(FIXTURE_PATH, JSON.stringify(network, null, '\t') + '\n', 'utf8');
|
||||||
@@ -148,7 +180,7 @@ async function main() {
|
|||||||
const stats = validate(network);
|
const stats = validate(network);
|
||||||
writeFixture(network);
|
writeFixture(network);
|
||||||
console.error(
|
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')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, Set<string>>();
|
||||||
|
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<string, Set<string>>, key: string, value: string) {
|
||||||
|
const s = map.get(key);
|
||||||
|
if (s) s.add(value);
|
||||||
|
else map.set(key, new Set([value]));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user