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; }; 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; }; 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); }); });