import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { parseBoard } from "../src/model/board"; import { serializeTaskLine } from "../src/model/serialize"; import type { Task } from "../src/model/types"; import { applyReorder, fillArchive, cutTask, insertBlock, locate, MissingLaneError, replaceLine, StaleTaskError, takeDone, } from "../src/vault/edits"; const FIXTURES = join(import.meta.dirname, "fixtures"); function board(name: string) { const content = readFileSync(join(FIXTURES, name), "utf8"); return { content, board: parseBoard(`Boards/${name}`, content) }; } function landmarks(text: string) { const lines = text.split("\n"); return { settings: lines.filter((line) => line.startsWith("%% kanban:settings")).length, fences: lines.filter((line) => line.trim() === "***").length, headings: lines.filter((line) => line.startsWith("## ")), frontmatter: text.startsWith("---\n") ? text.slice(0, text.indexOf("\n---\n", 4)) : "", }; } function edited(name: string, mutate: (lines: string[]) => void) { const { content } = board(name); const lines = content.split("\n"); mutate(lines); return lines.join("\n"); } describe("locate", () => { it("finds a line that has shifted", () => { const { board: parsed } = board("personal.md"); const task = parsed.tasks.find((item) => !item.inArchive) as Task; const lines = readFileSync(join(FIXTURES, "personal.md"), "utf8").split("\n"); lines.unshift("", "", ""); expect(locate(lines, task)).toBe(task.line + 3); }); it("refuses when the line is gone", () => { const { board: parsed } = board("personal.md"); expect(() => locate(["nothing", "here"], parsed.tasks[0])).toThrow( StaleTaskError, ); }); it("picks the nearest of several identical lines", () => { const { board: parsed } = board("work.md"); const counts = new Map(); for (const task of parsed.tasks) { const list = counts.get(task.raw) ?? []; list.push(task); counts.set(task.raw, list); } const dupes = [...counts.values()].find((list) => list.length > 1); expect(dupes, "work.md should carry duplicate archived lines").toBeDefined(); const lines = readFileSync(join(FIXTURES, "work.md"), "utf8").split("\n"); for (const task of dupes as Task[]) { expect(locate(lines, task)).toBe(task.line); } }); }); describe("replaceLine", () => { it("changes only the target line", () => { const { content, board: parsed } = board("personal.md"); const task = parsed.tasks.find( (item) => item.text === "renew the gym membership", ) as Task; expect(task).toBeDefined(); const next = edited("personal.md", (lines) => { replaceLine(lines, task, serializeTaskLine({ ...task, due: "2026-09-01" })); }); const before = content.split("\n"); const after = next.split("\n"); expect(after).toHaveLength(before.length); expect(after.filter((line, index) => line !== before[index])).toEqual([ "- [ ] renew the gym membership 📅 2026-09-01", ]); }); it("expands a recurring toggle into two lines", () => { const { board: parsed } = board("personal.md"); const task = parsed.tasks.find( (item) => !item.inArchive && item.recurrence, ) as Task; const next = edited("personal.md", (lines) => { replaceLine( lines, task, `- [ ] ${task.text} 🔁 every day 📅 2026-08-09\n- [x] ${task.text} 🔁 every day 📅 2026-08-08 ✅ 2026-08-08`, ); }); expect(next).toContain("📅 2026-08-09"); expect(next).toContain("✅ 2026-08-08"); expect(landmarks(next).settings).toBe(1); }); }); describe("cutTask and insertBlock", () => { it("moves a card between lanes of the same board", () => { const { content, board: parsed } = board("personal.md"); const task = parsed.tasks.find( (item) => !item.inArchive && item.project === "errands", ) as Task; const next = edited("personal.md", (lines) => { const block = cutTask(lines, task); insertBlock(lines, "personal.md", { project: "someday" }, block); }); const reparsed = parseBoard("personal.md", next); const moved = reparsed.tasks.find((item) => item.text === task.text) as Task; expect(moved.project).toBe("someday"); expect(reparsed.tasks).toHaveLength(parsed.tasks.length); const before = landmarks(content); const after = landmarks(next); expect(after.settings).toBe(before.settings); expect(after.fences).toBe(before.fences); expect(after.headings).toEqual(before.headings); }); it("carries a multi-line card body along", () => { const { content, board: parsed } = board("work.md"); const task = parsed.tasks.find((item) => item.body.length > 1) as Task; expect(task).toBeDefined(); const lines = content.split("\n"); const before = lines.length; const block = cutTask(lines, task); expect(block).toHaveLength(1 + task.body.length); expect(block.slice(1)).toEqual(task.body); expect(lines.join("\n")).not.toContain(block.join("\n")); expect(before - lines.length).toBeGreaterThanOrEqual(block.length); }); it("reports a missing lane instead of writing", () => { const lines = board("personal.md").content.split("\n"); expect(() => insertBlock(lines, "personal.md", { project: "nope" }, ["- [ ] x"]), ).toThrow(MissingLaneError); }); it("appends after the last card, before the lane's blank tail", () => { const next = edited("personal.md", (lines) => { insertBlock(lines, "personal.md", { project: "errands" }, [ "- [ ] new one 📅 2026-08-08", ]); }); const lane = parseBoard("personal.md", next).tasks.filter( (item) => item.project === "errands" && !item.inArchive, ); expect(lane[lane.length - 1].text).toBe("new one"); expect(landmarks(next).settings).toBe(1); }); }); describe("applyReorder", () => { it("reverses a lane without disturbing the rest of the file", () => { const { content, board: parsed } = board("personal.md"); const inLane = parsed.tasks.filter( (item) => item.project === "errands" && !item.inArchive, ); expect(inLane.length).toBeGreaterThan(2); const next = edited("personal.md", (lines) => { applyReorder(lines, inLane, [...inLane].reverse()); }); const after = parseBoard("personal.md", next) .tasks.filter((item) => item.project === "errands" && !item.inArchive) .map((item) => item.text); expect(after).toEqual([...inLane].reverse().map((item) => item.text)); expect(next.split("\n").sort()).toEqual(content.split("\n").sort()); expect(landmarks(next)).toEqual(landmarks(content)); }); }); /** Runs the archive over one board, mirror file and all. */ function archived(name: string, migrate = true) { const { content } = board(name); const lines = content.split("\n"); const harvest = takeDone(lines, name, migrate); const mirror: string[] = []; fillArchive(mirror, `archive/${name}`, harvest); return { harvest, board: lines.join("\n"), mirror: mirror.join("\n") }; } function markAllDone(name: string): string[] { const { content, board: parsed } = board(name); const lines = content.split("\n"); for (const task of parsed.tasks.filter((item) => !item.inArchive)) { replaceLine( lines, task, serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }), ); } return lines; } describe("takeDone", () => { it("lifts a completed card out with the lane it sat in", () => { const { content, board: parsed } = board("personal.md"); const lines = content.split("\n"); const open = parsed.tasks.find((item) => !item.inArchive) as Task; replaceLine( lines, open, serializeTaskLine({ ...open, status: "x", done: "2026-08-08" }), ); const harvest = takeDone(lines, "personal.md", false); expect(harvest.cards).toHaveLength(1); expect(harvest.cards[0].project).toBe("errands"); expect(harvest.cards[0].block[0]).toContain(open.text); expect(lines.join("\n")).not.toContain(open.text); }); it("is a no-op when nothing is done and nothing was archived", () => { const { content } = board("home.md"); const lines = content.split("\n"); const harvest = takeDone(lines, "home.md", true); expect(harvest.cards).toHaveLength(0); expect(harvest.legacy).toHaveLength(0); expect(lines.join("\n")).toBe(content); }); it("leaves the native archive alone without migrate", () => { const { content } = board("personal.md"); const lines = content.split("\n"); takeDone(lines, "personal.md", false); const next = lines.join("\n"); expect(next).toContain("***"); expect(next).toContain("## Archive"); expect(next).toContain("pick up the parcel"); }); it("takes the native archive whole, separator and all", () => { const { board: parsed } = board("personal.md"); const { harvest, board: next } = archived("personal.md"); expect(harvest.legacy).toHaveLength(1); expect(harvest.legacy[0].project).toBe("Archive"); const wasArchived = parsed.tasks.filter((item) => item.inArchive); expect(harvest.legacy[0].block).toHaveLength(wasArchived.length); expect(next).not.toContain("***"); expect(next).not.toContain("## Archive"); expect(next).not.toContain("pick up the parcel"); // The lanes that were live stay untouched, settings block included. expect(landmarks(next).settings).toBe(1); expect(landmarks(next).headings).toEqual(["## errands", "## someday"]); expect(next).toContain("- [ ] renew the gym membership"); }); }); describe("fillArchive", () => { it("writes a Kanban board with the lanes the cards came from", () => { const lines = markAllDone("work.md"); const harvest = takeDone(lines, "work.md", true); const mirror: string[] = []; fillArchive(mirror, "archive/work.md", harvest); const next = mirror.join("\n"); expect(next.startsWith("---\n\nkanban-plugin: board\n\n---\n")).toBe(true); expect(next).toContain("## website"); expect(next).toContain("## notes"); // The old native archive keeps its own heading. expect(next).toContain("## Archive"); expect(next).toContain("- [x] ship the pricing page 📅 2026-07-20 ✅ 2026-07-21"); const parsed = parseBoard("archive/work.md", next, true); expect(parsed.tasks.every((task) => task.inArchive)).toBe(true); expect(parsed.tasks.map((task) => task.project)).toContain("website"); }); it("keeps a card's indented body with it", () => { const lines = markAllDone("work.md"); const harvest = takeDone(lines, "work.md", false); const mirror: string[] = []; fillArchive(mirror, "archive/work.md", harvest); const next = mirror.join("\n"); expect(next).toContain("\t- shorter intro"); expect(next).toContain("\tfollow-up: check the analytics bundle size"); }); it("appends to a mirror that already holds history", () => { const first = markAllDone("personal.md"); const harvest = takeDone(first, "personal.md", true); const mirror: string[] = []; fillArchive(mirror, "archive/personal.md", harvest); const before = parseBoard("archive/personal.md", mirror.join("\n"), true); fillArchive(mirror, "archive/personal.md", { cards: [{ project: "errands", block: ["- [x] a later one ✅ 2026-09-01"] }], legacy: [], }); const after = parseBoard("archive/personal.md", mirror.join("\n"), true); expect(after.tasks).toHaveLength(before.tasks.length + 1); expect(after.lanes).toHaveLength(before.lanes.length); const errands = after.tasks.filter((task) => task.project === "errands"); expect(errands.at(-1)?.text).toBe("a later one"); }); }); describe("every board survives a full archive pass", () => { for (const name of readdirSync(FIXTURES).filter((n) => n.endsWith(".md"))) { it(`${name}: nothing lost, the board keeps its shape`, () => { const { content, board: parsed } = board(name); const lines = markAllDone(name); const harvest = takeDone(lines, name, true); const next = lines.join("\n"); const mirror: string[] = []; fillArchive(mirror, `archive/${name}`, harvest); // The board keeps its front matter, its settings and its live lanes, // and holds no task at all any more. const reparsed = parseBoard(name, next); expect(reparsed.tasks).toHaveLength(0); const before = landmarks(content); const after = landmarks(next); expect(after.settings).toBe(before.settings); expect(after.frontmatter).toBe(before.frontmatter); expect(after.fences).toBe(0); for (const heading of before.headings.filter((h) => h !== "## Archive")) { expect(after.headings, `${heading} must survive`).toContain(heading); } // Every task that was on the board is now in the mirror, once. const mirrored = parseBoard(`archive/${name}`, mirror.join("\n"), true); expect(mirrored.tasks).toHaveLength(parsed.tasks.length); expect(mirrored.tasks.every((task) => task.inArchive)).toBe(true); expect(mirrored.tasks.map((task) => task.raw).sort()).toEqual( [ ...parsed.tasks.filter((task) => task.inArchive).map((t) => t.raw), ...markedDone(parsed.tasks.filter((task) => !task.inArchive)), ].sort(), ); }); } }); function markedDone(tasks: Task[]): string[] { return tasks.map((task) => serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }), ); }