import { describe, expect, it } from "bun:test"; import { exportCypher, exportGraph, exportGraphMl, exportObsidian, } from "../export"; import type { KnowledgeGraph } from "../graph"; import type { WikiArticle } from "../types"; function fixture(): KnowledgeGraph { const nodes = new Map(); nodes.set("entity:Greeter", { id: "entity:Greeter", type: "entity", label: "Greeter", file: "src/greet.ts", pageRank: 0.42, }); nodes.set("entity:VERSION", { id: "entity:VERSION", type: "entity", label: "VERSION", file: "src/greet.ts", pageRank: 0.08, }); nodes.set("module:core", { id: "module:core", type: "module", label: "core", pageRank: 0.9, }); const edges = [ { source: "entity:Greeter", target: "entity:VERSION", type: "references", weight: 1, }, { source: "module:core", target: "entity:Greeter", type: "contains", weight: 2, }, ]; const adjacency = new Map(); for (const [id] of nodes) adjacency.set(id, new Set()); for (const e of edges) { adjacency.get(e.source)?.add(e.target); adjacency.get(e.target)?.add(e.source); } return { nodes, edges: edges as never, adjacency }; } const NO_ARTICLES: WikiArticle[] = []; describe("exportCypher", () => { it("produces deterministic BEGIN/COMMIT-wrapped output", () => { const a = exportCypher(fixture(), NO_ARTICLES); const b = exportCypher(fixture(), NO_ARTICLES); expect(a).toBe(b); expect(a).toStartWith("// Maina knowledge graph — Cypher export\n"); expect(a).toContain("BEGIN"); expect(a).toContain("COMMIT"); expect(a).toContain("CREATE (:Entity {"); expect(a).toContain("CREATE (:Module {"); expect(a).toMatch(/CREATE \(a\)-\[:REFERENCES \{weight: 1\}\]->\(b\)/); expect(a).toMatch(/CREATE \(a\)-\[:CONTAINS \{weight: 2\}\]->\(b\)/); }); it("escapes single quotes in string values", () => { const g = fixture(); g.nodes.set("quoted", { id: "quoted", type: "entity", label: "O'Brien", pageRank: 0.1, }); const out = exportCypher(g, NO_ARTICLES); expect(out).toContain("O\\'Brien"); }); }); describe("exportGraphMl", () => { it("emits well-formed XML with typed attributes", () => { const xml = exportGraphMl(fixture(), NO_ARTICLES); expect(xml).toStartWith('`); expect(xml).toContain(`Greeter`); expect(xml).toMatch(/ { const g = fixture(); g.nodes.set("weird", { id: "", type: "entity", label: `a&b"c'd`, pageRank: 0, }); const xml = exportGraphMl(g, NO_ARTICLES); expect(xml).toContain(``); expect(xml).toContain( `a&b"c'd<e>`, ); }); }); describe("exportObsidian", () => { function findPath( files: Record, prefix: string, ): string | undefined { return Object.keys(files).find((k) => k.startsWith(prefix)); } it("emits a directory map with per-node markdown, an index, and a workspace", () => { const files = exportObsidian(fixture(), NO_ARTICLES); expect(findPath(files, "entity/entity_Greeter")).toBeString(); expect(findPath(files, "entity/entity_VERSION")).toBeString(); expect(findPath(files, "module/module_core")).toBeString(); expect(files["index.md"]).toContain("# maina graph"); expect(files["index.md"]).toContain("## entity"); expect(files["index.md"]).toContain("## module"); expect(files[".obsidian/workspace.json"]).toBeString(); expect( JSON.parse(files[".obsidian/workspace.json"] ?? "{}"), ).toHaveProperty("main"); }); it("disambiguates filenames when sanitization would collide", () => { // Two distinct ids that sanitise to the same base name. const nodes = new Map(); nodes.set("a:b", { id: "a:b", type: "entity", label: "A", pageRank: 0.1, }); nodes.set("a_b", { id: "a_b", type: "entity", label: "A2", pageRank: 0.2, }); const adjacency = new Map([ ["a:b", new Set()], ["a_b", new Set()], ]); const graph = { nodes, edges: [], adjacency, } as unknown as Parameters[0]; const files = exportObsidian(graph, NO_ARTICLES); const matches = Object.keys(files).filter((k) => k.startsWith("entity/a_b"), ); expect(matches.length).toBeGreaterThanOrEqual(2); }); it("includes [[wikilinks]] that resolve to canonical file paths", () => { const files = exportObsidian(fixture(), NO_ARTICLES); const greeterKey = findPath(files, "entity/entity_Greeter"); const greeter = (greeterKey && files[greeterKey]) || ""; const versionKey = findPath(files, "entity/entity_VERSION") ?? ""; const coreKey = findPath(files, "module/module_core") ?? ""; expect(greeter).toContain("## Outgoing"); // Links point at the actual file basename (minus .md), so Obsidian's // resolver can reach the file even when the filename was hash-suffixed. expect(greeter).toContain( `- references → [[${versionKey.replace(/\.md$/, "")}|VERSION]]`, ); expect(greeter).toContain("## Incoming"); expect(greeter).toContain( `- contains ← [[${coreKey.replace(/\.md$/, "")}|core]]`, ); }); it("every wikilink target corresponds to a file the export actually wrote", () => { const files = exportObsidian(fixture(), NO_ARTICLES); // Collect the canonical set of page basenames (minus .md). const pageBasenames = new Set( Object.keys(files) .filter((k) => k.endsWith(".md") && k !== "index.md") .map((k) => k.replace(/\.md$/, "")), ); // Every [[link|label]] in every page (including index) must target one // of those basenames — raw ids are not valid on their own because the // filename may carry a disambiguating hash suffix. const linkRe = /\[\[([^|\]]+)\|/g; for (const [, contents] of Object.entries(files)) { for (const match of contents.matchAll(linkRe)) { const target = match[1]; if (!target) continue; // Skip Obsidian workspace JSON (not markdown). if (target.startsWith("{")) continue; expect(pageBasenames.has(target)).toBe(true); } } }); it("frontmatter carries node metadata", () => { const files = exportObsidian(fixture(), NO_ARTICLES); const greeterKey = findPath(files, "entity/entity_Greeter"); const greeter = (greeterKey && files[greeterKey]) || ""; expect(greeter).toStartWith("---\n"); expect(greeter).toContain('id: "entity:Greeter"'); expect(greeter).toContain("pageRank: 0.42"); expect(greeter).toContain('file: "src/greet.ts"'); }); }); describe("exportGraph dispatcher", () => { it("returns string contents for cypher/graphml", () => { const c = exportGraph(fixture(), NO_ARTICLES, "cypher"); const g = exportGraph(fixture(), NO_ARTICLES, "graphml"); expect(c.ok).toBe(true); expect(g.ok).toBe(true); if (c.ok && c.format === "cypher") expect(c.contents).toContain("BEGIN"); if (g.ok && g.format === "graphml") expect(g.contents).toContain(" { const o = exportGraph(fixture(), NO_ARTICLES, "obsidian"); if (o.ok && o.format === "obsidian") { expect(Object.keys(o.files).length).toBeGreaterThan(0); } }); it("errors on unknown format", () => { const r = exportGraph(fixture(), NO_ARTICLES, "gexf" as never); expect(r.ok).toBe(false); }); });