chore(stammbaum): add /api/network capture script + canonical fixture (#361)
Local-only developer utility that authenticates against the running backend, captures the current /api/network snapshot, and writes it to src/lib/person/genealogy/__fixtures__/stammbaum.json. Sanity gates exit non-zero on a vacuous capture (< 50 nodes, < 5 generations, 0 SPOUSE_OF edges). Fixture and script land together so the fixture is reproducible from the script that generated it. Captured snapshot: 62 nodes, 43 edges, 28 SPOUSE_OF (0 with fromYear), generations G0-G4. Albert de Gruyter is the canonical multi-spouse case with 4 marriages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
121
frontend/scripts/capture-network-fixture.mjs
Normal file
121
frontend/scripts/capture-network-fixture.mjs
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/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';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080';
|
||||
const EMAIL = process.env.CAPTURE_EMAIL ?? 'admin@familyarchive.local';
|
||||
const PASSWORD = process.env.CAPTURE_PASSWORD ?? 'admin123';
|
||||
|
||||
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.
|
||||
const MIN_NODES = 50;
|
||||
const MIN_GENERATIONS = 5;
|
||||
const MIN_SPOUSE_OF_EDGES = 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 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 (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)
|
||||
};
|
||||
}
|
||||
|
||||
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 generations: G${stats.generations.join(', G')}`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user