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:
22
frontend/scripts/README.md
Normal file
22
frontend/scripts/README.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# `frontend/scripts/`
|
||||||
|
|
||||||
|
One-off developer utilities. Each script is local-only and never invoked from
|
||||||
|
CI. Re-run intentionally when needed; commit any generated artefacts as a
|
||||||
|
separate, atomic commit.
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `capture-network-fixture.mjs` | Capture the canonical `GET /api/network` response into `src/lib/person/genealogy/__fixtures__/stammbaum.json`. Used by `buildLayout.test.ts`. Re-capture when the production family graph grows a new structural case (new edge type, new marriage configuration). |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
node scripts/capture-network-fixture.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults to `BACKEND_URL=http://localhost:8080` and the dev admin credentials.
|
||||||
|
Override via env vars (`BACKEND_URL`, `CAPTURE_EMAIL`, `CAPTURE_PASSWORD`).
|
||||||
|
|
||||||
|
The script exits non-zero if the captured fixture would be vacuous (fewer than
|
||||||
|
100 nodes, fewer than 5 generations, or zero `SPOUSE_OF` edges).
|
||||||
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);
|
||||||
|
});
|
||||||
25
frontend/src/lib/person/genealogy/__fixtures__/README.md
Normal file
25
frontend/src/lib/person/genealogy/__fixtures__/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# `__fixtures__/`
|
||||||
|
|
||||||
|
Pinned real-data fixtures used by Stammbaum layout tests.
|
||||||
|
|
||||||
|
## `stammbaum.json`
|
||||||
|
|
||||||
|
Snapshot of `GET /api/network` against the canonical Familienarchiv dataset.
|
||||||
|
Captured by `frontend/scripts/capture-network-fixture.mjs`.
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
The fixture is **pinned**, not auto-tracked. Tests assert _structural_
|
||||||
|
properties (e.g. "a person with ≥ 2 spouses exists") rather than identity
|
||||||
|
("Albert has exactly 4 spouses"), so the fixture survives data growth without
|
||||||
|
mechanical edits.
|
||||||
|
|
||||||
|
Re-capture and update the affected tests in a single intentional commit when a
|
||||||
|
new structural case appears in the production graph (new edge type, new
|
||||||
|
marriage configuration, new generation range).
|
||||||
|
|
||||||
|
### PII
|
||||||
|
|
||||||
|
The repository is private and the fixture contains real family names. If the
|
||||||
|
repository ever opens, scrubbing is a one-shot migration commit, not a
|
||||||
|
permanent test-authoring constraint.
|
||||||
1147
frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json
Normal file
1147
frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user