273 lines
9.6 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
}
|
|
});
|