obsidian-plugin-markdown-tasks/test/rollover.test.ts
Felipe M. f8fd0281eb
Refactor rollover behind a RolloverIO interface for testability
Split rollover.ts into pure orchestration (uses an injected RolloverIO)
and a small Obsidian adapter (rolloverObsidian.ts). Extract the
pending-tasks frontmatter mutation into pendingTasks.applyPendingFrontmatter
and the path filter into pendingTasks.shouldProcessPath so both
processFile and the post-rollover refresh share the same logic.

Add test/rollover.test.ts with an in-memory RolloverIO covering the
orchestration end-to-end: rollover candidate selection, daily-folder
filtering, future-date exclusion, and the frontmatter refresh
including the enableAutoProcessing gate and folder include/exclude.
2026-05-06 08:12:36 +02:00

213 lines
7.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string> = new Map();
frontmatters: Map<string, Record<string, unknown>> = new Map();
writes: string[] = [];
add(path: string, content = "", frontmatter: Record<string, unknown> = {}) {
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<string> {
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<void> {
this.files.set(path, content);
this.writes.push(path);
return Promise.resolve();
}
getOrCreateFile(path: string): Promise<RolloverFile> {
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<string, unknown>) => void,
): Promise<void> {
const fm = this.frontmatters.get(path) ?? {};
fn(fm);
this.frontmatters.set(path, fm);
return Promise.resolve();
}
}
const settings = (overrides: Partial<MarkdownTasksSettings> = {}): 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});
});
});