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