obsidian-plugin-markdown-tasks/test/rolloverCore.test.ts
Felipe M. dc0acb6f30
Migrate task logbooks during rollover
When the logbook feature is enabled, a pending task's logbook is kept
and carried along with the task during rollover instead of being pruned
out as a non-task bullet. Ownership is precise: a subtask's logbook
stays with the subtask, and a completed task's logbook is not migrated.
2026-06-02 13:48:41 +02:00

545 lines
19 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 {describe, expect, it} from "bun:test";
import {
appendRolledOver,
buildRolloverPlan,
formatDate,
parseDateFromName,
RolloverPlanSettings,
} from "../src/rolloverCore";
const settings = (overrides: Partial<RolloverPlanSettings> = {}): RolloverPlanSettings => ({
completedSymbol: "x",
inProgressSymbol: "/",
movedSymbol: ">",
rolloverSourceAction: "mark-moved",
rolloverContext: "topmost-element",
enableLogbook: true,
logbookHeader: "**Logbook**",
...overrides,
});
describe("formatDate", () => {
it("formats with default YYYY-MM-DD", () => {
expect(formatDate(new Date(2026, 3, 5), "YYYY-MM-DD")).toBe("2026-04-05");
});
it("zero-pads month and day", () => {
expect(formatDate(new Date(2026, 0, 1), "YYYY-MM-DD")).toBe("2026-01-01");
});
it("supports custom separators", () => {
expect(formatDate(new Date(2026, 11, 31), "DD/MM/YYYY")).toBe("31/12/2026");
});
it("supports literal text", () => {
expect(formatDate(new Date(2026, 3, 5), "daily-YYYY-MM-DD")).toBe("daily-2026-04-05");
});
});
describe("parseDateFromName", () => {
it("parses YYYY-MM-DD", () => {
const d = parseDateFromName("2026-04-05", "YYYY-MM-DD");
expect(d).not.toBeNull();
expect(d.getFullYear()).toBe(2026);
expect(d.getMonth()).toBe(3);
expect(d.getDate()).toBe(5);
});
it("returns null for non-matching name", () => {
expect(parseDateFromName("not-a-date", "YYYY-MM-DD")).toBeNull();
});
it("returns null when format is wrong", () => {
expect(parseDateFromName("04/05/2026", "YYYY-MM-DD")).toBeNull();
});
it("rejects out-of-range dates", () => {
expect(parseDateFromName("2026-13-01", "YYYY-MM-DD")).toBeNull();
expect(parseDateFromName("2026-02-30", "YYYY-MM-DD")).toBeNull();
});
it("supports custom format", () => {
const d = parseDateFromName("31/12/2026", "DD/MM/YYYY");
expect(d).not.toBeNull();
expect(d.getFullYear()).toBe(2026);
expect(d.getMonth()).toBe(11);
expect(d.getDate()).toBe(31);
});
it("escapes regex metacharacters in format", () => {
const d = parseDateFromName("2026.04.05", "YYYY.MM.DD");
expect(d).not.toBeNull();
expect(d.getFullYear()).toBe(2026);
});
it("round-trips with formatDate", () => {
const original = new Date(2026, 6, 14);
const formatted = formatDate(original, "YYYY-MM-DD");
const parsed = parseDateFromName(formatted, "YYYY-MM-DD");
expect(parsed).not.toBeNull();
expect(parsed.getTime()).toBe(original.getTime());
});
});
describe("buildRolloverPlan no migration", () => {
it("leaves content unchanged when there are no tasks", () => {
const input = "# Title\n\nJust some prose.\n";
const plan = buildRolloverPlan(input, settings());
expect(plan.newContent).toBe(input);
expect(plan.migratedBlocks).toEqual([]);
});
it("does not migrate when only completed tasks exist", () => {
const input = "- [x] done\n- [x] also done\n";
const plan = buildRolloverPlan(input, settings());
expect(plan.migratedBlocks).toEqual([]);
});
it("does not migrate when only discarded tasks exist", () => {
const input = "- [-] discarded\n";
const plan = buildRolloverPlan(input, settings());
expect(plan.migratedBlocks).toEqual([]);
});
it("does not migrate already-moved tasks", () => {
const input = "- [>] moved already\n";
const plan = buildRolloverPlan(input, settings());
expect(plan.migratedBlocks).toEqual([]);
});
});
describe("buildRolloverPlan pending detection", () => {
it("treats [ ] (open) as pending", () => {
const plan = buildRolloverPlan("- [ ] open\n", settings({rolloverContext: "none"}));
expect(plan.migratedBlocks).toEqual([["- [ ] open"]]);
});
it("treats configured in-progress symbol as pending", () => {
const plan = buildRolloverPlan("- [/] in progress\n", settings({rolloverContext: "none"}));
expect(plan.migratedBlocks).toEqual([["- [/] in progress"]]);
});
it("respects a custom in-progress symbol", () => {
const custom = settings({inProgressSymbol: "?", rolloverContext: "none"});
const plan = buildRolloverPlan("- [?] custom\n- [/] not pending now\n", custom);
expect(plan.migratedBlocks).toEqual([["- [?] custom"]]);
});
it("ignores discarded [-] tasks", () => {
const plan = buildRolloverPlan(
"- [ ] open\n- [-] discarded\n",
settings({rolloverContext: "none"}),
);
expect(plan.migratedBlocks).toEqual([["- [ ] open"]]);
});
});
describe("buildRolloverPlan source action", () => {
it("marks pending tasks as moved (default)", () => {
const plan = buildRolloverPlan(
"- [ ] open\n- [x] done\n",
settings({rolloverContext: "none"}),
);
expect(plan.newContent).toBe("- [>] open\n- [x] done\n");
});
it("removes pending tasks when action is remove", () => {
const plan = buildRolloverPlan(
"- [ ] open\n- [x] done\n",
settings({rolloverSourceAction: "remove", rolloverContext: "none"}),
);
expect(plan.newContent).toBe("- [x] done\n");
});
it("uses a custom moved symbol", () => {
const plan = buildRolloverPlan(
"- [ ] open\n",
settings({movedSymbol: "~", rolloverContext: "none"}),
);
expect(plan.newContent).toBe("- [~] open\n");
});
it("does not touch non-pending tasks in the source", () => {
const input = "- This\n - [ ] pending\n - [x] done\n - [-] discarded\n";
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-list"}));
expect(plan.newContent).toBe("- This\n - [>] pending\n - [x] done\n - [-] discarded\n");
});
});
describe("buildRolloverPlan surrounding context", () => {
const input = ["# H1", "## H2", "- Group", " - [ ] task", ""].join("\n");
it("none migrates only the task subtree", () => {
const plan = buildRolloverPlan(input, settings({rolloverContext: "none"}));
expect(plan.migratedBlocks).toEqual([["- [ ] task"]]);
});
it("topmost-task behaves like none", () => {
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-task"}));
expect(plan.migratedBlocks).toEqual([["- [ ] task"]]);
});
it("topmost-list returns the topmost non-task list ancestor subtree", () => {
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-list"}));
expect(plan.migratedBlocks).toEqual([["- Group", " - [ ] task"]]);
});
it("topmost-list falls back to root task when no list ancestor", () => {
const plain = "## H2\n- [ ] task\n";
const plan = buildRolloverPlan(plain, settings({rolloverContext: "topmost-list"}));
expect(plan.migratedBlocks).toEqual([["- [ ] task"]]);
});
it("topmost-heading returns the outermost heading section", () => {
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"}));
expect(plan.migratedBlocks).toEqual([["# H1", "## H2", "- Group", " - [ ] task"]]);
});
it("topmost-heading falls back to root task when no heading", () => {
const plain = "- Group\n - [ ] task\n";
const plan = buildRolloverPlan(plain, settings({rolloverContext: "topmost-heading"}));
expect(plan.migratedBlocks).toEqual([["- [ ] task"]]);
});
it("nearest-heading returns the closest heading section above the task", () => {
const plan = buildRolloverPlan(input, settings({rolloverContext: "nearest-heading"}));
expect(plan.migratedBlocks).toEqual([["## H2", "- Group", " - [ ] task"]]);
});
it("nearest-heading falls back to root task when no heading", () => {
const plain = "- Group\n - [ ] task\n";
const plan = buildRolloverPlan(plain, settings({rolloverContext: "nearest-heading"}));
expect(plan.migratedBlocks).toEqual([["- [ ] task"]]);
});
it("topmost-element prefers heading over list ancestor", () => {
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-element"}));
expect(plan.migratedBlocks).toEqual([["# H1", "## H2", "- Group", " - [ ] task"]]);
});
it("topmost-element uses list when no heading is present", () => {
const noHeading = "- Group\n - [ ] task\n";
const plan = buildRolloverPlan(noHeading, settings({rolloverContext: "topmost-element"}));
expect(plan.migratedBlocks).toEqual([["- Group", " - [ ] task"]]);
});
it("topmost-element falls back to task subtree when neither heading nor list", () => {
const bare = "- [ ] task\n";
const plan = buildRolloverPlan(bare, settings({rolloverContext: "topmost-element"}));
expect(plan.migratedBlocks).toEqual([["- [ ] task"]]);
});
});
describe("buildRolloverPlan tree pruning", () => {
it("user example: prunes branches without pending tasks", () => {
const input = [
"- This",
" - [ ] tasks pending",
" - Heading",
" - [ ] Pending",
" - Head 2",
" - [x] Completed",
" - Head 3",
" - Comment",
" - Comment 2",
" - [x] Completed",
"",
].join("\n");
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-list"}));
expect(plan.migratedBlocks).toEqual([
["- This", " - [ ] tasks pending", " - Heading", " - [ ] Pending"],
]);
});
it("keeps non-task containers that have pending descendants", () => {
const input = "- Group\n - [ ] open\n";
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-list"}));
expect(plan.migratedBlocks).toEqual([["- Group", " - [ ] open"]]);
});
it("drops non-task containers with only completed children", () => {
const input = [
"- Outer",
" - Empty group",
" - [x] done",
" - Active group",
" - [ ] open",
"",
].join("\n");
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-list"}));
expect(plan.migratedBlocks).toEqual([["- Outer", " - Active group", " - [ ] open"]]);
});
it("keeps a heading when one of its tasks is pending", () => {
const input = "## Plan\n- [x] done\n- [ ] open\n";
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"}));
expect(plan.migratedBlocks).toEqual([["## Plan", "- [ ] open"]]);
});
it("drops completed tasks even within the migrated block", () => {
const input = "## Plan\n- [ ] open\n- [x] done\n- [-] discarded\n- [ ] also open\n";
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"}));
expect(plan.migratedBlocks).toEqual([["## Plan", "- [ ] open", "- [ ] also open"]]);
});
});
describe("buildRolloverPlan multiple anchors", () => {
it("emits separate blocks for separate root tasks with no surrounding context", () => {
const input = "- [ ] first\n\n- [ ] second\n";
const plan = buildRolloverPlan(input, settings({rolloverContext: "none"}));
expect(plan.migratedBlocks).toEqual([["- [ ] first"], ["- [ ] second"]]);
});
it("merges overlapping anchors (two pending under same heading)", () => {
const input = "## Plan\n- [ ] one\n- [ ] two\n";
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"}));
expect(plan.migratedBlocks).toEqual([["## Plan", "- [ ] one", "- [ ] two"]]);
});
});
describe("buildRolloverPlan dedent", () => {
it("dedents migrated content by the anchor's indent", () => {
const input = " - Outer\n - [ ] task\n";
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-list"}));
expect(plan.migratedBlocks).toEqual([["- Outer", " - [ ] task"]]);
});
});
describe("buildRolloverPlan migration footer", () => {
it("appends the migration footer to the source when target is provided", () => {
const plan = buildRolloverPlan(
"- [ ] open\n",
settings({rolloverContext: "none"}),
"2026-04-29",
);
expect(plan.newContent).toBe(
"- [>] open\n\nTasks were automatically migrated to [[2026-04-29]]\n",
);
});
it("does not append a footer when no target is provided", () => {
const plan = buildRolloverPlan("- [ ] open\n", settings({rolloverContext: "none"}));
expect(plan.newContent).toBe("- [>] open\n");
});
it("does not append a footer when nothing was migrated", () => {
const plan = buildRolloverPlan(
"- [x] done\n",
settings({rolloverContext: "none"}),
"2026-04-29",
);
expect(plan.newContent).toBe("- [x] done\n");
});
it("does not duplicate an existing footer for the same target", () => {
const input = "- [ ] open\n\nTasks were automatically migrated to [[2026-04-29]]\n";
const plan = buildRolloverPlan(input, settings({rolloverContext: "none"}), "2026-04-29");
expect(plan.newContent).toBe(
"- [>] open\n\nTasks were automatically migrated to [[2026-04-29]]\n",
);
});
it("appends a new footer when targeting a different note", () => {
const input = "- [ ] open\n\nTasks were automatically migrated to [[2026-04-28]]\n";
const plan = buildRolloverPlan(input, settings({rolloverContext: "none"}), "2026-04-29");
expect(plan.newContent).toBe(
"- [>] open\n\nTasks were automatically migrated to [[2026-04-28]]\n\nTasks were automatically migrated to [[2026-04-29]]\n",
);
});
it("works alongside the remove source action", () => {
const plan = buildRolloverPlan(
"- [ ] open\n",
settings({rolloverContext: "none", rolloverSourceAction: "remove"}),
"2026-04-29",
);
expect(plan.newContent).toBe("Tasks were automatically migrated to [[2026-04-29]]\n");
});
});
describe("buildRolloverPlan code blocks and frontmatter", () => {
it("does not migrate task-like lines inside a fenced code block", () => {
const input = ["```markdown", "- [ ] not real", "```", ""].join("\n");
const plan = buildRolloverPlan(input, settings());
expect(plan.migratedBlocks).toEqual([]);
expect(plan.newContent).toBe(input);
});
it("does not migrate task-like lines inside a tilde-fenced code block", () => {
const input = ["~~~", "- [ ] not real", "~~~", ""].join("\n");
const plan = buildRolloverPlan(input, settings());
expect(plan.migratedBlocks).toEqual([]);
expect(plan.newContent).toBe(input);
});
it("treats code-block content as inert when migrating a heading section", () => {
const input = ["## Plan", "- [ ] real", "```", "- [ ] code task", "```", ""].join("\n");
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"}));
expect(plan.migratedBlocks).toEqual([["## Plan", "- [ ] real"]]);
});
it("does not treat a heading inside a code block as a heading ancestor", () => {
const input = ["```", "# Fake heading", "```", "- [ ] real"].join("\n");
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"}));
expect(plan.migratedBlocks).toEqual([["- [ ] real"]]);
});
it("does not mark code-block tasks as moved in the source", () => {
const input = ["```", "- [ ] not real", "```", "- [ ] real"].join("\n");
const plan = buildRolloverPlan(input, settings({rolloverContext: "none"}));
expect(plan.newContent).toBe(["```", "- [ ] not real", "```", "- [>] real"].join("\n"));
});
it("ignores task-like lines inside YAML frontmatter", () => {
const input = ["---", "- [ ] not a task", "---", "- [x] done"].join("\n");
const plan = buildRolloverPlan(input, settings());
expect(plan.migratedBlocks).toEqual([]);
expect(plan.newContent).toBe(input);
});
});
describe("appendRolledOver", () => {
const block = (lines: string[]) => lines;
it("returns destination unchanged when no blocks", () => {
expect(appendRolledOver("hello\n", [], "## Past tasks")).toBe("hello\n");
});
it("inserts header and content into an empty destination", () => {
const result = appendRolledOver("", [block(["- [ ] x"])], "## Past tasks");
expect(result).toBe("## Past tasks\n\n- [ ] x\n");
});
it("appends header and content when not present", () => {
const result = appendRolledOver("# Day\n", [block(["- [ ] x"])], "## Past tasks");
expect(result).toBe("# Day\n\n## Past tasks\n\n- [ ] x\n");
});
it("appends content under existing header", () => {
const dest = "# Day\n\n## Past tasks\n\n- [ ] earlier\n";
const result = appendRolledOver(dest, [block(["- [ ] new"])], "## Past tasks");
expect(result).toBe("# Day\n\n## Past tasks\n\n- [ ] earlier\n- [ ] new\n");
});
it("joins multiple blocks", () => {
const result = appendRolledOver(
"",
[block(["- [ ] a"]), block(["- [ ] b"])],
"## Past tasks",
);
expect(result).toBe("## Past tasks\n\n- [ ] a\n- [ ] b\n");
});
it("normalizes destination missing trailing newline", () => {
const result = appendRolledOver("# Day", [block(["- [ ] x"])], "## Past tasks");
expect(result).toBe("# Day\n\n## Past tasks\n\n- [ ] x\n");
});
});
describe("buildRolloverPlan logbook migration", () => {
const nested = [
"- [ ] My task",
" - **Logbook**",
" - 2026-05-31: did stuff",
" - 2026-06-01: more",
].join("\n");
it("migrates a nested logbook along with its task", () => {
const plan = buildRolloverPlan(nested, settings({rolloverContext: "topmost-task"}));
expect(plan.migratedBlocks).toEqual([
[
"- [ ] My task",
" - **Logbook**",
" - 2026-05-31: did stuff",
" - 2026-06-01: more",
],
]);
});
it("migrates the logbook regardless of surrounding-context setting", () => {
for (const ctx of ["none", "topmost-list", "topmost-element"] as const) {
const plan = buildRolloverPlan(nested, settings({rolloverContext: ctx}));
expect(plan.migratedBlocks[0]).toContain(" - **Logbook**");
expect(plan.migratedBlocks[0]).toContain(" - 2026-05-31: did stuff");
}
});
it("dedents a deeply nested logbook with the task", () => {
const input = ["## Work", "- [ ] task", " - **Logbook**", " - 2026-05-31: note"].join(
"\n",
);
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"}));
expect(plan.migratedBlocks).toEqual([
["## Work", "- [ ] task", " - **Logbook**", " - 2026-05-31: note"],
]);
});
it("leaves the logbook in the source under the moved task (mark-moved)", () => {
const plan = buildRolloverPlan(nested, settings({rolloverContext: "topmost-task"}));
expect(plan.newContent).toBe(
[
"- [>] My task",
" - **Logbook**",
" - 2026-05-31: did stuff",
" - 2026-06-01: more",
].join("\n"),
);
});
it("does not migrate a completed task's logbook", () => {
const input = [
"- [x] done",
" - **Logbook**",
" - 2026-05-30: history",
"- [ ] todo",
" - **Logbook**",
" - 2026-05-31: note",
].join("\n");
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-task"}));
expect(plan.migratedBlocks).toEqual([
["- [ ] todo", " - **Logbook**", " - 2026-05-31: note"],
]);
});
it("keeps each task's own logbook when a subtask has one too", () => {
const input = [
"- [ ] parent",
" - **Logbook**",
" - 2026-05-31: parent log",
" - [ ] child",
" - **Logbook**",
" - 2026-05-31: child log",
].join("\n");
const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-task"}));
expect(plan.migratedBlocks).toEqual([
[
"- [ ] parent",
" - **Logbook**",
" - 2026-05-31: parent log",
" - [ ] child",
" - **Logbook**",
" - 2026-05-31: child log",
],
]);
});
it("honors a custom logbook header", () => {
const input = ["- [ ] task", " - Journal", " - 2026-05-31: note"].join("\n");
const plan = buildRolloverPlan(
input,
settings({rolloverContext: "topmost-task", logbookHeader: "Journal"}),
);
expect(plan.migratedBlocks).toEqual([
["- [ ] task", " - Journal", " - 2026-05-31: note"],
]);
});
it("drops the logbook when the feature is disabled", () => {
const plan = buildRolloverPlan(
nested,
settings({rolloverContext: "topmost-task", enableLogbook: false}),
);
expect(plan.migratedBlocks).toEqual([["- [ ] My task"]]);
});
});