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 { applyArchive, applyReorder, cutTask, insertBlock, locate, MissingLaneError, replaceLine, StaleTaskError, } 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)); }); }); describe("applyArchive", () => { it("moves done cards under the archive heading", () => { 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" }), ); expect(applyArchive(lines, "personal.md", "Archive")).toBe(1); const next = lines.join("\n"); const reparsed = parseBoard("personal.md", next); const archived = reparsed.tasks.find((item) => item.text === open.text) as Task; expect(archived.inArchive).toBe(true); expect(archived.done).toBe("2026-08-08"); expect(reparsed.tasks.filter((item) => item.text === open.text)).toHaveLength(1); expect(landmarks(next).settings).toBe(1); expect(landmarks(next).fences).toBe(1); }); it("is a no-op when nothing is done", () => { const { content } = board("home.md"); const lines = content.split("\n"); expect(applyArchive(lines, "home.md", "Archive")).toBe(0); expect(lines.join("\n")).toBe(content); }); it("creates the fence and heading on a board without one", () => { const { content } = board("home.md"); expect(content).not.toContain("## Archive"); const lines = content.split("\n"); const parsed = parseBoard("home.md", content); for (const task of parsed.tasks) { replaceLine( lines, task, serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }), ); } expect(applyArchive(lines, "home.md", "Archive")).toBe(parsed.tasks.length); const next = lines.join("\n"); expect(next).toContain("***"); expect(next).toContain("## Archive"); expect(next).toContain("%% kanban:settings"); expect(parseBoard("home.md", next).tasks.every((t) => t.inArchive)).toBe(true); }); }); describe("every board survives a full archive pass", () => { for (const name of readdirSync(FIXTURES).filter((n) => n.endsWith(".md"))) { it(`${name}: landmarks intact, no task lost`, () => { 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" }), ); } applyArchive(lines, name, "Archive"); const next = lines.join("\n"); const reparsed = parseBoard(name, next); expect(reparsed.tasks).toHaveLength(parsed.tasks.length); expect(reparsed.tasks.every((item) => item.inArchive)).toBe(true); const before = landmarks(content); const after = landmarks(next); expect(after.settings).toBe(before.settings); expect(after.frontmatter).toBe(before.frontmatter); for (const heading of before.headings) { expect(after.headings, `${heading} must survive`).toContain(heading); } }); } });