content-explorer/ui/lib/graph.test.ts
tegwick b5b6f6bc00 Add a bounded relation graph and knowledge journeys.
CONTENT-WP-0004 keeps outgoing and incoming labels apart, limits the graph to two hops, and stores focus and detail in the URL.

Assistant: grok
Assistant-Session: 01a1531b-6698-7732-8587-f28ea604bcdf
2026-10-19 10:00:20 +02:00

202 lines
9.7 KiB
TypeScript

import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { findEntity, findGraph } from "./catalog";
import {
HOP_BOUND,
exportAdjacency,
memberLinks,
parseGraphSearch,
partitionIncident,
readingPathCrumbs,
selectNeighborhood,
serializeGraphSearch,
type GraphModel,
} from "./graph";
const root = join(process.cwd(), "../examples/dataspaces/systems-thinking");
function loadJson(path: string): {
entities: { id: string; properties?: { glossary?: string; preview?: { title: string; summary: string }; readingPath?: string[] } }[];
relations: { id: string; predicate: string; from: string; to: string }[];
} {
return JSON.parse(readFileSync(path, "utf8"));
}
describe("incident relations", () => {
it("splits feedback into forward and inverse blocks", () => {
const snapshot = loadJson(join(root, "snapshot.json"));
const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")) as {
relationLabels: Record<string, { forward: string; inverse: string }>;
};
const feedback = snapshot.entities.find((entity) => entity.id.endsWith(":feedback"));
expect(feedback).toBeTruthy();
const blocks = partitionIncident(feedback!.id, snapshot.relations, manifest.relationLabels);
const incident = snapshot.relations.filter((relation) => relation.from === feedback!.id || relation.to === feedback!.id);
const listed = [...blocks.outgoing, ...blocks.incoming];
expect(blocks.remainder).toBe(0);
expect(listed.map((relation) => relation.id).sort()).toEqual(incident.map((relation) => relation.id).sort());
expect(new Set(listed.map((relation) => relation.direction)).size).toBe(2);
const represented = blocks.outgoing.find((relation) => relation.predicate === "represented-by");
expect(represented?.wording).toBe(manifest.relationLabels["represented-by"].forward);
expect(represented?.wording).toBe("Represented by");
const modeled = blocks.incoming.find((relation) => relation.predicate === "models");
expect(modeled?.wording).toBe(manifest.relationLabels.models.inverse);
expect(modeled?.wording).not.toBe(manifest.relationLabels.models.forward);
for (const relation of listed) {
const source = incident.find((item) => item.id === relation.id);
expect(source).toBeTruthy();
expect(relation.direction === "out" ? source!.from : source!.to).toBe(feedback!.id);
}
});
it("reports a remainder when incident relations are not all listed", () => {
const snapshot = loadJson(join(root, "snapshot.json"));
const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")) as {
relationLabels: Record<string, { forward: string; inverse: string }>;
};
const feedback = snapshot.entities.find((entity) => entity.id.endsWith(":feedback"))!;
const incident = snapshot.relations.filter((relation) => relation.from === feedback.id || relation.to === feedback.id);
const blocks = partitionIncident(feedback.id, snapshot.relations, manifest.relationLabels, 1);
expect(blocks.outgoing.length + blocks.incoming.length).toBe(1);
expect(blocks.remainder).toBe(incident.length - 1);
expect(blocks.remainder).toBeGreaterThan(0);
});
it("keeps a self-loop in one block", () => {
const blocks = partitionIncident("a", [{ id: "loop", predicate: "related-to", from: "a", to: "a" }], {
"related-to": { forward: "Related to", inverse: "Related from" },
});
expect(blocks.outgoing.map((relation) => relation.id)).toEqual(["loop"]);
expect(blocks.incoming).toEqual([]);
expect(blocks.remainder).toBe(0);
});
});
describe("neighborhood", () => {
it("stops a cycle at the hop bound", () => {
const model: GraphModel = {
nodes: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "c", title: "C" },
{ id: "d", title: "D" },
{ id: "e", title: "E" },
],
edges: [
{ id: "ab", predicate: "p", from: "a", to: "b" },
{ id: "bc", predicate: "p", from: "b", to: "c" },
{ id: "ca", predicate: "p", from: "c", to: "a" },
{ id: "cd", predicate: "p", from: "c", to: "d" },
{ id: "de", predicate: "p", from: "d", to: "e" },
],
};
const bounded = selectNeighborhood(model, "a", 100);
expect(bounded.hops).toBe(HOP_BOUND);
expect(bounded.nodeIds).not.toContain("e");
expect(bounded.edges.map((edge) => edge.id)).not.toContain("de");
expect(bounded.excludedCount).toBe(model.edges.length - bounded.edges.length);
expect(bounded.excludedCount).toBeGreaterThan(0);
const again = selectNeighborhood(model, "a", HOP_BOUND);
expect(again.nodeIds).toEqual(bounded.nodeIds);
expect(again.edges).toEqual(bounded.edges);
});
it("keeps parallel edges and both parents", () => {
const model: GraphModel = {
nodes: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "c", title: "C" },
],
edges: [
{ id: "e1", predicate: "p", from: "a", to: "b" },
{ id: "e2", predicate: "q", from: "a", to: "b" },
{ id: "parent-a", predicate: "parent", from: "a", to: "c" },
{ id: "parent-b", predicate: "parent", from: "b", to: "c" },
],
};
const fromA = selectNeighborhood(model, "a", 1);
const exported = exportAdjacency(fromA);
const parallel = exported.edges.filter((edge) => edge.from === "a" && edge.to === "b");
expect(parallel.map((edge) => edge.id).sort()).toEqual(["e1", "e2"]);
expect(parallel.map((edge) => edge.predicate).sort()).toEqual(["p", "q"]);
expect(new Set(parallel.map((edge) => edge.direction))).toEqual(new Set(["out"]));
const fromC = selectNeighborhood(model, "c", 1);
const parents = exportAdjacency(fromC).edges.filter((edge) => edge.predicate === "parent");
expect(parents.map((edge) => edge.id).sort()).toEqual(["parent-a", "parent-b"]);
expect(fromC.nodeIds).toEqual(expect.arrayContaining(["a", "b", "c"]));
});
it("keeps a disconnected node addressable", () => {
const model: GraphModel = {
nodes: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "solo", title: "Solo" },
],
edges: [{ id: "ab", predicate: "p", from: "a", to: "b" }],
};
expect(model.nodes.map((node) => node.id)).toContain("solo");
const alone = selectNeighborhood(model, "solo", 2);
expect(alone.nodeIds).toEqual(["solo"]);
expect(alone.edges).toEqual([]);
expect(alone.excludedCount).toBe(model.edges.length);
const connected = selectNeighborhood(model, "a", 2);
expect(connected.nodeIds).not.toContain("solo");
expect(connected.excludedCount).toBe(0);
});
it("keeps a synthetic graph inside the hop bound and counts the rest", () => {
const model = JSON.parse(readFileSync(join(process.cwd(), "fixtures/large-graph.json"), "utf8")) as GraphModel;
expect(model.edges.length).toBeGreaterThan(HOP_BOUND);
const one = selectNeighborhood(model, "n0", 1);
const two = selectNeighborhood(model, "n0", HOP_BOUND);
const forced = selectNeighborhood(model, "n0", 50);
expect(two.hops).toBe(HOP_BOUND);
expect(forced).toEqual(two);
expect(two.nodeIds.length).toBeGreaterThan(one.nodeIds.length);
expect(two.nodeIds.length).toBeLessThan(model.nodes.length);
expect(two.nodeIds).toContain("n0");
expect(two.nodeIds).not.toContain("n8");
const inside = new Set(two.nodeIds);
const omitted = model.edges.filter((edge) => !inside.has(edge.from) || !inside.has(edge.to));
expect(two.excludedCount).toBe(omitted.length);
expect(two.edges.length + two.excludedCount).toBe(model.edges.length);
expect(two.excludedCount).toBeGreaterThan(0);
const exported = exportAdjacency(two);
expect(exported.edges.map((edge) => edge.id)).toEqual(two.edges.map((edge) => edge.id));
expect(exported.edges.every((edge) => edge.direction === "out" && edge.predicate.length > 0)).toBe(true);
const solo = selectNeighborhood(model, "solo", 2);
expect(model.nodes.some((node) => node.id === "solo")).toBe(true);
expect(solo.nodeIds).toEqual(["solo"]);
expect(solo.excludedCount).toBe(model.edges.length);
});
});
describe("journeys", () => {
it("keeps reading-path order and collection members", () => {
const steps = readingPathCrumbs(["boundary", "feedback", "diagram"], (id) => ({ title: id, href: `/${id}/` }));
expect(steps.map((step) => step.id)).toEqual(["boundary", "feedback", "diagram"]);
expect(steps[0]?.href).toBe("/boundary/");
const members = memberLinks(["left", "right"], (id) => ({ title: id.toUpperCase(), href: `/${id}/` }));
expect(members.map((member) => member.title)).toEqual(["LEFT", "RIGHT"]);
const feedback = findEntity("systems-thinking", "concept", "feedback");
const snapshot = loadJson(join(root, "snapshot.json"));
const declared = snapshot.entities.find((entity) => entity.id.endsWith(":feedback"))?.properties?.readingPath ?? [];
expect(feedback?.readingPath.map((step) => step.id)).toEqual(declared);
expect(feedback?.collection?.members.length).toBeGreaterThan(1);
expect(feedback?.related.length).toBeGreaterThan(0);
});
it("round-trips focus and detail in the query", () => {
const graph = findGraph("systems-thinking");
expect(graph).not.toBeNull();
const ids = graph!.nodes.map((node) => node.id);
const focus = ids.find((id) => id.endsWith(":feedback")) ?? ids[0];
const detail = ids.find((id) => id.endsWith(":causal-loop")) ?? ids[1];
const search = serializeGraphSearch({ focus, detail, hops: 1 });
expect(parseGraphSearch(search, ids, graph!.defaultFocusId)).toEqual({ focus, detail, hops: 1 });
expect(parseGraphSearch("", ids, graph!.defaultFocusId).focus).toBe(graph!.defaultFocusId);
});
});