import {beforeEach, describe, expect, it} from "bun:test"; import type {RolloverFile, RolloverIO} from "../src/rollover"; import {isTodayNote, rollOverPastTasks} from "../src/rollover"; import type {MarkdownTasksSettings} from "../src/settings"; class InMemoryRolloverIO implements RolloverIO { files: Map = new Map(); frontmatters: Map> = new Map(); writes: string[] = []; add(path: string, content = "", frontmatter: Record = {}) { this.files.set(path, content); this.frontmatters.set(path, {...frontmatter}); } private describe(path: string): RolloverFile { const segments = path.split("/"); const name = segments.pop() ?? path; return { path, basename: name.replace(/\.md$/, ""), parentPath: segments.join("/"), }; } listMarkdownFiles(): RolloverFile[] { return [...this.files.keys()].filter((p) => p.endsWith(".md")).map((p) => this.describe(p)); } readFile(path: string): Promise { const content = this.files.get(path); if (content === undefined) return Promise.reject(new Error(`Not found: ${path}`)); return Promise.resolve(content); } writeFile(path: string, content: string): Promise { this.files.set(path, content); this.writes.push(path); return Promise.resolve(); } getOrCreateFile(path: string): Promise { if (!this.files.has(path)) { this.files.set(path, ""); this.frontmatters.set(path, {}); } return Promise.resolve(this.describe(path)); } processFrontMatter( path: string, fn: (frontmatter: Record) => void, ): Promise { const fm = this.frontmatters.get(path) ?? {}; fn(fm); this.frontmatters.set(path, fm); return Promise.resolve(); } } const settings = (overrides: Partial = {}): MarkdownTasksSettings => ({ completedSymbol: "x", inProgressSymbol: "/", movedSymbol: ">", rolloverSourceAction: "mark-moved", rolloverContext: "none", rolloverHeader: "## Past tasks", rolloverOnTodayCreate: true, dailyNoteFormat: "YYYY-MM-DD", dailyNotesFolder: "", enableAutoProcessing: true, pendingTasksProperty: "Pending tasks", pendingTasksCountProperty: "Pending tasks count", totalTasksCountProperty: "Total tasks count", enableTaskCounts: false, countRootTasksOnly: false, excludeFolders: [], includeFolders: [], ...overrides, }) as MarkdownTasksSettings; const today = new Date(2026, 4, 6); // 2026-05-06 describe("isTodayNote", () => { it("matches when basename equals today's formatted date and folder matches", () => { const file = {basename: "2026-05-06", parentPath: "Daily"}; expect(isTodayNote(file, settings({dailyNotesFolder: "Daily"}), today)).toBe(true); }); it("rejects when folder does not match", () => { const file = {basename: "2026-05-06", parentPath: "Other"}; expect(isTodayNote(file, settings({dailyNotesFolder: "Daily"}), today)).toBe(false); }); it("rejects when basename is not today", () => { const file = {basename: "2026-05-05", parentPath: ""}; expect(isTodayNote(file, settings(), today)).toBe(false); }); }); describe("rollOverPastTasks – orchestration", () => { let io: InMemoryRolloverIO; beforeEach(() => { io = new InMemoryRolloverIO(); }); it("returns no-op result when there are no past notes", async () => { io.add("notes/random.md", "- [ ] not a daily note"); const result = await rollOverPastTasks(io, settings(), today); expect(result).toEqual({rolledOverFiles: 0, migratedBlocks: 0, destinationPath: null}); expect(io.writes).toEqual([]); }); it("skips past notes without pending tasks", async () => { io.add("2026-05-04.md", "- [x] done\n"); const result = await rollOverPastTasks(io, settings(), today); expect(result.migratedBlocks).toBe(0); expect(result.rolledOverFiles).toBe(0); expect(io.writes).toEqual([]); }); it("rolls over pending tasks from past notes to today's note", async () => { io.add("2026-05-04.md", "- [ ] yesterday-yesterday\n"); io.add("2026-05-05.md", "- [ ] yesterday\n"); const result = await rollOverPastTasks(io, settings(), today); expect(result.migratedBlocks).toBe(2); expect(result.rolledOverFiles).toBe(2); expect(result.destinationPath).toBe("2026-05-06.md"); expect(io.files.get("2026-05-04.md")).toContain("- [>] yesterday-yesterday"); expect(io.files.get("2026-05-05.md")).toContain("- [>] yesterday"); const todayContent = io.files.get("2026-05-06.md"); expect(todayContent).toContain("## Past tasks"); expect(todayContent).toContain("- [ ] yesterday-yesterday"); expect(todayContent).toContain("- [ ] yesterday"); }); it("respects the daily notes folder filter", async () => { io.add("Daily/2026-05-05.md", "- [ ] in-folder\n"); io.add("Other/2026-05-05.md", "- [ ] outside-folder\n"); const result = await rollOverPastTasks(io, settings({dailyNotesFolder: "Daily"}), today); expect(result.rolledOverFiles).toBe(1); expect(io.files.get("Other/2026-05-05.md")).toBe("- [ ] outside-folder\n"); expect(io.files.get("Daily/2026-05-05.md")).toContain("- [>] in-folder"); expect(result.destinationPath).toBe("Daily/2026-05-06.md"); }); it("ignores future-dated notes", async () => { io.add("2027-01-01.md", "- [ ] future\n"); const result = await rollOverPastTasks(io, settings(), today); expect(result.migratedBlocks).toBe(0); expect(io.files.get("2027-01-01.md")).toBe("- [ ] future\n"); }); }); describe("rollOverPastTasks – pending-tasks frontmatter refresh", () => { let io: InMemoryRolloverIO; beforeEach(() => { io = new InMemoryRolloverIO(); }); it("clears the pending property on the source after pending tasks are migrated", async () => { io.add("2026-05-05.md", "- [ ] yesterday\n", {"Pending tasks": true}); await rollOverPastTasks(io, settings(), today); const fm = io.frontmatters.get("2026-05-05.md"); expect(fm).toBeDefined(); expect(fm).not.toHaveProperty("Pending tasks"); }); it("sets the pending property on today's note after it receives migrated tasks", async () => { io.add("2026-05-05.md", "- [ ] yesterday\n"); await rollOverPastTasks(io, settings(), today); expect(io.frontmatters.get("2026-05-06.md")).toEqual({"Pending tasks": true}); }); it("writes count properties when enableTaskCounts is on", async () => { io.add("2026-05-05.md", "- [ ] one\n- [ ] two\n"); await rollOverPastTasks(io, settings({enableTaskCounts: true}), today); expect(io.frontmatters.get("2026-05-06.md")).toEqual({ "Pending tasks": true, "Pending tasks count": 2, "Total tasks count": 2, }); }); it("skips frontmatter refresh entirely when enableAutoProcessing is off", async () => { io.add("2026-05-05.md", "- [ ] yesterday\n", {"Pending tasks": true}); await rollOverPastTasks(io, settings({enableAutoProcessing: false}), today); expect(io.frontmatters.get("2026-05-05.md")).toEqual({"Pending tasks": true}); expect(io.frontmatters.get("2026-05-06.md")).toEqual({}); }); it("skips frontmatter refresh on excluded folders", async () => { io.add("Templates/2026-05-05.md", "- [ ] task\n", {"Pending tasks": true}); await rollOverPastTasks(io, settings({excludeFolders: ["Templates"]}), today); expect(io.frontmatters.get("Templates/2026-05-05.md")).toEqual({"Pending tasks": true}); }); it("respects include-folder restrictions on the source", async () => { io.add("Daily/2026-05-05.md", "- [ ] yesterday\n", {"Pending tasks": true}); await rollOverPastTasks( io, settings({dailyNotesFolder: "Daily", includeFolders: ["Other"]}), today, ); // Source is in "Daily" but include filter restricts to "Other" → no refresh expect(io.frontmatters.get("Daily/2026-05-05.md")).toEqual({"Pending tasks": true}); }); });