Files
obsidian-foundry-sync/tests/batch.test.ts
2026-06-20 19:15:38 +00:00

238 lines
12 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readFile, rm, mkdir, writeFile, copyFile } from "node:fs/promises";
import { join } from "node:path";
import { JournalDb } from "../src/db.js";
import { indexAll, seedRow, syncRow, importRow, buildBlock, seedBlockContent, recommend, type FileRow, type IndexResult } from "../src/batch.js";
import { splitFrontmatter } from "../src/frontmatter.js";
import { contentHash } from "../src/normalize.js";
import { ensureJournalFixture, ensureRefinedFixture, ensureCcFixture } from "./helpers.js";
const STAMP = "2026-06-20T00:00:00.000Z";
describe("indexAll", () => {
it("matches all refined notes to cc files with the expected counts", async () => {
const db = await JournalDb.open(ensureJournalFixture());
try {
const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture());
expect(idx.counts.matched).toBe(107);
expect(idx.counts.ccOnly).toBe(52);
expect(idx.counts.refinedOnly).toBe(0);
expect(idx.counts.unlinked).toBe(0);
expect(idx.matched.length).toBe(107);
expect(idx.ccOnly.length).toBe(52);
} finally { await db.close(); }
});
it("resolves cc_id to a journal entry and curated type, and recommends seed when unseeded", async () => {
const db = await JournalDb.open(ensureJournalFixture());
try {
const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture());
const fenris = idx.matched.find((r) => r.name === "Fenris of House Quche")!;
expect(fenris).toBeTruthy();
expect(fenris.status).toBe("matched");
expect(fenris.curatedType).toBe("npc");
expect(fenris.ccType).toBe("npc");
expect(fenris.ccId).toBe("0dNlOAJfOuglX7Gp");
expect(fenris.entry).not.toBeNull();
// Cold start: no foundry: block, no cc_sync_hash -> recommendation is "seed".
expect(fenris.recommendation).toBe("seed");
expect(fenris.storedRefinedHash).toBeNull();
expect(fenris.storedCcHash).toBeNull();
} finally { await db.close(); }
});
it("every cc-only row recommends import, every matched row recommends seed (cold start)", async () => {
const db = await JournalDb.open(ensureJournalFixture());
try {
const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture());
expect(idx.ccOnly.every((r) => r.recommendation === "import")).toBe(true);
expect(idx.matched.every((r) => r.recommendation === "seed")).toBe(true);
} finally { await db.close(); }
});
});
describe("recommend (pure direction logic)", () => {
const E = { name: "x", _id: "x" } as never;
it("ranks import > seed > sync-cc > repull > conflict > in-sync", () => {
expect(recommend({ status: "cc-only", seeded: false, hasCc: true, ccSynced: false, refinedChanged: false, ccChanged: false, entry: E })).toBe("import");
expect(recommend({ status: "matched", seeded: false, hasCc: true, ccSynced: false, refinedChanged: false, ccChanged: false, entry: E })).toBe("seed");
expect(recommend({ status: "matched", seeded: true, hasCc: true, ccSynced: false, refinedChanged: false, ccChanged: false, entry: E })).toBe("sync-cc");
expect(recommend({ status: "matched", seeded: true, hasCc: true, ccSynced: true, refinedChanged: true, ccChanged: false, entry: E })).toBe("sync-cc");
expect(recommend({ status: "matched", seeded: true, hasCc: true, ccSynced: true, refinedChanged: false, ccChanged: true, entry: E })).toBe("repull");
expect(recommend({ status: "matched", seeded: true, hasCc: true, ccSynced: true, refinedChanged: true, ccChanged: true, entry: E })).toBe("conflict");
expect(recommend({ status: "matched", seeded: true, hasCc: true, ccSynced: true, refinedChanged: false, ccChanged: false, entry: E })).toBe("in-sync");
});
});
describe("seedBlock (minimal link)", () => {
it("injects only the foundry: block, preserving all curation byte-for-byte", async () => {
const db = await JournalDb.open(ensureJournalFixture());
try {
const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture());
const fenris = idx.matched.find((r) => r.name === "Fenris of House Quche")!;
const refinedDir = ensureRefinedFixture();
const orig = await readFile(join(refinedDir, fenris.refinedPath!), "utf8");
const seeded = await seedRow(fenris, refinedDir, STAMP);
expect(seeded).not.toBeNull();
const origLines = orig.split("\n");
const seededLines = seeded!.content.split("\n");
expect(origLines.filter((l) => !seededLines.includes(l))).toEqual([]);
const added = seededLines.filter((l) => !origLines.includes(l)).filter((l) => l.trim() !== "");
expect(added).toEqual([
"foundry:",
` cc_uuid: JournalEntry.${fenris.ccId}`,
" cc_type: npc",
" folder_path: Campaign Codex - NPCs",
expect.stringMatching(/^ contentHash: [0-9a-f]{64}$/),
` syncedAt: ${STAMP}`,
]);
const { fm } = splitFrontmatter(seeded!.content);
expect(fm.type).toBe("npc");
expect(fm.aliases).toBe("[]");
expect(fm.faction).toBe("[[House Quche]]");
expect(fm.foundry).toBeTruthy();
} finally { await db.close(); }
});
it("is idempotent: seeding twice yields the same content", async () => {
const db = await JournalDb.open(ensureJournalFixture());
try {
const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture());
const fenris = idx.matched.find((r) => r.name === "Fenris of House Quche")!;
const refinedDir = ensureRefinedFixture();
const once = await seedRow(fenris, refinedDir, STAMP);
const body1 = splitFrontmatter(once!.content).body;
const twice = seedBlockContent(once!.content, buildBlock(fenris.entry!, body1, STAMP));
expect(twice).toBe(once!.content);
} finally { await db.close(); }
});
});
describe("syncRow (baselines both sides)", () => {
it("regenerates cc.md with cc_sync_hash AND refreshes the refined foundry block", async () => {
const db = await JournalDb.open(ensureJournalFixture());
try {
const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture());
const fenris = idx.matched.find((r) => r.name === "Fenris of House Quche")!;
const refinedDir = ensureRefinedFixture();
const synced = await syncRow(fenris, refinedDir, db, STAMP);
expect(synced.cc).not.toBeNull();
expect(synced.refined).not.toBeNull();
const { fm, body } = splitFrontmatter(synced.cc!.content);
expect(fm.cc_id).toBe("0dNlOAJfOuglX7Gp");
expect(fm.cc_type).toBe("npc");
expect(fm.cc_folder_path).toBe("Campaign Codex - NPCs");
expect(fm.cc_sync_hash).toMatch(/^[0-9a-f]{64}$/);
expect(body.trim()).toMatch(/^# Fenris of House Quche/);
expect(body).toMatch(/## Background/);
expect(body).toContain("[[House Quche]]");
// cc_sync_hash == hash of the cc body (excluding frontmatter).
expect(fm.cc_sync_hash).toBe(contentHash(splitFrontmatter(synced.cc!.content).body));
// Refined side now carries a foundry: block whose contentHash matches its body.
const rfm = splitFrontmatter(synced.refined!.content).fm;
expect(rfm.foundry).toBeTruthy();
expect((rfm.foundry as Record<string, string>).contentHash).toBe(contentHash(splitFrontmatter(synced.refined!.content).body));
} finally { await db.close(); }
});
});
describe("importCcOnly", () => {
it("builds a new refined note with a foundry: block for a cc-only entry", async () => {
const db = await JournalDb.open(ensureJournalFixture());
try {
const idx = await indexAll(db, ensureCcFixture(), ensureRefinedFixture());
const ccOnly = idx.ccOnly.find((r) => r.entry);
expect(ccOnly).toBeTruthy();
const note = importRow(ccOnly!, db, STAMP);
expect(note).not.toBeNull();
expect(note!.filename).toBe(ccOnly!.basename);
const { fm } = splitFrontmatter(note!.content);
expect(fm.foundry).toBeTruthy();
expect((fm.foundry as Record<string, string>).cc_uuid).toBe(`JournalEntry.${ccOnly!.ccId}`);
} finally { await db.close(); }
});
});
describe("merge direction end-to-end", () => {
// Stages a single Fenris note + its cc in a temp dir, then drives the stateless
// merge through seed -> sync -> in-sync, and verifies refined-newer, cc-newer,
// and both-changed (conflict) are each detected from the embedded hashes alone.
it("detects seed -> in-sync -> sync-cc -> repull -> conflict from embedded hashes", async () => {
const db = await JournalDb.open(ensureJournalFixture());
const root = "/tmp/test-merge-direction";
const tmpRefined = join(root, "refined");
const tmpCc = join(root, "cc");
try {
await rm(root, { recursive: true, force: true });
const refinedDir = ensureRefinedFixture();
const ccDir = ensureCcFixture();
const rSrc = join(refinedDir, "01 - Characters", "Fenris of House Quche.md");
const cSrc = join(ccDir, "Campaign Codex - NPCs", "Fenris of House Quche.md");
await mkdir(join(tmpRefined, "01 - Characters"), { recursive: true });
await mkdir(join(tmpCc, "Campaign Codex - NPCs"), { recursive: true });
await copyFile(rSrc, join(tmpRefined, "01 - Characters", "Fenris of House Quche.md"));
await copyFile(cSrc, join(tmpCc, "Campaign Codex - NPCs", "Fenris of House Quche.md"));
const find = (idx: IndexResult): FileRow =>
idx.matched.find((r) => r.name === "Fenris of House Quche")!;
// Cold start -> seed.
const f0 = find(await indexAll(db, tmpCc, tmpRefined));
expect(f0.recommendation).toBe("seed");
// Seed + sync baselines both sides.
const seeded = await seedRow(f0, tmpRefined, STAMP);
await writeFile(join(tmpRefined, f0.refinedPath!), seeded!.content, "utf8");
const f1 = find(await indexAll(db, tmpCc, tmpRefined));
const synced = await syncRow(f1, tmpRefined, db, STAMP);
await writeFile(join(tmpRefined, f1.refinedPath!), synced.refined!.content, "utf8");
await writeFile(join(tmpCc, f1.ccPath!), synced.cc!.content, "utf8");
const f2 = find(await indexAll(db, tmpCc, tmpRefined));
expect(f2.recommendation).toBe("in-sync");
expect(f2.storedCcHash).toMatch(/^[0-9a-f]{64}$/);
// Edit the refined note -> refined newer -> sync-cc.
const rp = join(tmpRefined, f2.refinedPath!);
await writeFile(rp, (await readFile(rp, "utf8")).replace("## Personality", "## Personality\n\nEDITED-for-test"), "utf8");
const f3 = find(await indexAll(db, tmpCc, tmpRefined));
expect(f3.recommendation).toBe("sync-cc");
expect(f3.refinedChanged).toBe(true);
expect(f3.ccChanged).toBe(false);
// Re-sync to re-baseline, then edit the cc file -> cc newer -> repull.
const synced2 = await syncRow(f3, tmpRefined, db, STAMP);
await writeFile(rp, synced2.refined!.content, "utf8");
const cp = join(tmpCc, f3.ccPath!);
await writeFile(cp, synced2.cc!.content, "utf8");
await writeFile(cp, (await readFile(cp, "utf8")).replace("## Background", "## Background\n\nCC-EDITED-for-test"), "utf8");
const f4 = find(await indexAll(db, tmpCc, tmpRefined));
expect(f4.recommendation).toBe("repull");
expect(f4.ccChanged).toBe(true);
expect(f4.refinedChanged).toBe(false);
// Also edit the refined note (cc still edited) -> both changed -> conflict.
await writeFile(rp, (await readFile(rp, "utf8")).replace("## Goals", "## Goals\n\nCONFLICT-edit"), "utf8");
const f5 = find(await indexAll(db, tmpCc, tmpRefined));
expect(f5.recommendation).toBe("conflict");
} finally {
await db.close();
await rm(root, { recursive: true, force: true });
}
});
});
describe("teardown", () => {
it("removes test out dirs", async () => {
for (const d of ["/tmp/test-batch-out", "/tmp/test-server-out"]) {
await rm(d, { recursive: true, force: true });
}
expect(true).toBe(true);
});
});