Files
familienarchiv/frontend/scripts/capture-network-fixture.mjs
Marcel 585f28cd23 refactor(stammbaum): single source of truth for findAc3Candidates (#361)
Extracts the AC3 revisit-trigger predicate into a plain .mjs module both
the Node-run capture script and the TypeScript validator import directly.
Removes the line-for-line duplicate (and its "keep both in sync" comment)
that Felix + Markus flagged in cycle-3 review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:15:02 +02:00

215 lines
7.1 KiB
JavaScript

#!/usr/bin/env node
// Local-only. Never invoked from CI. Re-run intentionally; commit the
// resulting JSON in one atomic commit.
//
// Captures the current /api/network response into the canonical fixture used
// by buildLayout.test.ts. Asserts a minimum shape so a silently-empty backend
// can't write a vacuous fixture.
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import { findAc3Candidates } from '../src/lib/person/genealogy/__fixtures__/findAc3Candidates.mjs';
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080';
const EMAIL = process.env.CAPTURE_EMAIL;
const PASSWORD = process.env.CAPTURE_PASSWORD;
// Preflight guards: this script writes the canonical Stammbaum fixture from
// a *running* backend with admin-shaped credentials. Two slips would be
// silent disasters — running with default creds against staging/prod, or
// running with a typo'd BACKEND_URL that happens to resolve. Refuse both
// before sending a single byte.
preflight();
function preflight() {
const failures = [];
if (!EMAIL) {
failures.push('CAPTURE_EMAIL must be set explicitly (no default).');
}
if (!PASSWORD) {
failures.push('CAPTURE_PASSWORD must be set explicitly (no default).');
}
if (!isLocalhost(BACKEND_URL)) {
failures.push(
`BACKEND_URL must point at localhost / 127.0.0.1 (got: ${BACKEND_URL}). ` +
'This script is local-only.'
);
}
if (failures.length > 0) {
console.error('Preflight failed:');
for (const f of failures) console.error(` - ${f}`);
process.exit(2);
}
}
function isLocalhost(url) {
try {
const host = new URL(url).hostname;
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
} catch {
return false;
}
}
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, 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 matching gates in
// src/lib/person/genealogy/__fixtures__/validateFixture.ts cover unit-test
// coverage; the floors are intentionally duplicated as numeric constants
// (the AC3 revisit predicate, by contrast, now lives in one shared module).
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();
const raw = headers.getSetCookie?.() ?? [];
for (const line of raw) {
const [pair] = line.split(';');
const eq = pair.indexOf('=');
if (eq < 0) continue;
out.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
return out;
}
function serialiseCookies(jar) {
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
async function login(jar) {
const xsrf = randomUUID();
jar.set('XSRF-TOKEN', xsrf);
const res = await fetch(`${BACKEND_URL}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': xsrf,
Cookie: serialiseCookies(jar)
},
body: JSON.stringify({ email: EMAIL, password: PASSWORD })
});
if (!res.ok) {
throw new Error(`Login failed: ${res.status} ${await res.text()}`);
}
for (const [k, v] of parseSetCookies(res.headers)) jar.set(k, v);
}
async function fetchNetwork(jar) {
const res = await fetch(`${BACKEND_URL}/api/network`, {
headers: { Cookie: serialiseCookies(jar) }
});
if (!res.ok) {
throw new Error(`GET /api/network failed: ${res.status} ${await res.text()}`);
}
return res.json();
}
function validate(network) {
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 = [];
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) {
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 - 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');
}
async function main() {
const jar = new Map();
console.error(`Capturing /api/network from ${BACKEND_URL} as ${EMAIL} ...`);
await login(jar);
const network = await fetchNetwork(jar);
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 multi-spouse persons: ${stats.multiSpousePersons}\n generations: G${stats.generations.join(', G')}`
);
warnIfAc3Reachable(network);
}
function warnIfAc3Reachable(network) {
const candidates = findAc3Candidates(network);
if (candidates.length === 0) return;
// Soft, non-blocking. The ADR-026 deferral decision says: revisit the
// dagre adoption choice as soon as the canonical fixture starts to
// contain a parented unseeded spouse. This warning is that signal —
// fail-open so a recapture still writes the fixture (the human needs
// to see the data to decide), but loud enough that nobody can miss it.
console.error('');
console.error(
`⚠ AC3 revisit trigger reached (ADR-026): ${candidates.length} unseeded ` +
'person(s) with a seeded parent AND a SPOUSE_OF edge in the captured graph.'
);
console.error(` Candidates: ${candidates.join(', ')}`);
console.error(
' Action: re-evaluate the dagre adoption deferral. See ADR-026 §Notes ' +
'for the assessor + cadence.'
);
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});