Files
2026-08-08 16:30:22 +02:00

273 lines
9.6 KiB
TypeScript

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<string, Task[]>();
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);
}
});
}
});