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.
213 lines
7.6 KiB
TypeScript
213 lines
7.6 KiB
TypeScript
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});
|
||
});
|
||
});
|