`-per-line style emitted by Google's editors belong here — treating
+ * them as paragraphs would double-space every list.
+ */
+const LINE_TAGS = new Set([
+ "DD",
+ "DIV",
+ "DT",
+ "FIGCAPTION",
+ "LI",
+ "TBODY",
+ "TD",
+ "TFOOT",
+ "TH",
+ "THEAD",
+ "TR",
+]);
+
+/**
+ * Elements whose content is markup rather than prose.
+ */
+const NON_CONTENT_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "HEAD"]);
+
+/**
+ * A closing tag is strong evidence that a string contains markup. Void tags
+ * cover common standalone elements such as `
`. Requiring one of those
+ * forms preserves ordinary text such as `Contact
` and
+ * placeholders such as ``.
+ */
+const PAIRED_HTML_TAG_PATTERN = /<([a-z][\w:-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1\s*>/i;
+const VOID_HTML_TAG_PATTERN =
+ /<(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)(?:\s[^>]*)?\/?\s*>/i;
+const HTML_COMMENT_PATTERN = //;
+
+/**
+ * Reports whether a description looks like HTML rather than plain text.
+ */
+export function looksLikeHtml(value: string): boolean {
+ return (
+ PAIRED_HTML_TAG_PATTERN.test(value) ||
+ VOID_HTML_TAG_PATTERN.test(value) ||
+ HTML_COMMENT_PATTERN.test(value)
+ );
+}
+
+/**
+ * A flattened fragment: literal text, or a line break whose `weight` is the
+ * number of newlines it requests. Adjacent breaks coalesce to their strongest
+ * weight, so nested block elements never stack up blank lines.
+ */
+type Fragment = string | { weight: number };
+
+function breakWeightOf(tag: string): number {
+ if (PARAGRAPH_TAGS.has(tag)) {
+ return 2;
+ }
+ if (LINE_TAGS.has(tag)) {
+ return 1;
+ }
+ return 0;
+}
+
+function flattenAnchor(element: Element, out: Fragment[]): void {
+ const labelFragments: Fragment[] = [];
+ element.childNodes.forEach((child) => flattenNode(child, labelFragments));
+ const label = tidy(joinFragments(labelFragments));
+ const href = (element.getAttribute("href") ?? "").trim();
+
+ if (!href || href === label) {
+ out.push(label);
+ return;
+ }
+
+ if (!label) {
+ out.push(href);
+ return;
+ }
+
+ // Keep the target reachable once the anchor markup is gone.
+ out.push(`${label} (${href})`);
+}
+
+function flattenNode(node: Node, out: Fragment[]): void {
+ if (node.nodeType === 3 /* TEXT_NODE */) {
+ out.push(node.textContent ?? "");
+ return;
+ }
+
+ if (node.nodeType !== 1 /* ELEMENT_NODE */) {
+ return;
+ }
+
+ const element = node as Element;
+ const tag = element.tagName.toUpperCase();
+
+ if (NON_CONTENT_TAGS.has(tag)) {
+ return;
+ }
+
+ if (tag === "BR") {
+ out.push({ weight: 1 });
+ return;
+ }
+
+ if (tag === "A") {
+ flattenAnchor(element, out);
+ return;
+ }
+
+ const weight = breakWeightOf(tag);
+ if (weight > 0) {
+ out.push({ weight });
+ }
+
+ if (tag === "LI") {
+ out.push("- ");
+ }
+
+ element.childNodes.forEach((child) => flattenNode(child, out));
+
+ if (weight > 0) {
+ out.push({ weight });
+ }
+}
+
+/**
+ * Joins fragments, coalescing runs of breaks into the strongest one and
+ * dropping whitespace that only exists to indent the source markup.
+ */
+function joinFragments(fragments: Fragment[]): string {
+ let result = "";
+ let pendingBreak = 0;
+
+ for (const fragment of fragments) {
+ if (typeof fragment !== "string") {
+ pendingBreak = Math.max(pendingBreak, fragment.weight);
+ continue;
+ }
+
+ if (fragment.length === 0) {
+ continue;
+ }
+
+ // Whitespace between block elements is markup indentation, not content.
+ if (pendingBreak > 0 && fragment.trim().length === 0) {
+ continue;
+ }
+
+ if (result.length > 0 && pendingBreak > 0) {
+ result += "\n".repeat(pendingBreak);
+ }
+ pendingBreak = 0;
+ result += fragment;
+ }
+
+ return result;
+}
+
+/**
+ * Collapses the flattened text into tidy plain text: normal spaces, no trailing
+ * whitespace, and at most one blank line between paragraphs.
+ */
+function tidy(text: string): string {
+ return text
+ .replace(/\u00a0/g, " ")
+ .replace(/\r\n?/g, "\n")
+ .split("\n")
+ .map((line) => line.replace(/[ \t]+/g, " ").trim())
+ .join("\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+}
+
+/**
+ * Converts an HTML event description to plain text, preserving paragraph
+ * breaks, list structure, and link targets.
+ *
+ * Obsidian sanitizes the markup into a detached fragment before it is read.
+ */
+export function htmlToPlainText(html: string): string {
+ const fragment = sanitizeHTMLToDom(html);
+ const fragments: Fragment[] = [];
+ fragment.childNodes.forEach((child) => flattenNode(child, fragments));
+ return tidy(joinFragments(fragments));
+}
+
+/**
+ * Normalizes a provider event description to plain text.
+ *
+ * Plain-text descriptions are returned unchanged. Descriptions containing HTML
+ * are flattened, with entities decoded as a side effect of DOM parsing. Returns
+ * `undefined` when there is no usable text left, so existing truthiness checks
+ * on the field continue to skip empty descriptions.
+ */
+export function normalizeCalendarDescription(value: string | undefined | null): string | undefined {
+ if (typeof value !== "string" || value.length === 0) {
+ return undefined;
+ }
+
+ if (!looksLikeHtml(value)) {
+ return value;
+ }
+
+ const plainText = htmlToPlainText(value);
+ return plainText.length > 0 ? plainText : undefined;
+}
diff --git a/src/utils/linkAliasUtils.ts b/src/utils/linkAliasUtils.ts
new file mode 100644
index 000000000..053a0d8d0
--- /dev/null
+++ b/src/utils/linkAliasUtils.ts
@@ -0,0 +1,54 @@
+function getWikilinkDisplayText(linkText: string): string {
+ const display = linkText.includes("|")
+ ? linkText.split("|").pop() || linkText
+ : linkText;
+ return display.split("/").pop()?.replace(/\.md$/i, "") || display;
+}
+
+export function sanitizeLinkAliasText(alias: string): string {
+ let sanitized = alias;
+ for (let pass = 0; pass < 4; pass++) {
+ const previous = sanitized;
+ sanitized = sanitized.replace(/\[\[([^[\]]+)\]\]/g, (_match, inner) =>
+ getWikilinkDisplayText(String(inner)).trim()
+ );
+ sanitized = sanitized.replace(
+ /\[([^\]]+)\]\((<[^>]+>|[^)]+)\)/g,
+ (_match, label) => String(label).trim()
+ );
+ if (sanitized === previous) break;
+ }
+
+ return sanitized.replace(/\s+/g, " ").trim();
+}
+
+export function sanitizeGeneratedLinkAlias(linkText: string): string {
+ if (linkText.startsWith("[[") && linkText.endsWith("]]")) {
+ const inner = linkText.slice(2, -2);
+ const aliasSeparator = inner.indexOf("|");
+ if (aliasSeparator === -1) {
+ return linkText;
+ }
+
+ const target = inner.slice(0, aliasSeparator);
+ const alias = inner.slice(aliasSeparator + 1);
+ const sanitizedAlias = sanitizeLinkAliasText(alias);
+
+ return sanitizedAlias ? `[[${target}|${sanitizedAlias}]]` : `[[${target}]]`;
+ }
+
+ if (linkText.startsWith("[") && linkText.endsWith(")")) {
+ const aliasSeparator = linkText.lastIndexOf("](");
+ if (aliasSeparator <= 0) {
+ return linkText;
+ }
+
+ const alias = linkText.slice(1, aliasSeparator);
+ const destination = linkText.slice(aliasSeparator + 2, -1);
+ const sanitizedAlias = sanitizeLinkAliasText(alias);
+
+ return sanitizedAlias ? `[${sanitizedAlias}](${destination})` : linkText;
+ }
+
+ return linkText;
+}
diff --git a/styles/bases-views.css b/styles/bases-views.css
index 6212e0c18..0a12253a2 100644
--- a/styles/bases-views.css
+++ b/styles/bases-views.css
@@ -194,6 +194,18 @@ body.is-mobile .tn-bases-tasknotes-list {
scroll-padding-bottom: calc(128px + env(safe-area-inset-bottom, 0px));
}
+body.is-mobile .internal-embed .tn-tasknotesTaskList .tn-bases-items-container,
+body.is-mobile .markdown-embed .tn-tasknotesTaskList .tn-bases-items-container {
+ padding-bottom: 0;
+ scroll-padding-bottom: 0;
+}
+
+body.is-mobile .internal-embed .tn-bases-tasknotes-list,
+body.is-mobile .markdown-embed .tn-bases-tasknotes-list {
+ padding-bottom: var(--tn-spacing-sm);
+ scroll-padding-bottom: 0;
+}
+
/* Sidebar and popout note embeds already have a constrained note scroller. Let
that parent own vertical scrolling so Task List embeds do not reserve empty
viewport-height space inside the note. */
diff --git a/styles/settings-view.css b/styles/settings-view.css
index 8208f4ebf..6bab54f56 100644
--- a/styles/settings-view.css
+++ b/styles/settings-view.css
@@ -22,11 +22,14 @@ body:not(.is-mobile) .modal.mod-settings .tasknotes-settings .settings-view__tab
SETTINGS HEADER
================================================ */
+/* Wrap instead of clipping: at constrained pane widths the horizontal
+ overflow used to hide the final tab (Integrations) entirely (#2222). */
.tasknotes-plugin .settings-view__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
- gap: var(--tn-spacing-md);
+ flex-wrap: wrap;
+ gap: var(--tn-spacing-xs) var(--tn-spacing-md);
margin-bottom: var(--tn-spacing-md);
}
@@ -34,6 +37,7 @@ body:not(.is-mobile) .modal.mod-settings .tasknotes-settings .settings-view__tab
display: flex;
flex-shrink: 0;
justify-content: flex-end;
+ margin-inline-start: auto;
}
.tasknotes-plugin .settings-header-link {
@@ -69,15 +73,18 @@ body:not(.is-mobile) .modal.mod-settings .tasknotes-settings .settings-view__tab
.tasknotes-plugin .settings-view__tab-nav {
display: flex;
align-items: center;
+ flex: 1 1 36rem;
+ flex-wrap: wrap;
gap: var(--tn-spacing-xs);
- width: fit-content;
+ min-width: 0;
+ width: auto;
max-width: 100%;
margin-bottom: 0;
padding: 0;
border: 0;
background: transparent;
- overflow-x: auto;
- white-space: nowrap;
+ overflow: visible;
+ white-space: normal;
scrollbar-width: thin;
}
diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts
index 6198e9c4f..91635d785 100644
--- a/tests/__mocks__/obsidian.ts
+++ b/tests/__mocks__/obsidian.ts
@@ -1084,6 +1084,12 @@ export function setTooltip(element: HTMLElement, tooltip: string, options?: { pl
element.classList.add('has-tooltip');
}
+export function sanitizeHTMLToDom(html: string): DocumentFragment {
+ const template = document.createElement('template');
+ template.innerHTML = html;
+ return template.content;
+}
+
// API version check utilities (added in Obsidian 1.11.0)
export function requireApiVersion(version: string): boolean {
// Mock implementation - returns true for testing purposes
@@ -1181,6 +1187,7 @@ export const MockObsidian = {
Notice,
setIcon,
setTooltip,
+ sanitizeHTMLToDom,
};
// Simple debounce mock: return the original function for test determinism
@@ -1232,6 +1239,7 @@ export default {
Events,
setIcon,
setTooltip,
+ sanitizeHTMLToDom,
parseFrontMatterAliases,
parseFrontMatterTags,
parseLinktext,
diff --git a/tests/services/GoogleCalendarService.test.ts b/tests/services/GoogleCalendarService.test.ts
index 5443c0fb1..1f1b9e8d6 100644
--- a/tests/services/GoogleCalendarService.test.ts
+++ b/tests/services/GoogleCalendarService.test.ts
@@ -8,7 +8,12 @@ import { GoogleCalendarError, RateLimitError, EventNotFoundError } from '../../s
jest.mock('obsidian', () => ({
Notice: jest.fn(),
requestUrl: jest.fn(),
- Platform: { isDesktopApp: true }
+ Platform: { isDesktopApp: true },
+ sanitizeHTMLToDom: (html: string) => {
+ const template = document.createElement('template');
+ template.innerHTML = html;
+ return template.content;
+ }
}));
describe('GoogleCalendarService', () => {
@@ -157,6 +162,39 @@ describe('GoogleCalendarService', () => {
expect(events[1].allDay).toBe(true);
});
+ test('should normalize HTML descriptions without changing angle-bracketed text', async () => {
+ mockRequestUrl.mockResolvedValueOnce({
+ status: 200,
+ json: {
+ items: [
+ {
+ id: 'html-description',
+ summary: 'HTML description',
+ description: 'Agenda
',
+ start: { date: '2025-10-22' },
+ end: { date: '2025-10-23' }
+ },
+ {
+ id: 'plain-description',
+ summary: 'Plain description',
+ description: 'Contact ; venue ',
+ start: { date: '2025-10-23' },
+ end: { date: '2025-10-24' }
+ }
+ ],
+ nextSyncToken: 'sync-token-123'
+ },
+ text: '',
+ arrayBuffer: new ArrayBuffer(0),
+ headers: {}
+ });
+
+ const events = await service.getEvents('primary');
+
+ expect(events[0].description).toBe('Agenda\n\n- First item');
+ expect(events[1].description).toBe('Contact ; venue ');
+ });
+
test('should use sync token for incremental updates', async () => {
// Set up sync token
mockPlugin.settings!.googleCalendarSyncTokens = { 'primary': 'old-sync-token' };
diff --git a/tests/services/OAuthService.revocation.test.ts b/tests/services/OAuthService.revocation.test.ts
index 4c8b105aa..934036c96 100644
--- a/tests/services/OAuthService.revocation.test.ts
+++ b/tests/services/OAuthService.revocation.test.ts
@@ -80,8 +80,17 @@ describe("OAuthService token revocation", () => {
headers: {},
});
+ const connectionGeneration = sut.getConnectionGeneration("google");
+ await expect(
+ sut.isConnectionGenerationCurrent("google", connectionGeneration)
+ ).resolves.toBe(true);
+
await sut.disconnect("google");
+ expect(sut.getConnectionGeneration("google")).toBe(connectionGeneration + 1);
+ await expect(
+ sut.isConnectionGenerationCurrent("google", connectionGeneration)
+ ).resolves.toBe(false);
expect(mockRequestUrl).toHaveBeenCalledTimes(2);
const firstRequest = getRequestCall(mockRequestUrl, 0);
const secondRequest = getRequestCall(mockRequestUrl, 1);
diff --git a/tests/services/TaskCalendarSyncService.test.ts b/tests/services/TaskCalendarSyncService.test.ts
index 051c4803c..5f832d7fe 100644
--- a/tests/services/TaskCalendarSyncService.test.ts
+++ b/tests/services/TaskCalendarSyncService.test.ts
@@ -190,7 +190,8 @@ describe("TaskCalendarSyncService", () => {
{
summary: "✓ Task Title",
description: undefined
- }
+ },
+ expect.any(Number)
);
});
diff --git a/tests/unit/bases/calendarMaterializedOccurrences.test.ts b/tests/unit/bases/calendarMaterializedOccurrences.test.ts
index 881fa25c0..eefd68977 100644
--- a/tests/unit/bases/calendarMaterializedOccurrences.test.ts
+++ b/tests/unit/bases/calendarMaterializedOccurrences.test.ts
@@ -5,6 +5,7 @@ jest.mock("../../../src/utils/helpers", () => ({
import {
generateCalendarEvents,
+ getOccurrenceDateForEvent,
getTargetDateForEvent,
type CalendarEvent,
} from "../../../src/bases/calendar-core";
@@ -186,3 +187,55 @@ describe("calendar materialized occurrences", () => {
expect(formatDateForStorage(targetDate)).toBe("2026-06-02");
});
});
+
+describe("calendar recurring occurrence context", () => {
+ const recurringTask = TaskFactory.createRecurringTask("FREQ=WEEKLY", {
+ scheduled: "2026-06-02T09:00",
+ });
+
+ it("uses instanceDate instead of the rendered event date", () => {
+ const occurrenceDate = getOccurrenceDateForEvent(recurringTask, {
+ event: {
+ start: localDate(2026, 5, 9),
+ extendedProps: { instanceDate: "2026-06-02" },
+ },
+ });
+
+ expect(occurrenceDate && formatDateForStorage(occurrenceDate)).toBe("2026-06-02");
+ });
+
+ it("does not treat a recurring task's unrelated calendar event as an occurrence", () => {
+ const occurrenceDate = getOccurrenceDateForEvent(recurringTask, {
+ event: {
+ start: localDate(2026, 5, 9),
+ extendedProps: { eventType: "timeEntry" },
+ },
+ });
+
+ expect(occurrenceDate).toBeUndefined();
+ });
+
+ it("requires a valid date-only instanceDate", () => {
+ const occurrenceDate = getOccurrenceDateForEvent(recurringTask, {
+ event: {
+ extendedProps: { instanceDate: "not-a-date" },
+ },
+ });
+
+ expect(occurrenceDate).toBeUndefined();
+ });
+
+ it("does not promote materialized occurrence notes to recurring parent context", () => {
+ const occurrenceTask = TaskFactory.createTask({
+ recurrence_parent: "[[Tasks/parent]]",
+ occurrence_date: "2026-06-02",
+ });
+ const occurrenceDate = getOccurrenceDateForEvent(occurrenceTask, {
+ event: {
+ extendedProps: { instanceDate: "2026-06-02" },
+ },
+ });
+
+ expect(occurrenceDate).toBeUndefined();
+ });
+});
diff --git a/tests/unit/components/taskContextMenu.complete.test.ts b/tests/unit/components/taskContextMenu.complete.test.ts
new file mode 100644
index 000000000..362064336
--- /dev/null
+++ b/tests/unit/components/taskContextMenu.complete.test.ts
@@ -0,0 +1,365 @@
+import { App, Menu } from "obsidian";
+import { TaskContextMenu } from "../../../src/components/TaskContextMenu";
+import { createI18nService } from "../../../src/i18n";
+import { formatDateForStorage, getTodayString } from "../../../src/utils/dateUtils";
+import type TaskNotesPlugin from "../../../src/main";
+import type { TaskInfo } from "../../../src/types";
+
+type MockMenuItem = Record | { type: string };
+type MockMenu = { items: MockMenuItem[] };
+
+const menuMock = Menu as unknown as jest.Mock;
+
+function createPlugin(): TaskNotesPlugin {
+ const app = new App();
+ return {
+ app,
+ i18n: createI18nService(),
+ settings: {
+ defaultTaskStatus: "open",
+ customStatuses: [
+ { value: "open", label: "Open", order: 0 },
+ { value: "done", label: "Done", order: 1, isCompleted: true },
+ ],
+ customPriorities: [{ value: "normal", label: "Normal", weight: 0 }],
+ calendarViewSettings: { enableTimeblocking: false },
+ useFrontmatterMarkdownLinks: true,
+ },
+ statusManager: {
+ getAllStatuses: jest.fn(() => [
+ { value: "open", label: "Open" },
+ { value: "done", label: "Done" },
+ ]),
+ getNonCompletionStatuses: jest.fn(() => [{ value: "open", label: "Open" }]),
+ getCompletedStatuses: jest.fn(() => ["done"]),
+ isCompletedStatus: jest.fn((status: string) => status === "done"),
+ },
+ priorityManager: {
+ getAllPriorities: jest.fn(() => [{ value: "normal", label: "Normal" }]),
+ getPrioritiesByWeight: jest.fn(() => [{ value: "normal", label: "Normal" }]),
+ },
+ taskService: {
+ toggleRecurringTaskSkipped: jest.fn(),
+ updateBlockingRelationships: jest.fn(),
+ },
+ cacheManager: {
+ getAllContexts: jest.fn(() => []),
+ getAllTasks: jest.fn(() => []),
+ getTaskInfo: jest.fn(),
+ },
+ updateTaskProperty: jest.fn(),
+ toggleRecurringTaskComplete: jest.fn(),
+ getActiveTimeSession: jest.fn(() => null),
+ stopTimeTracking: jest.fn(),
+ startTimeTracking: jest.fn(),
+ openDueDateModal: jest.fn(),
+ openScheduledDateModal: jest.fn(),
+ openTimeEntryEditor: jest.fn(),
+ toggleTaskArchive: jest.fn(),
+ openTaskEditModal: jest.fn(),
+ openTaskCreationModal: jest.fn(),
+ } as unknown as TaskNotesPlugin;
+}
+
+function task(overrides: Partial = {}): TaskInfo {
+ return {
+ id: "Tasks/t.md",
+ path: "Tasks/t.md",
+ title: "T",
+ status: "open",
+ priority: "normal",
+ complete_instances: [],
+ skipped_instances: [],
+ ...overrides,
+ } as TaskInfo;
+}
+
+function titleOf(item: MockMenuItem): string | undefined {
+ return "type" in item ? undefined : item.setTitle?.mock.calls[0]?.[0];
+}
+
+function submenuOf(item: MockMenuItem): MockMenu | undefined {
+ if ("type" in item) return undefined;
+ const results = item.setSubmenu?.mock.results;
+ return results && results.length ? (results[results.length - 1].value as MockMenu) : undefined;
+}
+
+/** Find a menu item by title anywhere in the tree (top level or any submenu). */
+function findItem(
+ title: string,
+ menu: MockMenu | undefined = menuMock.mock.results[0]?.value as MockMenu,
+ seen = new Set()
+): Record | undefined {
+ if (!menu || seen.has(menu)) return undefined;
+ seen.add(menu);
+ for (const item of menu.items) {
+ if ("type" in item) continue;
+ if (titleOf(item) === title) return item as Record;
+ const found = findItem(title, submenuOf(item), seen);
+ if (found) return found;
+ }
+ return undefined;
+}
+
+function completionSubmenuTitles(): string[] {
+ const top = menuMock.mock.results[0].value as MockMenu;
+ const parent = top.items.find(
+ (it) => titleOf(it) === "Mark complete or skip" || titleOf(it) === "Mark complete"
+ );
+ const sub = parent ? submenuOf(parent) : undefined;
+ return sub
+ ? sub.items
+ .map(titleOf)
+ .filter((t): t is string => typeof t === "string")
+ : [];
+}
+
+describe("completion menu items", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date("2026-06-15T12:00:00Z")); // not the scheduled/due dates below
+ menuMock.mockClear();
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ menuMock.mockClear();
+ });
+
+ describe("recurring", () => {
+ const recurring = (o: Partial = {}) =>
+ task({
+ recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU",
+ recurrence_anchor: "scheduled",
+ scheduled: "2026-06-02",
+ ...o,
+ });
+
+ it("nests the four actions + skip under a 'Complete or Skip' submenu and drops the old single item", () => {
+ new TaskContextMenu({
+ task: recurring({ due: "2026-06-30" }),
+ plugin: createPlugin(),
+ targetDate: new Date("2026-06-15T12:00:00"),
+ });
+
+ const top = menuMock.mock.results[0].value as MockMenu;
+ expect(top.items.some((it) => titleOf(it) === "Mark complete or skip")).toBe(true);
+ expect(completionSubmenuTitles()).toEqual(
+ expect.arrayContaining([
+ "Completed today",
+ "Completed on schedule",
+ "Completed on due date",
+ "Completed on (pick date)",
+ "Skip instance",
+ ])
+ );
+ // old single item gone
+ expect(findItem("Mark complete for this date")).toBeUndefined();
+ });
+
+ it("records the scheduled occurrence for 'Completed on Schedule'", async () => {
+ const plugin = createPlugin();
+ const t = recurring();
+ new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") });
+
+ await findItem("Completed on schedule")?.onClick.mock.calls[0]?.[0]();
+
+ const [, date] = (plugin.toggleRecurringTaskComplete as jest.Mock).mock.calls[0];
+ expect(formatDateForStorage(date)).toBe("2026-06-02");
+ });
+
+ it("resolves 'Completed on Schedule' to scheduled for a completion-anchored recurrence (re-anchor)", async () => {
+ const plugin = createPlugin();
+ const t = recurring({ recurrence_anchor: "completion", scheduled: "2026-06-02" });
+ new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") });
+
+ await findItem("Completed on schedule")?.onClick.mock.calls[0]?.[0]();
+ const [, date] = (plugin.toggleRecurringTaskComplete as jest.Mock).mock.calls[0];
+ expect(formatDateForStorage(date)).toBe("2026-06-02");
+ });
+
+ it("records the due date for 'Completed on Due Date'", async () => {
+ const plugin = createPlugin();
+ const t = recurring({ due: "2026-06-30" });
+ new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") });
+
+ await findItem("Completed on due date")?.onClick.mock.calls[0]?.[0]();
+
+ const [, date] = (plugin.toggleRecurringTaskComplete as jest.Mock).mock.calls[0];
+ expect(formatDateForStorage(date)).toBe("2026-06-30");
+ });
+
+ it("disables 'Completed on Due Date' with a reason when there is no due date", () => {
+ new TaskContextMenu({
+ task: recurring({ due: undefined }),
+ plugin: createPlugin(),
+ targetDate: new Date("2026-06-15T12:00:00"),
+ });
+
+ expect(findItem("Completed on due date")?.setDisabled).toHaveBeenCalledWith(true);
+ expect(findItem("Completed today")?.setDisabled).not.toHaveBeenCalledWith(true);
+ });
+
+ it("shows 'Mark Incomplete' for an already-recorded instance and toggles it out", async () => {
+ const plugin = createPlugin();
+ const t = recurring({ complete_instances: ["2026-06-02"] });
+ new TaskContextMenu({
+ task: t,
+ plugin,
+ targetDate: new Date("2026-06-02T00:00:00Z"),
+ occurrenceDate: new Date("2026-06-02T00:00:00Z"),
+ });
+
+ // The asScheduled action (resolving 2026-06-02) collapses to Mark Incomplete.
+ const incompleteItem = findItem("Mark incomplete");
+ expect(incompleteItem).toBeDefined();
+
+ await incompleteItem?.onClick.mock.calls[0]?.[0]();
+ expect(plugin.toggleRecurringTaskComplete).toHaveBeenCalled();
+ });
+ });
+
+ describe("non-recurring", () => {
+ it("nests the four actions under a 'Complete' submenu (no skip, uniform labels)", () => {
+ new TaskContextMenu({
+ task: task({ scheduled: "2026-06-02", due: "2026-06-30" }),
+ plugin: createPlugin(),
+ targetDate: new Date("2026-06-15T12:00:00"),
+ });
+
+ const top = menuMock.mock.results[0].value as MockMenu;
+ expect(top.items.some((it) => titleOf(it) === "Mark complete")).toBe(true);
+ const titles = completionSubmenuTitles();
+ expect(titles).toEqual(
+ expect.arrayContaining([
+ "Completed today",
+ "Completed on schedule",
+ "Completed on due date",
+ "Completed on (pick date)",
+ ])
+ );
+ // Non-recurring has no skip action.
+ expect(titles).not.toContain("Skip instance");
+ });
+
+ it("dispatches a status change carrying the resolved completion date", async () => {
+ const plugin = createPlugin();
+ const t = task({ scheduled: "2026-06-02" });
+ new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") });
+
+ await findItem("Completed on schedule")?.onClick.mock.calls[0]?.[0]();
+
+ expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "done", {
+ completionDate: "2026-06-02",
+ });
+ });
+
+ it("records today for 'Completed Today'", async () => {
+ const plugin = createPlugin();
+ const t = task({ scheduled: "2026-06-02" });
+ new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") });
+
+ await findItem("Completed today")?.onClick.mock.calls[0]?.[0]();
+
+ expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "done", {
+ completionDate: getTodayString(),
+ });
+ });
+
+ it("shows a notice and does not dispatch when no completed status is configured", async () => {
+ const plugin = createPlugin();
+ (plugin.statusManager.getCompletedStatuses as jest.Mock).mockReturnValue([]);
+ const t = task({ scheduled: "2026-06-02" });
+ new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") });
+
+ await findItem("Completed today")?.onClick.mock.calls[0]?.[0]();
+
+ expect(plugin.updateTaskProperty).not.toHaveBeenCalled();
+ });
+
+ it("disables scheduled/due for an undated task but keeps today and pick enabled", () => {
+ new TaskContextMenu({
+ task: task(),
+ plugin: createPlugin(),
+ targetDate: new Date("2026-06-15T12:00:00"),
+ });
+
+ expect(findItem("Completed on schedule")?.setDisabled).toHaveBeenCalledWith(true);
+ expect(findItem("Completed on due date")?.setDisabled).toHaveBeenCalledWith(true);
+ expect(findItem("Completed today")?.setDisabled).not.toHaveBeenCalledWith(true);
+ expect(findItem("Completed on (pick date)")?.setDisabled).not.toHaveBeenCalledWith(true);
+ });
+
+ it("collapses to a single 'Mark Incomplete' when already completed and reverts to the default status", async () => {
+ const plugin = createPlugin();
+ const t = task({ status: "done", completedDate: "2026-06-02", scheduled: "2026-06-02" });
+ new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") });
+
+ expect(findItem("Completed today")).toBeUndefined();
+ expect(findItem("Completed on schedule")).toBeUndefined();
+ expect(findItem("Completed on (pick date)")).toBeUndefined();
+
+ const incomplete = findItem("Mark incomplete");
+ expect(incomplete).toBeDefined();
+ await incomplete?.onClick.mock.calls[0]?.[0]();
+ expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "open");
+ });
+ });
+
+ describe("menu style setting", () => {
+ it("renders completion + skip as top-level items when completionMenuAsSubmenu is false", () => {
+ const plugin = createPlugin();
+ (plugin.settings as { completionMenuAsSubmenu?: boolean }).completionMenuAsSubmenu = false;
+ new TaskContextMenu({
+ task: task({
+ recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU",
+ scheduled: "2026-06-02",
+ }),
+ plugin,
+ targetDate: new Date("2026-06-15T12:00:00"),
+ });
+
+ const top = menuMock.mock.results[0].value as MockMenu;
+ const topTitles = top.items
+ .map(titleOf)
+ .filter((t): t is string => typeof t === "string");
+ expect(topTitles).toEqual(
+ expect.arrayContaining([
+ "Completed today",
+ "Completed on schedule",
+ "Completed on due date",
+ "Completed on (pick date)",
+ "Skip instance",
+ ])
+ );
+ // No submenu wrapper in flat mode.
+ expect(topTitles).not.toContain("Mark complete or skip");
+
+ const items = top.items;
+ const firstIdx = items.findIndex((it) => titleOf(it) === "Completed today");
+ const skipIdx = items.findIndex((it) => titleOf(it) === "Skip instance");
+ expect(firstIdx).toBeGreaterThan(0);
+ expect("type" in items[firstIdx - 1]).toBe(true); // separator before "Completed today"
+ expect("type" in items[skipIdx + 1]).toBe(true); // separator after "Skip instance"
+ });
+
+ it("nests under a submenu by default (setting unset)", () => {
+ new TaskContextMenu({
+ task: task({
+ recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU",
+ scheduled: "2026-06-02",
+ }),
+ plugin: createPlugin(),
+ targetDate: new Date("2026-06-15T12:00:00"),
+ });
+
+ const top = menuMock.mock.results[0].value as MockMenu;
+ const topTitles = top.items
+ .map(titleOf)
+ .filter((t): t is string => typeof t === "string");
+ expect(topTitles).toContain("Mark complete or skip");
+ expect(topTitles).not.toContain("Completed today"); // it's inside the submenu
+ });
+ });
+});
diff --git a/tests/unit/components/taskContextMenu.occurrenceContext.test.ts b/tests/unit/components/taskContextMenu.occurrenceContext.test.ts
new file mode 100644
index 000000000..816fdaf68
--- /dev/null
+++ b/tests/unit/components/taskContextMenu.occurrenceContext.test.ts
@@ -0,0 +1,167 @@
+import { App, Menu } from "obsidian";
+import { TaskContextMenu } from "../../../src/components/TaskContextMenu";
+import { showTaskContextMenu } from "../../../src/ui/taskCardContextMenu";
+import { createI18nService } from "../../../src/i18n";
+import { formatDateForStorage } from "../../../src/utils/dateUtils";
+import type TaskNotesPlugin from "../../../src/main";
+import type { TaskInfo } from "../../../src/types";
+
+type MockMenuItem = Record | { type: string };
+type MockMenu = { items: MockMenuItem[] };
+
+const menuMock = Menu as unknown as jest.Mock;
+
+function createRecurringTask(overrides: Partial = {}): TaskInfo {
+ return {
+ id: "Tasks/recurring.md",
+ path: "Tasks/recurring.md",
+ title: "Recurring task",
+ status: "open",
+ priority: "normal",
+ recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU",
+ recurrence_anchor: "scheduled",
+ scheduled: "2026-06-02",
+ complete_instances: [],
+ skipped_instances: [],
+ ...overrides,
+ } as TaskInfo;
+}
+
+function createPlugin(): TaskNotesPlugin {
+ const app = new App();
+ return {
+ app,
+ i18n: createI18nService(),
+ settings: {
+ customStatuses: [
+ { value: "open", label: "Open", order: 0 },
+ { value: "done", label: "Done", order: 1 },
+ ],
+ customPriorities: [{ value: "normal", label: "Normal", weight: 0 }],
+ calendarViewSettings: { enableTimeblocking: false },
+ useFrontmatterMarkdownLinks: true,
+ },
+ statusManager: {
+ getAllStatuses: jest.fn(() => [
+ { value: "open", label: "Open" },
+ { value: "done", label: "Done" },
+ ]),
+ getNonCompletionStatuses: jest.fn(() => [{ value: "open", label: "Open" }]),
+ isCompletedStatus: jest.fn((status: string) => status === "done"),
+ },
+ priorityManager: {
+ getAllPriorities: jest.fn(() => [{ value: "normal", label: "Normal" }]),
+ getPrioritiesByWeight: jest.fn(() => [{ value: "normal", label: "Normal" }]),
+ },
+ taskService: {
+ toggleRecurringTaskSkipped: jest.fn(),
+ updateBlockingRelationships: jest.fn(),
+ },
+ cacheManager: {
+ getAllContexts: jest.fn(() => []),
+ getAllTasks: jest.fn(() => []),
+ getTaskInfo: jest.fn(),
+ },
+ updateTaskProperty: jest.fn(),
+ toggleRecurringTaskComplete: jest.fn(),
+ getActiveTimeSession: jest.fn(() => null),
+ stopTimeTracking: jest.fn(),
+ startTimeTracking: jest.fn(),
+ openDueDateModal: jest.fn(),
+ openScheduledDateModal: jest.fn(),
+ openTimeEntryEditor: jest.fn(),
+ toggleTaskArchive: jest.fn(),
+ openTaskEditModal: jest.fn(),
+ openTaskCreationModal: jest.fn(),
+ } as unknown as TaskNotesPlugin;
+}
+
+function submenuOf(item: MockMenuItem): MockMenu | undefined {
+ if ("type" in item) return undefined;
+ const results = item.setSubmenu?.mock.results;
+ return results && results.length ? (results[results.length - 1].value as MockMenu) : undefined;
+}
+
+// Skip now lives inside the "Complete or skip" submenu, so search deep.
+function findTopLevelMenuItem(
+ title: string,
+ menu: MockMenu | undefined = menuMock.mock.results[0]?.value as MockMenu,
+ seen = new Set()
+): Record | undefined {
+ if (!menu || seen.has(menu)) return undefined;
+ seen.add(menu);
+ for (const item of menu.items) {
+ if ("type" in item) continue;
+ if (item.setTitle?.mock.calls[0]?.[0] === title) return item as Record;
+ const found = findTopLevelMenuItem(title, submenuOf(item), seen);
+ if (found) return found;
+ }
+ return undefined;
+}
+
+describe("occurrenceDate context field", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ menuMock.mockClear();
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ menuMock.mockClear();
+ });
+
+ it("is optional — existing callers that omit it still build a menu", () => {
+ expect(
+ () =>
+ new TaskContextMenu({
+ task: createRecurringTask(),
+ plugin: createPlugin(),
+ targetDate: new Date("2026-06-06T12:00:00"),
+ })
+ ).not.toThrow();
+
+ expect(findTopLevelMenuItem("Skip instance")).toBeDefined();
+ });
+
+ it("reads occurrenceDate (not targetDate) to decide the skip/unskip label", () => {
+ // The already-skipped date matches occurrenceDate but NOT targetDate.
+ const occurrence = new Date("2026-06-09T00:00:00Z");
+ const task = createRecurringTask({
+ skipped_instances: [formatDateForStorage(occurrence)],
+ });
+
+ new TaskContextMenu({
+ task,
+ plugin: createPlugin(),
+ // A different, unrelated targetDate — if the label inferred from
+ // targetDate it would (wrongly) show "Skip instance".
+ targetDate: new Date("2026-06-06T12:00:00"),
+ occurrenceDate: occurrence,
+ });
+
+ expect(findTopLevelMenuItem("Unskip instance")).toBeDefined();
+ expect(findTopLevelMenuItem("Skip instance")).toBeUndefined();
+ });
+
+ it("showTaskContextMenu forwards occurrenceDate into the built menu", async () => {
+ // Skipped date matches occurrenceDate but not targetDate, so "Unskip" proves forwarding.
+ const occurrence = new Date("2026-06-09T00:00:00Z");
+ const task = createRecurringTask({
+ skipped_instances: [formatDateForStorage(occurrence)],
+ });
+ const plugin = createPlugin();
+ plugin.cacheManager.getTaskInfo = jest.fn(async () => task);
+
+ await showTaskContextMenu(
+ new MouseEvent("contextmenu"),
+ task.path,
+ plugin,
+ new Date("2026-06-06T12:00:00"), // unrelated targetDate
+ { occurrenceDate: occurrence }
+ );
+
+ expect(findTopLevelMenuItem("Unskip instance")).toBeDefined();
+ expect(findTopLevelMenuItem("Skip instance")).toBeUndefined();
+ });
+});
diff --git a/tests/unit/components/taskContextMenu.pickDate.test.ts b/tests/unit/components/taskContextMenu.pickDate.test.ts
new file mode 100644
index 000000000..b07d47aec
--- /dev/null
+++ b/tests/unit/components/taskContextMenu.pickDate.test.ts
@@ -0,0 +1,154 @@
+import { App, Menu } from "obsidian";
+
+// Capture the DateTimePickerModal options so the test can drive onSelect.
+jest.mock("../../../src/modals/DateTimePickerModal", () => ({
+ DateTimePickerModal: jest.fn().mockImplementation(() => ({ open: jest.fn() })),
+}));
+
+import { TaskContextMenu } from "../../../src/components/TaskContextMenu";
+import { DateTimePickerModal } from "../../../src/modals/DateTimePickerModal";
+import { createI18nService } from "../../../src/i18n";
+import { formatDateForStorage } from "../../../src/utils/dateUtils";
+import type TaskNotesPlugin from "../../../src/main";
+import type { TaskInfo } from "../../../src/types";
+
+type MockMenuItem = Record | { type: string };
+type MockMenu = { items: MockMenuItem[] };
+
+const menuMock = Menu as unknown as jest.Mock;
+const modalMock = DateTimePickerModal as unknown as jest.Mock;
+
+function createPlugin(): TaskNotesPlugin {
+ const app = new App();
+ return {
+ app,
+ i18n: createI18nService(),
+ settings: {
+ defaultTaskStatus: "open",
+ customStatuses: [
+ { value: "open", label: "Open", order: 0 },
+ { value: "done", label: "Done", order: 1, isCompleted: true },
+ ],
+ customPriorities: [{ value: "normal", label: "Normal", weight: 0 }],
+ calendarViewSettings: { enableTimeblocking: false },
+ useFrontmatterMarkdownLinks: true,
+ },
+ statusManager: {
+ getAllStatuses: jest.fn(() => [
+ { value: "open", label: "Open" },
+ { value: "done", label: "Done" },
+ ]),
+ getNonCompletionStatuses: jest.fn(() => [{ value: "open", label: "Open" }]),
+ getCompletedStatuses: jest.fn(() => ["done"]),
+ isCompletedStatus: jest.fn((status: string) => status === "done"),
+ },
+ priorityManager: {
+ getAllPriorities: jest.fn(() => [{ value: "normal", label: "Normal" }]),
+ getPrioritiesByWeight: jest.fn(() => [{ value: "normal", label: "Normal" }]),
+ },
+ taskService: { toggleRecurringTaskSkipped: jest.fn(), updateBlockingRelationships: jest.fn() },
+ cacheManager: {
+ getAllContexts: jest.fn(() => []),
+ getAllTasks: jest.fn(() => []),
+ getTaskInfo: jest.fn(),
+ },
+ updateTaskProperty: jest.fn(),
+ toggleRecurringTaskComplete: jest.fn(),
+ getActiveTimeSession: jest.fn(() => null),
+ stopTimeTracking: jest.fn(),
+ startTimeTracking: jest.fn(),
+ openDueDateModal: jest.fn(),
+ openScheduledDateModal: jest.fn(),
+ openTimeEntryEditor: jest.fn(),
+ toggleTaskArchive: jest.fn(),
+ openTaskEditModal: jest.fn(),
+ openTaskCreationModal: jest.fn(),
+ } as unknown as TaskNotesPlugin;
+}
+
+function task(overrides: Partial = {}): TaskInfo {
+ return {
+ id: "Tasks/t.md",
+ path: "Tasks/t.md",
+ title: "T",
+ status: "open",
+ priority: "normal",
+ complete_instances: [],
+ skipped_instances: [],
+ ...overrides,
+ } as TaskInfo;
+}
+
+function submenuOf(item: MockMenuItem): MockMenu | undefined {
+ if ("type" in item) return undefined;
+ const results = item.setSubmenu?.mock.results;
+ return results && results.length ? (results[results.length - 1].value as MockMenu) : undefined;
+}
+
+// Completion actions live inside the "Complete or Skip" submenu, so search deep.
+function findItem(
+ title: string,
+ menu: MockMenu | undefined = menuMock.mock.results[0]?.value as MockMenu,
+ seen = new Set()
+): Record | undefined {
+ if (!menu || seen.has(menu)) return undefined;
+ seen.add(menu);
+ for (const item of menu.items) {
+ if ("type" in item) continue;
+ if (item.setTitle?.mock.calls[0]?.[0] === title) return item as Record;
+ const found = findItem(title, submenuOf(item), seen);
+ if (found) return found;
+ }
+ return undefined;
+}
+
+function getPickerOnSelect(): (date: string | null) => void {
+ return modalMock.mock.calls[modalMock.mock.calls.length - 1][1].onSelect;
+}
+
+describe("Complete on… date picker", () => {
+ beforeEach(() => {
+ menuMock.mockClear();
+ modalMock.mockClear();
+ });
+
+ it("records the picked date into complete_instances for a recurring task", async () => {
+ const plugin = createPlugin();
+ const t = task({ recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU", scheduled: "2026-06-02" });
+ new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") });
+
+ await findItem("Completed on (pick date)")?.onClick.mock.calls[0]?.[0]();
+ expect(modalMock).toHaveBeenCalledTimes(1);
+
+ await getPickerOnSelect()("2026-05-20");
+
+ expect(plugin.toggleRecurringTaskComplete).toHaveBeenCalledTimes(1);
+ const [, date] = (plugin.toggleRecurringTaskComplete as jest.Mock).mock.calls[0];
+ expect(formatDateForStorage(date)).toBe("2026-05-20");
+ });
+
+ it("records the picked date as completedDate via a status change for a non-recurring task", async () => {
+ const plugin = createPlugin();
+ const t = task(); // undated
+ new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") });
+
+ await findItem("Completed on (pick date)")?.onClick.mock.calls[0]?.[0]();
+ await getPickerOnSelect()("2026-05-20");
+
+ expect(plugin.updateTaskProperty).toHaveBeenCalledWith(t, "status", "done", {
+ completionDate: "2026-05-20",
+ });
+ });
+
+ it("records nothing when the picker is cancelled", async () => {
+ const plugin = createPlugin();
+ const t = task({ scheduled: "2026-06-02" });
+ new TaskContextMenu({ task: t, plugin, targetDate: new Date("2026-06-15T12:00:00") });
+
+ await findItem("Completed on (pick date)")?.onClick.mock.calls[0]?.[0]();
+ await getPickerOnSelect()(null);
+
+ expect(plugin.updateTaskProperty).not.toHaveBeenCalled();
+ expect(plugin.toggleRecurringTaskComplete).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/components/taskContextMenu.skip.test.ts b/tests/unit/components/taskContextMenu.skip.test.ts
new file mode 100644
index 000000000..b86e88f42
--- /dev/null
+++ b/tests/unit/components/taskContextMenu.skip.test.ts
@@ -0,0 +1,251 @@
+import { App, Menu } from "obsidian";
+import { TaskContextMenu } from "../../../src/components/TaskContextMenu";
+import { getRecurringTaskActionDate } from "../../../src/services/task-service/taskRecurringPlanning";
+import { createI18nService } from "../../../src/i18n";
+import { formatDateForStorage, getTodayString } from "../../../src/utils/dateUtils";
+import type TaskNotesPlugin from "../../../src/main";
+import type { TaskInfo } from "../../../src/types";
+
+type MockMenuItem = Record | { type: string };
+type MockMenu = { items: MockMenuItem[] };
+
+const menuMock = Menu as unknown as jest.Mock;
+
+function createRecurringTask(overrides: Partial = {}): TaskInfo {
+ return {
+ id: "Tasks/recurring.md",
+ path: "Tasks/recurring.md",
+ title: "Recurring task",
+ status: "open",
+ priority: "normal",
+ recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU",
+ recurrence_anchor: "scheduled",
+ scheduled: "2026-06-02",
+ complete_instances: [],
+ skipped_instances: [],
+ ...overrides,
+ } as TaskInfo;
+}
+
+function createPlugin(): TaskNotesPlugin {
+ const app = new App();
+ return {
+ app,
+ i18n: createI18nService(),
+ settings: {
+ customStatuses: [
+ { value: "open", label: "Open", order: 0 },
+ { value: "done", label: "Done", order: 1 },
+ ],
+ customPriorities: [{ value: "normal", label: "Normal", weight: 0 }],
+ calendarViewSettings: { enableTimeblocking: false },
+ useFrontmatterMarkdownLinks: true,
+ },
+ statusManager: {
+ getAllStatuses: jest.fn(() => [
+ { value: "open", label: "Open" },
+ { value: "done", label: "Done" },
+ ]),
+ getNonCompletionStatuses: jest.fn(() => [{ value: "open", label: "Open" }]),
+ isCompletedStatus: jest.fn((status: string) => status === "done"),
+ },
+ priorityManager: {
+ getAllPriorities: jest.fn(() => [{ value: "normal", label: "Normal" }]),
+ getPrioritiesByWeight: jest.fn(() => [{ value: "normal", label: "Normal" }]),
+ },
+ taskService: {
+ toggleRecurringTaskSkipped: jest.fn(),
+ updateBlockingRelationships: jest.fn(),
+ },
+ cacheManager: {
+ getAllContexts: jest.fn(() => []),
+ getAllTasks: jest.fn(() => []),
+ getTaskInfo: jest.fn(),
+ },
+ updateTaskProperty: jest.fn(),
+ toggleRecurringTaskComplete: jest.fn(),
+ getActiveTimeSession: jest.fn(() => null),
+ stopTimeTracking: jest.fn(),
+ startTimeTracking: jest.fn(),
+ openDueDateModal: jest.fn(),
+ openScheduledDateModal: jest.fn(),
+ openTimeEntryEditor: jest.fn(),
+ toggleTaskArchive: jest.fn(),
+ openTaskEditModal: jest.fn(),
+ openTaskCreationModal: jest.fn(),
+ } as unknown as TaskNotesPlugin;
+}
+
+function submenuOf(item: MockMenuItem): MockMenu | undefined {
+ if ("type" in item) return undefined;
+ const results = item.setSubmenu?.mock.results;
+ return results && results.length ? (results[results.length - 1].value as MockMenu) : undefined;
+}
+
+// Skip now lives inside the "Complete or skip" submenu, so search deep.
+function findTopLevelMenuItem(
+ title: string,
+ menu: MockMenu | undefined = menuMock.mock.results[0]?.value as MockMenu,
+ seen = new Set()
+): Record | undefined {
+ if (!menu || seen.has(menu)) return undefined;
+ seen.add(menu);
+ for (const item of menu.items) {
+ if ("type" in item) continue;
+ if (item.setTitle?.mock.calls[0]?.[0] === title) return item as Record;
+ const found = findTopLevelMenuItem(title, submenuOf(item), seen);
+ if (found) return found;
+ }
+ return undefined;
+}
+
+describe("Skip this instance records the occurrence date", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ // Freeze at midday UTC so "today" resolves deterministically regardless of
+ // the runner's timezone (getTodayLocal vs. new Date() agree within +/-12h).
+ jest.setSystemTime(new Date("2026-06-06T12:00:00Z"));
+ menuMock.mockClear();
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ menuMock.mockClear();
+ });
+
+ it("List/Kanban skip passes no explicit date so the service resolves the scheduled occurrence, not today", async () => {
+ const task = createRecurringTask();
+ const plugin = createPlugin();
+
+ // List/Kanban open the menu with a view-wide "today" targetDate and NO
+ // occurrenceDate. The old behavior forwarded that today to the service.
+ new TaskContextMenu({
+ task,
+ plugin,
+ targetDate: new Date("2026-06-06T12:00:00"), // a Saturday review, not the Tuesday occurrence
+ });
+
+ const skipItem = findTopLevelMenuItem("Skip instance");
+ await skipItem?.onClick.mock.calls[0]?.[0]();
+
+ // The fix: pass undefined so getRecurringTaskActionDate resolves anchor-aware.
+ expect(plugin.taskService.toggleRecurringTaskSkipped).toHaveBeenCalledWith(task, undefined);
+ expect(plugin.taskService.toggleRecurringTaskSkipped).not.toHaveBeenCalledWith(
+ task,
+ expect.any(Date)
+ );
+
+ // And that anchor-aware resolution records the scheduled Tuesday, not today.
+ const resolved = getRecurringTaskActionDate(task, undefined);
+ expect(formatDateForStorage(resolved)).toBe("2026-06-02");
+ });
+
+ it("Calendar skip records the clicked occurrence via occurrenceDate", async () => {
+ const task = createRecurringTask();
+ const plugin = createPlugin();
+ const clickedOccurrence = new Date("2026-06-09T00:00:00Z"); // next Tuesday
+
+ new TaskContextMenu({
+ task,
+ plugin,
+ targetDate: clickedOccurrence,
+ occurrenceDate: clickedOccurrence,
+ });
+
+ const skipItem = findTopLevelMenuItem("Skip instance");
+ await skipItem?.onClick.mock.calls[0]?.[0]();
+
+ expect(plugin.taskService.toggleRecurringTaskSkipped).toHaveBeenCalledWith(
+ task,
+ clickedOccurrence
+ );
+ });
+
+ it("completion-anchored skip still records today (unchanged)", async () => {
+ const task = createRecurringTask({
+ recurrence_anchor: "completion",
+ scheduled: "2026-06-02",
+ });
+ const plugin = createPlugin();
+
+ new TaskContextMenu({
+ task,
+ plugin,
+ targetDate: new Date("2026-06-06T12:00:00"),
+ });
+
+ const skipItem = findTopLevelMenuItem("Skip instance");
+ await skipItem?.onClick.mock.calls[0]?.[0]();
+
+ // No explicit date passed; the service's anchor-aware default returns today
+ // for completion-anchored recurrences.
+ expect(plugin.taskService.toggleRecurringTaskSkipped).toHaveBeenCalledWith(task, undefined);
+ const resolved = getRecurringTaskActionDate(task, undefined);
+ expect(formatDateForStorage(resolved)).toBe(getTodayString()); // today, not scheduled
+ });
+
+ it("List/Kanban shows Unskip once the resolved scheduled date is already skipped, and toggles it out", async () => {
+ // The label corollary of the fix: skip records the scheduled date, so the
+ // card must offer "Unskip" for that same date even with NO occurrenceDate.
+ const task = createRecurringTask({ skipped_instances: ["2026-06-02"] });
+ const plugin = createPlugin();
+
+ new TaskContextMenu({
+ task,
+ plugin,
+ targetDate: new Date("2026-06-06T12:00:00"), // view-wide today, not the occurrence
+ });
+
+ expect(findTopLevelMenuItem("Unskip instance")).toBeDefined();
+ expect(findTopLevelMenuItem("Skip instance")).toBeUndefined();
+
+ const unskipItem = findTopLevelMenuItem("Unskip instance");
+ await unskipItem?.onClick.mock.calls[0]?.[0]();
+ // Still no explicit date: the service re-resolves the same scheduled date on the fresh task.
+ expect(plugin.taskService.toggleRecurringTaskSkipped).toHaveBeenCalledWith(task, undefined);
+ });
+
+ it("due-only recurring skip resolves to today (no scheduled anchor)", async () => {
+ // recurrence + due, no scheduled: getRecurringTaskActionDate falls through to today.
+ const task = createRecurringTask({ due: "2026-06-02" });
+ delete (task as Partial).scheduled;
+ const plugin = createPlugin();
+
+ new TaskContextMenu({
+ task,
+ plugin,
+ targetDate: new Date("2026-06-06T12:00:00"),
+ });
+
+ const skipItem = findTopLevelMenuItem("Skip instance");
+ await skipItem?.onClick.mock.calls[0]?.[0]();
+
+ expect(plugin.taskService.toggleRecurringTaskSkipped).toHaveBeenCalledWith(task, undefined);
+ expect(formatDateForStorage(getRecurringTaskActionDate(task, undefined))).toBe(getTodayString());
+ });
+
+ it("shows Unskip for a Calendar occurrence that is already skipped and toggles the same date out", async () => {
+ const clickedOccurrence = new Date("2026-06-09T00:00:00Z");
+ const task = createRecurringTask({
+ skipped_instances: [formatDateForStorage(clickedOccurrence)],
+ });
+ const plugin = createPlugin();
+
+ new TaskContextMenu({
+ task,
+ plugin,
+ targetDate: clickedOccurrence,
+ occurrenceDate: clickedOccurrence,
+ });
+
+ const unskipItem = findTopLevelMenuItem("Unskip instance");
+ expect(unskipItem).toBeDefined();
+
+ await unskipItem?.onClick.mock.calls[0]?.[0]();
+ expect(plugin.taskService.toggleRecurringTaskSkipped).toHaveBeenCalledWith(
+ task,
+ clickedOccurrence
+ );
+ });
+});
diff --git a/tests/unit/core/VaultMutationService.test.ts b/tests/unit/core/VaultMutationService.test.ts
new file mode 100644
index 000000000..9074cd97d
--- /dev/null
+++ b/tests/unit/core/VaultMutationService.test.ts
@@ -0,0 +1,90 @@
+import type { TFile } from "obsidian";
+import {
+ processVaultFile,
+ processVaultFrontMatter,
+} from "../../../src/core/VaultMutationService";
+
+describe("VaultMutationService", () => {
+ it("serializes frontmatter and content mutations for the same file", async () => {
+ let releaseFirstWrite: (() => void) | undefined;
+ const firstWriteGate = new Promise((resolve) => {
+ releaseFirstWrite = resolve;
+ });
+ const calls: string[] = [];
+ const file = {} as TFile;
+ const app = {
+ fileManager: {
+ processFrontMatter: jest.fn(async () => {
+ calls.push("frontmatter:start");
+ await firstWriteGate;
+ calls.push("frontmatter:end");
+ }),
+ },
+ vault: {
+ process: jest.fn(async (_file: TFile, update: (content: string) => string) => {
+ calls.push("content");
+ return update("body");
+ }),
+ },
+ };
+
+ const first = processVaultFrontMatter(app, file, () => {});
+ const second = processVaultFile(app, file, (content) => content);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(app.fileManager.processFrontMatter).toHaveBeenCalledTimes(1);
+ expect(app.vault.process).not.toHaveBeenCalled();
+ releaseFirstWrite?.();
+ await Promise.all([first, second]);
+ expect(calls).toEqual(["frontmatter:start", "frontmatter:end", "content"]);
+ });
+
+ it("does not block mutations to different files", async () => {
+ let releaseWrites: (() => void) | undefined;
+ const writeGate = new Promise((resolve) => {
+ releaseWrites = resolve;
+ });
+ let activeWrites = 0;
+ let maximumActiveWrites = 0;
+ const app = {
+ fileManager: {
+ processFrontMatter: jest.fn(async () => {
+ activeWrites += 1;
+ maximumActiveWrites = Math.max(maximumActiveWrites, activeWrites);
+ await writeGate;
+ activeWrites -= 1;
+ }),
+ },
+ };
+
+ const first = processVaultFrontMatter(app, {} as TFile, () => {});
+ const second = processVaultFrontMatter(app, {} as TFile, () => {});
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(app.fileManager.processFrontMatter).toHaveBeenCalledTimes(2);
+ expect(maximumActiveWrites).toBe(2);
+ releaseWrites?.();
+ await Promise.all([first, second]);
+ });
+
+ it("continues a same-file queue after a failed mutation", async () => {
+ const app = {
+ fileManager: {
+ processFrontMatter: jest
+ .fn()
+ .mockRejectedValueOnce(new Error("write failed"))
+ .mockResolvedValueOnce(undefined),
+ },
+ };
+ const file = {} as TFile;
+
+ const first = processVaultFrontMatter(app, file, () => {});
+ const second = processVaultFrontMatter(app, file, () => {});
+
+ await expect(first).rejects.toThrow("write failed");
+ await expect(second).resolves.toBeUndefined();
+ expect(app.fileManager.processFrontMatter).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/tests/unit/editor/MarkdownWidgetContext.test.ts b/tests/unit/editor/MarkdownWidgetContext.test.ts
index ef1315a7e..c0ba725e2 100644
--- a/tests/unit/editor/MarkdownWidgetContext.test.ts
+++ b/tests/unit/editor/MarkdownWidgetContext.test.ts
@@ -8,7 +8,7 @@ import { createTaskNotesLogger } from "../../../src/utils/tasknotesLogger";
function createMockView(options: {
dom?: HTMLElement;
- leaf?: { parent?: unknown };
+ leaf?: { parent?: unknown; view?: { getMode?: () => string } };
containerEl?: HTMLElement;
}): EditorView {
return {
@@ -49,6 +49,14 @@ describe("MarkdownWidgetContext", () => {
expect(shouldSkipMarkdownWidgetEditor(view)).toBe(false);
});
+ it("skips stale editors after their Markdown view switches to Reading mode", () => {
+ const view = createMockView({
+ leaf: { parent: {}, view: { getMode: () => "preview" } },
+ });
+
+ expect(shouldSkipMarkdownWidgetEditor(view)).toBe(true);
+ });
+
it("skips editors mounted inside markdown embeds", () => {
const embed = document.createElement("div");
embed.className = "internal-embed markdown-embed";
diff --git a/tests/unit/editor/ReadingModeInjectionScheduler.test.ts b/tests/unit/editor/ReadingModeInjectionScheduler.test.ts
index e8687b0ca..f44119776 100644
--- a/tests/unit/editor/ReadingModeInjectionScheduler.test.ts
+++ b/tests/unit/editor/ReadingModeInjectionScheduler.test.ts
@@ -51,4 +51,31 @@ describe("ReadingModeInjectionScheduler", () => {
"finish-2-current",
]);
});
+
+ it("invalidates an in-flight run and prevents queued work after disposal", async () => {
+ const scheduler = new ReadingModeInjectionScheduler();
+ const leaf = {} as any;
+ let releaseRun: (() => void) | undefined;
+ let contextIsCurrent: (() => boolean) | undefined;
+ let runCount = 0;
+
+ scheduler.schedule(leaf, async ({ isCurrent }) => {
+ runCount += 1;
+ contextIsCurrent = isCurrent;
+ await new Promise((resolve) => {
+ releaseRun = resolve;
+ });
+ });
+ await Promise.resolve();
+ scheduler.schedule(leaf, async () => {
+ runCount += 1;
+ });
+
+ scheduler.dispose();
+ expect(contextIsCurrent?.()).toBe(false);
+ releaseRun?.();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(runCount).toBe(1);
+ });
});
diff --git a/tests/unit/issues/issue-1329-relationships-widget-gap.test.ts b/tests/unit/issues/issue-1329-relationships-widget-gap.test.ts
index 4bd489a17..4c4edcc4d 100644
--- a/tests/unit/issues/issue-1329-relationships-widget-gap.test.ts
+++ b/tests/unit/issues/issue-1329-relationships-widget-gap.test.ts
@@ -129,6 +129,36 @@ describe("Issue #1329: relationships widget bottom placement", () => {
expect(widget.style.getPropertyValue("--tn-relationships-widget-margin-top")).toBe("20px");
});
+ it("uses rendered Live Preview Dataview blocks that are direct content children", () => {
+ const sizer = el("cm-sizer");
+ const contentContainer = el("cm-contentContainer");
+ const cmContent = el("cm-content cm-lineWrapping");
+ const lastLine = el("cm-line");
+ const dataviewBlock = el(
+ "cm-preview-code-block cm-embed-block markdown-rendered cm-lang-dataview"
+ );
+ const widget = el("tasknotes-relationships-widget");
+ widget.style.marginTop = "24px";
+
+ cmContent.append(lastLine, dataviewBlock);
+ contentContainer.append(cmContent);
+ sizer.append(contentContainer, widget);
+
+ Object.defineProperty(contentContainer, "getBoundingClientRect", {
+ value: () => ({ bottom: 224 }),
+ });
+ Object.defineProperty(lastLine, "getBoundingClientRect", {
+ value: () => ({ bottom: 100, width: 100, height: 20 }),
+ });
+ Object.defineProperty(dataviewBlock, "getBoundingClientRect", {
+ value: () => ({ bottom: 220, width: 100, height: 120 }),
+ });
+
+ applyRelationshipsBottomOffset(sizer, widget);
+
+ expect(widget.style.getPropertyValue("--tn-relationships-widget-margin-top")).toBe("20px");
+ });
+
it("anchors reading mode widgets after the last content section", () => {
const sizer = el("markdown-preview-sizer");
const firstSection = el("markdown-preview-section");
diff --git a/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts b/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts
index 32c0b6b89..1782362d0 100644
--- a/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts
+++ b/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts
@@ -203,7 +203,8 @@ describe("Issue #1696: Google Calendar recurring reschedule sync", () => {
"master-event-id",
expect.objectContaining({
recurrence: expect.arrayContaining(["EXDATE;VALUE=DATE:20260413"]),
- })
+ }),
+ expect.any(Number)
);
expect(googleCalendarService.createEvent).toHaveBeenCalledWith(
"primary",
@@ -212,7 +213,8 @@ describe("Issue #1696: Google Calendar recurring reschedule sync", () => {
start: { date: "2026-04-15" },
end: { date: "2026-04-16" },
isAllDay: true,
- })
+ }),
+ expect.any(Number)
);
expect(frontmatter.googleCalendarExceptionEventId).toBe("detached-exception-id");
});
@@ -249,7 +251,8 @@ describe("Issue #1696: Google Calendar recurring reschedule sync", () => {
expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith(
"primary",
- "detached-exception-id"
+ "detached-exception-id",
+ expect.any(Number)
);
expect(frontmatter.googleCalendarExceptionEventId).toBeUndefined();
});
diff --git a/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts b/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts
index 7b25f848c..5e5db7df3 100644
--- a/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts
+++ b/tests/unit/issues/issue-1724-recurring-actions-date-section.test.ts
@@ -130,7 +130,7 @@ describe("Issue #1724: recurring task actions belong with date menu items", () =
menuMock.mockClear();
});
- it("places recurring complete and skip actions after scheduled date, not between status and priority", () => {
+ it("places the 'Complete or Skip' submenu after scheduled date, before the occurrence note", () => {
new TaskContextMenu({
task: createRecurringTask(),
plugin: createPlugin(),
@@ -145,29 +145,36 @@ describe("Issue #1724: recurring task actions belong with date menu items", () =
"Priority",
"Due date",
"Scheduled date",
- "Mark complete for this date",
- "Skip instance",
+ "Mark complete or skip",
"Open or create occurrence note",
"Reminders",
])
);
+ // The single "Mark complete for this date" item was replaced by the
+ // "Mark complete or skip" submenu; the completion actions live inside it.
+ expect(titles).not.toContain("Mark complete for this date");
+
expect(titles.indexOf("Status")).toBeLessThan(titles.indexOf("Priority"));
- expect(titles.indexOf("Priority")).toBeLessThan(
- titles.indexOf("Mark complete for this date")
- );
- expect(titles.indexOf("Scheduled date")).toBeLessThan(
- titles.indexOf("Mark complete for this date")
- );
- expect(titles.indexOf("Mark complete for this date")).toBeLessThan(
- titles.indexOf("Skip instance")
- );
- expect(titles.indexOf("Skip instance")).toBeLessThan(
+ expect(titles.indexOf("Priority")).toBeLessThan(titles.indexOf("Mark complete or skip"));
+ expect(titles.indexOf("Scheduled date")).toBeLessThan(titles.indexOf("Mark complete or skip"));
+ expect(titles.indexOf("Mark complete or skip")).toBeLessThan(
titles.indexOf("Open or create occurrence note")
);
expect(titles.indexOf("Open or create occurrence note")).toBeLessThan(
titles.indexOf("Reminders")
);
+
+ const submenuTitles = getAllMenuTitles();
+ expect(submenuTitles).toEqual(
+ expect.arrayContaining([
+ "Completed today",
+ "Completed on schedule",
+ "Completed on due date",
+ "Completed on (pick date)",
+ "Skip instance",
+ ])
+ );
});
it("categorizes the quick-action recurring completion action with date actions", () => {
@@ -229,6 +236,87 @@ describe("Issue #1724: recurring task actions belong with date menu items", () =
expect(openFile).toHaveBeenCalledWith(expect.objectContaining({ path: occurrence.path }));
});
+ it("uses the parent scheduled date for generic completion-anchor occurrence notes", async () => {
+ const task = createRecurringTask({
+ title: "Task2",
+ path: "Tasks/Task2.md",
+ recurrence: "DTSTART:20260810;FREQ=DAILY;INTERVAL=3",
+ recurrence_anchor: "completion",
+ scheduled: "2026-08-13",
+ });
+ const plugin = createPlugin();
+ const occurrence = {
+ ...task,
+ path: "Tasks/Task2 2026-08-13.md",
+ recurrence: undefined,
+ recurrence_parent: "[[Tasks/Task2]]",
+ occurrence_date: "2026-08-13",
+ scheduled: "2026-08-13",
+ };
+ const openFile = jest.fn();
+ plugin.taskService.findMaterializedOccurrence = jest.fn(async () => undefined);
+ plugin.taskService.materializeOccurrence = jest.fn(async () => occurrence);
+ plugin.app.vault.getAbstractFileByPath = jest.fn((path: string) => new TFile(path));
+ plugin.app.workspace.getLeaf = jest.fn(() => ({ openFile }));
+
+ new TaskContextMenu({
+ task,
+ plugin,
+ targetDate: new Date("2026-08-14T12:00:00"),
+ });
+
+ const item = findTopLevelMenuItem("Open or create occurrence note");
+ await item?.onClick.mock.calls[0]?.[0]();
+
+ const findTarget = (plugin.taskService.findMaterializedOccurrence as jest.Mock).mock
+ .calls[0][1] as Date;
+ const materializeTarget = (plugin.taskService.materializeOccurrence as jest.Mock).mock
+ .calls[0][1] as Date;
+ expect(findTarget.toISOString().slice(0, 10)).toBe("2026-08-13");
+ expect(materializeTarget.toISOString().slice(0, 10)).toBe("2026-08-13");
+ expect(openFile).toHaveBeenCalledWith(expect.objectContaining({ path: occurrence.path }));
+ });
+
+ it("keeps promoted occurrence-note actions tied to the event target date", async () => {
+ const task = createRecurringTask({
+ title: "Task2",
+ path: "Tasks/Task2.md",
+ recurrence: "DTSTART:20260810;FREQ=DAILY;INTERVAL=3",
+ recurrence_anchor: "completion",
+ scheduled: "2026-08-13",
+ });
+ const plugin = createPlugin();
+ const occurrence = {
+ ...task,
+ path: "Tasks/Task2 2026-08-16.md",
+ recurrence: undefined,
+ recurrence_parent: "[[Tasks/Task2]]",
+ occurrence_date: "2026-08-16",
+ scheduled: "2026-08-16",
+ };
+ const openFile = jest.fn();
+ plugin.taskService.findMaterializedOccurrence = jest.fn(async () => undefined);
+ plugin.taskService.materializeOccurrence = jest.fn(async () => occurrence);
+ plugin.app.vault.getAbstractFileByPath = jest.fn((path: string) => new TFile(path));
+ plugin.app.workspace.getLeaf = jest.fn(() => ({ openFile }));
+
+ new TaskContextMenu({
+ task,
+ plugin,
+ targetDate: new Date("2026-08-16T12:00:00"),
+ occurrenceDate: new Date("2026-08-16T12:00:00"),
+ promoteOccurrenceControls: true,
+ });
+
+ const item = findTopLevelMenuItem("Open or create occurrence note");
+ await item?.onClick.mock.calls[0]?.[0]();
+
+ const materializeTarget = (plugin.taskService.materializeOccurrence as jest.Mock).mock
+ .calls[0][1] as Date;
+ expect(materializeTarget.toISOString().slice(0, 10)).toBe("2026-08-16");
+ expect(openFile).toHaveBeenCalledWith(expect.objectContaining({ path: occurrence.path }));
+ });
+
it("can promote occurrence controls for calendar event context menus", () => {
new TaskContextMenu({
task: createRecurringTask(),
diff --git a/tests/unit/issues/issue-1956-google-calendar-recurring-completion-title.test.ts b/tests/unit/issues/issue-1956-google-calendar-recurring-completion-title.test.ts
index d8e59948d..bc45ab6b9 100644
--- a/tests/unit/issues/issue-1956-google-calendar-recurring-completion-title.test.ts
+++ b/tests/unit/issues/issue-1956-google-calendar-recurring-completion-title.test.ts
@@ -127,7 +127,8 @@ describe("Issue #1956: recurring Google Calendar completion title", () => {
summary: "✓ Weekly review",
description: expect.stringContaining("Status: Done"),
recurrence: expect.arrayContaining(["EXDATE;VALUE=DATE:20260528"]),
- })
+ }),
+ expect.any(Number)
);
});
});
diff --git a/tests/unit/issues/issue-2081-reading-mode-widget-stability.test.ts b/tests/unit/issues/issue-2081-reading-mode-widget-stability.test.ts
index ee585114f..ff2e19b85 100644
--- a/tests/unit/issues/issue-2081-reading-mode-widget-stability.test.ts
+++ b/tests/unit/issues/issue-2081-reading-mode-widget-stability.test.ts
@@ -366,4 +366,104 @@ describe("Issue #2081: reading mode note widgets stay mounted", () => {
cleanup();
}
});
+
+ it("preserves relationship widgets owned by nested Markdown embeds", async () => {
+ jest.useFakeTimers();
+ const leaf = createMarkdownLeaf();
+ const outerSizer = leaf.containerEl.querySelector(".markdown-preview-sizer");
+ const nestedEmbed = document.createElement("div");
+ nestedEmbed.className = "markdown-embed";
+ nestedEmbed.innerHTML = `
+
+ `;
+ outerSizer?.appendChild(nestedEmbed);
+ const nestedWidget = nestedEmbed.querySelector(".tasknotes-relationships-widget");
+ const pluginMock = createPluginMock(leaf);
+ const cleanup = setupRelationshipsReadingModeHandlers(pluginMock.plugin);
+
+ try {
+ jest.runOnlyPendingTimers();
+ await flushMicrotasks();
+
+ expect(nestedWidget?.isConnected).toBe(true);
+ expect(
+ Array.from(outerSizer?.children ?? []).filter((child) =>
+ child.classList.contains("tasknotes-relationships-widget")
+ )
+ ).toHaveLength(1);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("unloads a rendered widget when Reading mode has no preview sizer", async () => {
+ jest.useFakeTimers();
+ const leaf = createMarkdownLeaf();
+ leaf.containerEl.querySelector(".markdown-preview-sizer")?.remove();
+ const pluginMock = createPluginMock(leaf);
+ const unload = Component.prototype.unload as unknown as jest.Mock;
+ unload.mockClear();
+ const cleanup = setupRelationshipsReadingModeHandlers(pluginMock.plugin);
+
+ try {
+ jest.runOnlyPendingTimers();
+ await flushMicrotasks();
+ expect(unload).toHaveBeenCalled();
+ expect(leaf.containerEl.querySelector(".tasknotes-relationships-widget")).toBeNull();
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("removes a hidden Live Preview relationships widget when Reading mode opens", async () => {
+ jest.useFakeTimers();
+ const leaf = createMarkdownLeaf();
+ const editorContainer = document.createElement("div");
+ editorContainer.className = "markdown-source-view";
+ editorContainer.innerHTML = `
+
+ `;
+ leaf.containerEl.prepend(editorContainer);
+ const pluginMock = createPluginMock(leaf);
+ const cleanup = setupRelationshipsReadingModeHandlers(pluginMock.plugin);
+
+ try {
+ jest.runOnlyPendingTimers();
+ await flushMicrotasks();
+
+ expect(
+ leaf.containerEl.querySelectorAll(".tasknotes-relationships-widget")
+ ).toHaveLength(1);
+ expect(
+ leaf.containerEl.querySelector(
+ ".markdown-preview-sizer > .tasknotes-relationships-widget"
+ )
+ ).not.toBeNull();
+ expect(
+ leaf.containerEl.querySelector(
+ ".cm-sizer > .tasknotes-relationships-widget"
+ )
+ ).toBeNull();
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("removes owned relationships widgets when handlers are torn down", async () => {
+ jest.useFakeTimers();
+ const leaf = createMarkdownLeaf();
+ const pluginMock = createPluginMock(leaf);
+ const cleanup = setupRelationshipsReadingModeHandlers(pluginMock.plugin);
+ jest.runOnlyPendingTimers();
+ await flushMicrotasks();
+
+ expect(leaf.containerEl.querySelector(".tasknotes-relationships-widget")).not.toBeNull();
+ cleanup();
+ expect(leaf.containerEl.querySelector(".tasknotes-relationships-widget")).toBeNull();
+ });
});
diff --git a/tests/unit/issues/issue-2198-agenda-overdue-on-today.test.ts b/tests/unit/issues/issue-2198-agenda-overdue-on-today.test.ts
index 10e009951..b8a3c5ad2 100644
--- a/tests/unit/issues/issue-2198-agenda-overdue-on-today.test.ts
+++ b/tests/unit/issues/issue-2198-agenda-overdue-on-today.test.ts
@@ -114,6 +114,151 @@ describe("Issue #2198: Agenda overdue tasks on today", () => {
]);
});
+ it("materializes one overdue Agenda row for a task with both overdue scheduled and due dates", async () => {
+ const task = TaskFactory.createTask({
+ title: "Past scheduled and due",
+ path: "Tasks/past-scheduled-and-due.md",
+ status: "open",
+ scheduled: "2026-08-03",
+ due: "2026-08-04",
+ });
+
+ const events = await generateCalendarEvents([task], createPlugin(), {
+ showScheduled: true,
+ showDue: true,
+ showRecurring: false,
+ showOverdueOnToday: true,
+ visibleStart: new Date(2026, 7, 5),
+ visibleEnd: new Date(2026, 7, 12),
+ });
+
+ expect(taskEventSummaries(events)).toEqual([
+ {
+ id: "scheduled-Tasks/past-scheduled-and-due.md-overdue-today",
+ start: "2026-08-05",
+ eventType: "scheduled",
+ isOverdueOnToday: true,
+ path: "Tasks/past-scheduled-and-due.md",
+ },
+ ]);
+ });
+
+ it("keeps the overdue due row when scheduled events are hidden", async () => {
+ const task = TaskFactory.createTask({
+ title: "Past scheduled and due",
+ path: "Tasks/past-scheduled-hidden.md",
+ status: "open",
+ scheduled: "2026-08-03",
+ due: "2026-08-04",
+ });
+
+ const events = await generateCalendarEvents([task], createPlugin(), {
+ showScheduled: false,
+ showDue: true,
+ showRecurring: false,
+ showOverdueOnToday: true,
+ visibleStart: new Date(2026, 7, 5),
+ visibleEnd: new Date(2026, 7, 12),
+ });
+
+ expect(taskEventSummaries(events)).toEqual([
+ {
+ id: "due-Tasks/past-scheduled-hidden.md-overdue-today",
+ start: "2026-08-05",
+ eventType: "due",
+ isOverdueOnToday: true,
+ path: "Tasks/past-scheduled-hidden.md",
+ },
+ ]);
+ });
+
+ it("does not add a date-only overdue due row beside a generated recurring row", async () => {
+ const task = TaskFactory.createTask({
+ title: "Recurring scheduled and due",
+ path: "Tasks/recurring-scheduled-and-due.md",
+ status: "open",
+ scheduled: "2026-08-03",
+ due: "2026-08-04",
+ recurrence: "DTSTART:20260803;FREQ=DAILY",
+ });
+
+ const events = await generateCalendarEvents([task], createPlugin(), {
+ showScheduled: true,
+ showDue: true,
+ showRecurring: true,
+ showCompletedRecurringInstances: false,
+ showSkippedRecurringInstances: false,
+ showOverdueOnToday: true,
+ visibleStart: new Date(2026, 7, 5),
+ visibleEnd: new Date(2026, 7, 12),
+ });
+
+ const todayRows = taskEventSummaries(events).filter(
+ (event) => event.start.slice(0, 10) === "2026-08-05"
+ );
+ expect(todayRows).toHaveLength(1);
+ expect(todayRows[0]?.eventType).not.toBe("due");
+ });
+
+ it("keeps an overdue due row when the generated recurring row is in the future", async () => {
+ const task = TaskFactory.createTask({
+ title: "Future recurrence with overdue due date",
+ path: "Tasks/future-recurring-overdue-due.md",
+ status: "open",
+ scheduled: "2026-08-10",
+ due: "2026-08-01",
+ recurrence: "DTSTART:20260810;FREQ=DAILY",
+ });
+
+ const events = await generateCalendarEvents([task], createPlugin(), {
+ showScheduled: true,
+ showDue: true,
+ showRecurring: true,
+ showCompletedRecurringInstances: false,
+ showSkippedRecurringInstances: false,
+ showOverdueOnToday: true,
+ visibleStart: new Date(2026, 7, 5),
+ visibleEnd: new Date(2026, 7, 12),
+ });
+
+ expect(taskEventSummaries(events)).toContainEqual(
+ expect.objectContaining({
+ id: "due-Tasks/future-recurring-overdue-due.md-overdue-today",
+ start: "2026-08-05",
+ eventType: "due",
+ isOverdueOnToday: true,
+ })
+ );
+ });
+
+ it("keeps a timed overdue due row beside a generated recurring row", async () => {
+ const task = TaskFactory.createTask({
+ title: "Recurring task with timed deadline",
+ path: "Tasks/recurring-timed-due.md",
+ status: "open",
+ scheduled: "2026-08-03",
+ due: "2026-08-04T17:00",
+ recurrence: "DTSTART:20260803;FREQ=DAILY",
+ });
+
+ const events = await generateCalendarEvents([task], createPlugin(), {
+ showScheduled: true,
+ showDue: true,
+ showRecurring: true,
+ showCompletedRecurringInstances: false,
+ showSkippedRecurringInstances: false,
+ showOverdueOnToday: true,
+ visibleStart: new Date(2026, 7, 5),
+ visibleEnd: new Date(2026, 7, 12),
+ });
+
+ const todayRows = taskEventSummaries(events).filter(
+ (event) => event.start.slice(0, 10) === "2026-08-05"
+ );
+ expect(todayRows).toHaveLength(2);
+ expect(todayRows.some((event) => event.eventType === "due")).toBe(true);
+ });
+
it("does not materialize overdue events when the option is disabled", async () => {
const events = await generateCalendarEvents(
[
diff --git a/tests/unit/issues/issue-2206-time-entry-editor-large-list.test.ts b/tests/unit/issues/issue-2206-time-entry-editor-large-list.test.ts
new file mode 100644
index 000000000..fa513a5d0
--- /dev/null
+++ b/tests/unit/issues/issue-2206-time-entry-editor-large-list.test.ts
@@ -0,0 +1,36 @@
+import { TimeEntryEditorModal } from "../../../src/modals/TimeEntryEditorModal";
+import { createTaskModalMarkdownEditor } from "../../../src/modals/taskModalEditorAdapter";
+import { PluginFactory, TaskFactory, TimeEntryFactory } from "../../helpers/mock-factories";
+
+jest.mock("../../../src/modals/taskModalEditorAdapter", () => ({
+ createTaskModalMarkdownEditor: jest.fn(),
+}));
+
+describe("Issue #2206: time entry editor large list", () => {
+ const createTaskModalMarkdownEditorMock = createTaskModalMarkdownEditor as jest.MockedFunction<
+ typeof createTaskModalMarkdownEditor
+ >;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ createTaskModalMarkdownEditorMock.mockReturnValue({
+ destroy: jest.fn(),
+ } as any);
+ });
+
+ it("does not eagerly construct one markdown editor per existing time entry on open", () => {
+ const plugin = PluginFactory.createMockPlugin();
+ const task = TaskFactory.createTask({
+ timeEntries: TimeEntryFactory.createEntries(50),
+ });
+
+ const modal = new TimeEntryEditorModal(plugin.app as any, plugin as any, task, jest.fn());
+ modal.onOpen();
+
+ expect(createTaskModalMarkdownEditorMock).not.toHaveBeenCalled();
+ expect(modal.contentEl.querySelectorAll(".time-entry-editor-modal__entry")).toHaveLength(50);
+ expect(
+ modal.contentEl.querySelectorAll(".time-entry-editor-modal__description-editor-fallback")
+ ).toHaveLength(50);
+ });
+});
diff --git a/tests/unit/issues/issue-2222-settings-integrations-tab-visibility.test.ts b/tests/unit/issues/issue-2222-settings-integrations-tab-visibility.test.ts
new file mode 100644
index 000000000..c399c6a29
--- /dev/null
+++ b/tests/unit/issues/issue-2222-settings-integrations-tab-visibility.test.ts
@@ -0,0 +1,58 @@
+import fs from "fs";
+import path from "path";
+
+function readRepoFile(relativePath: string): string {
+ return fs.readFileSync(path.join(process.cwd(), relativePath), "utf8");
+}
+
+function extractCssBlock(css: string, selector: string): string {
+ const index = css.indexOf(selector);
+ if (index === -1) {
+ return "";
+ }
+
+ const blockStart = css.indexOf("{", index);
+ if (blockStart === -1) {
+ return "";
+ }
+
+ let depth = 0;
+ for (let i = blockStart; i < css.length; i += 1) {
+ if (css[i] === "{") {
+ depth += 1;
+ } else if (css[i] === "}") {
+ depth -= 1;
+ if (depth === 0) {
+ return css.slice(blockStart + 1, i);
+ }
+ }
+ }
+
+ return "";
+}
+
+describe("Issue #2222: settings integrations tab visibility", () => {
+ it("wraps the settings toolbar instead of clipping the final TaskNotes tab", () => {
+ const css = readRepoFile("styles/settings-view.css");
+ const toolbarBlock = extractCssBlock(
+ css,
+ ".tasknotes-plugin .settings-view__toolbar"
+ );
+ const tabNavBlock = extractCssBlock(
+ css,
+ ".tasknotes-plugin .settings-view__tab-nav"
+ );
+
+ expect(toolbarBlock).toContain("flex-wrap: wrap;");
+ expect(tabNavBlock).toContain("flex-wrap: wrap;");
+ expect(tabNavBlock).toContain("overflow: visible;");
+ expect(tabNavBlock).not.toContain("overflow-x: auto;");
+ });
+
+ it("keeps the documentation link pinned right while the toolbar wraps", () => {
+ const css = readRepoFile("styles/settings-view.css");
+ const headerBlock = extractCssBlock(css, ".tasknotes-plugin .settings-header");
+
+ expect(headerBlock).toContain("margin-inline-start: auto;");
+ });
+});
diff --git a/tests/unit/issues/issue-2229-google-oauth-external-browser.test.ts b/tests/unit/issues/issue-2229-google-oauth-external-browser.test.ts
new file mode 100644
index 000000000..e95d83e0b
--- /dev/null
+++ b/tests/unit/issues/issue-2229-google-oauth-external-browser.test.ts
@@ -0,0 +1,117 @@
+const mockOpenExternal = jest.fn();
+
+jest.mock("obsidian", () => ({
+ Platform: { isDesktopApp: true },
+ requestUrl: jest.fn(),
+}));
+
+jest.mock(
+ "electron",
+ () => ({
+ shell: {
+ openExternal: mockOpenExternal,
+ },
+ }),
+ { virtual: true }
+);
+
+import { OAuthService } from "../../../src/services/OAuthService";
+import { OAuthSecretStore } from "../../../src/services/OAuthSecretStore";
+import type TaskNotesPlugin from "../../../src/main";
+import type { OAuthConfig, OAuthProvider, OAuthTokens } from "../../../src/types";
+
+class InMemorySecretStorage {
+ private readonly values = new Map();
+
+ getSecret(id: string): string | null {
+ return this.values.get(id) ?? null;
+ }
+
+ setSecret(id: string, value: string): void {
+ this.values.set(id, value);
+ }
+}
+
+type OAuthServiceInternals = OAuthService & {
+ findAvailablePort: jest.Mock, [number, number]>;
+ startCallbackServer: jest.Mock, [number]>;
+ stopCallbackServer: jest.Mock, []>;
+ generateCodeVerifier: jest.Mock;
+ generateCodeChallenge: jest.Mock, [string]>;
+ generateState: jest.Mock;
+ buildAuthorizationUrl: jest.Mock;
+ waitForCallback: jest.Mock, [string, number]>;
+ exchangeCodeForTokens: jest.Mock, [OAuthConfig, string, string]>;
+ storeConnection: jest.Mock, [OAuthProvider, OAuthTokens]>;
+};
+
+function createOAuthService(): { service: OAuthService; authUrl: string } {
+ const secretStore = new OAuthSecretStore(new InMemorySecretStorage());
+ secretStore.setCredentials("google", {
+ clientId: "google-client-id",
+ clientSecret: "google-client-secret",
+ });
+ const service = new OAuthService(
+ {
+ emitter: {
+ trigger: jest.fn(),
+ },
+ } as unknown as TaskNotesPlugin,
+ secretStore
+ ) as unknown as OAuthServiceInternals;
+ const tokens: OAuthTokens = {
+ accessToken: "access-token",
+ refreshToken: "refresh-token",
+ expiresAt: Date.now() + 3600,
+ scope: "calendar",
+ tokenType: "Bearer",
+ };
+ const authUrl =
+ "https://accounts.google.com/o/oauth2/v2/auth?client_id=google-client-id";
+
+ service.findAvailablePort = jest.fn().mockResolvedValue(18080);
+ service.startCallbackServer = jest.fn().mockResolvedValue(undefined);
+ service.stopCallbackServer = jest.fn().mockResolvedValue(undefined);
+ service.generateCodeVerifier = jest.fn().mockReturnValue("code-verifier");
+ service.generateCodeChallenge = jest.fn().mockResolvedValue("code-challenge");
+ service.generateState = jest.fn().mockReturnValue("oauth-state");
+ service.buildAuthorizationUrl = jest.fn().mockReturnValue(authUrl);
+ service.waitForCallback = jest.fn().mockResolvedValue("authorization-code");
+ service.exchangeCodeForTokens = jest.fn().mockResolvedValue(tokens);
+ service.storeConnection = jest.fn().mockResolvedValue(undefined);
+
+ return { service, authUrl };
+}
+
+describe("Issue #2229: Google OAuth opens outside Obsidian Web Viewer", () => {
+ let windowOpenSpy: jest.SpyInstance>;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ windowOpenSpy = jest.spyOn(window, "open").mockImplementation(() => null);
+ });
+
+ afterEach(() => {
+ windowOpenSpy.mockRestore();
+ });
+
+ it("uses the system browser for the OAuth authorization URL", async () => {
+ mockOpenExternal.mockResolvedValue(undefined);
+ const { service, authUrl } = createOAuthService();
+
+ await service.authenticate("google");
+
+ expect(mockOpenExternal).toHaveBeenCalledWith(authUrl);
+ expect(windowOpenSpy).not.toHaveBeenCalled();
+ });
+
+ it("falls back to the existing window.open path when external launch fails", async () => {
+ mockOpenExternal.mockRejectedValue(new Error("external launch unavailable"));
+ const { service, authUrl } = createOAuthService();
+
+ await service.authenticate("google");
+
+ expect(mockOpenExternal).toHaveBeenCalledWith(authUrl);
+ expect(windowOpenSpy).toHaveBeenCalledWith(authUrl, "_blank");
+ });
+});
diff --git a/tests/unit/issues/issue-2239-mobile-embedded-tasklist-gap.test.ts b/tests/unit/issues/issue-2239-mobile-embedded-tasklist-gap.test.ts
new file mode 100644
index 000000000..12d586fe6
--- /dev/null
+++ b/tests/unit/issues/issue-2239-mobile-embedded-tasklist-gap.test.ts
@@ -0,0 +1,70 @@
+/**
+ * Issue #2239: Mobile markdown embeds should not inherit the full-screen
+ * Task List bottom inset.
+ *
+ * Direct Task List Bases need bottom clearance for Obsidian mobile chrome, but
+ * embedded Bases live in the note's document flow. Applying that same inset
+ * inside an embed creates a large blank block after the final task card.
+ *
+ * @see https://github.com/callumalpass/tasknotes/issues/2239
+ */
+
+import * as fs from "fs";
+import * as path from "path";
+
+const cssFilePath = path.resolve(__dirname, "../../../styles/bases-views.css");
+
+describe("Issue #2239: Mobile embedded Task List bottom gap", () => {
+ it("keeps the direct mobile Task List bottom inset", () => {
+ const cssContent = fs.readFileSync(cssFilePath, "utf-8");
+ const directMobileBlock = extractCssBlock(
+ cssContent,
+ "body.is-mobile .tn-tasknotesTaskList .tn-bases-items-container"
+ );
+
+ expect(directMobileBlock).toContain("padding-bottom: calc(128px");
+ expect(directMobileBlock).toContain("env(safe-area-inset-bottom");
+ expect(directMobileBlock).toContain("scroll-padding-bottom: calc(128px");
+ });
+
+ it("removes the full-screen mobile inset from markdown embedded Task List containers", () => {
+ const cssContent = fs.readFileSync(cssFilePath, "utf-8");
+ const internalEmbedItemsBlock = extractCssBlock(
+ cssContent,
+ "body.is-mobile .internal-embed .tn-tasknotesTaskList .tn-bases-items-container"
+ );
+ const markdownEmbedItemsBlock = extractCssBlock(
+ cssContent,
+ "body.is-mobile .markdown-embed .tn-tasknotesTaskList .tn-bases-items-container"
+ );
+
+ expect(internalEmbedItemsBlock).toContain("padding-bottom: 0");
+ expect(internalEmbedItemsBlock).toContain("scroll-padding-bottom: 0");
+ expect(markdownEmbedItemsBlock).toContain("padding-bottom: 0");
+ expect(markdownEmbedItemsBlock).toContain("scroll-padding-bottom: 0");
+ });
+
+ it("keeps normal embedded list padding without the mobile safe-area reserve", () => {
+ const cssContent = fs.readFileSync(cssFilePath, "utf-8");
+ const internalEmbedListBlock = extractCssBlock(
+ cssContent,
+ "body.is-mobile .internal-embed .tn-bases-tasknotes-list"
+ );
+ const markdownEmbedListBlock = extractCssBlock(
+ cssContent,
+ "body.is-mobile .markdown-embed .tn-bases-tasknotes-list"
+ );
+
+ expect(internalEmbedListBlock).toContain("padding-bottom: var(--tn-spacing-sm)");
+ expect(internalEmbedListBlock).toContain("scroll-padding-bottom: 0");
+ expect(markdownEmbedListBlock).toContain("padding-bottom: var(--tn-spacing-sm)");
+ expect(markdownEmbedListBlock).toContain("scroll-padding-bottom: 0");
+ });
+});
+
+function extractCssBlock(css: string, selector: string): string {
+ const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const regex = new RegExp(`${escapedSelector}[\\s\\S]*?\\{([^}]*?)\\}`, "s");
+ const match = css.match(regex);
+ return match ? match[1] : "";
+}
diff --git a/tests/unit/issues/issue-2246-occurrence-template-title-frontmatter.test.ts b/tests/unit/issues/issue-2246-occurrence-template-title-frontmatter.test.ts
new file mode 100644
index 000000000..b44a8fc6b
--- /dev/null
+++ b/tests/unit/issues/issue-2246-occurrence-template-title-frontmatter.test.ts
@@ -0,0 +1,129 @@
+/**
+ * Issue #2246: Occurrence filename template writes title frontmatter,
+ * so views display the title without the period suffix
+ *
+ * @see https://github.com/callumalpass/tasknotes/issues/2246
+ *
+ * With `storeTitleInFilename` enabled, a templated occurrence filename
+ * (e.g. "Pay rent — 2026-09") never equals the plain title, so the
+ * `titleIsRepresentedByFilename` check from the collision-handling fix
+ * always fails and every occurrence is born with a title property.
+ * The title property then wins over the filename when reading, hiding
+ * the period suffix in every view.
+ *
+ * Expected: when the templated filename is used as-is (no collision
+ * suffix, no sanitization loss), the filename represents the title and
+ * the title property should be omitted — matching the behavior for
+ * regular tasks whose filename equals their title.
+ */
+
+import type { TaskInfo } from '../../../src/types';
+import { PluginFactory } from '../../helpers/mock-factories';
+import { TaskCreationService } from '../../../src/services/task-service/TaskCreationService';
+import {
+ generateTaskFilename,
+ generateUniqueFilename,
+ generateOccurrenceFilename,
+} from '../../../src/utils/filenameGenerator';
+
+jest.mock('../../../src/utils/dateUtils', () => ({
+ getCurrentTimestamp: jest.fn(() => '2026-08-20T00:00:00-03:00'),
+}));
+
+jest.mock('../../../src/utils/filenameGenerator', () => ({
+ generateTaskFilename: jest.fn(() => 'Pay rent'),
+ generateUniqueFilename: jest.fn(async (base) => base),
+ generateOccurrenceFilename: jest.fn(() => 'Pay rent — 2026-09'),
+}));
+
+jest.mock('../../../src/utils/helpers', () => ({
+ ensureFolderExists: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock('../../../src/utils/templateProcessor', () => ({
+ mergeTemplateFrontmatter: jest.fn((base, template) => ({ ...base, ...template })),
+}));
+
+describe('Issue #2246: occurrence filename template vs title frontmatter', () => {
+ const mockGenerateUniqueFilename = generateUniqueFilename as jest.MockedFunction<
+ typeof generateUniqueFilename
+ >;
+ const mockGenerateOccurrenceFilename = generateOccurrenceFilename as jest.MockedFunction<
+ typeof generateOccurrenceFilename
+ >;
+ const mockGenerateTaskFilename = generateTaskFilename as jest.MockedFunction<
+ typeof generateTaskFilename
+ >;
+
+ beforeEach(() => {
+ mockGenerateTaskFilename.mockReturnValue('Pay rent');
+ mockGenerateOccurrenceFilename.mockReturnValue('Pay rent — 2026-09');
+ mockGenerateUniqueFilename.mockImplementation(async (base) => base);
+ });
+
+ function createService(overrides: { sanitizeForFilename?: (input: string) => string } = {}) {
+ const mockPlugin = PluginFactory.createMockPlugin();
+ mockPlugin.settings.storeTitleInFilename = true;
+
+ const service = new TaskCreationService({
+ runtime: mockPlugin,
+ applyTaskCreationDefaults: jest.fn(async (taskData) => taskData),
+ applyTemplate: jest.fn(async () => ({ frontmatter: {}, body: '' })),
+ processFolderTemplate: jest.fn((folderTemplate) => folderTemplate),
+ sanitizeTitleForFilename: jest.fn(overrides.sanitizeForFilename ?? ((input) => input)),
+ sanitizeTitleForStorage: jest.fn((input) => input),
+ });
+
+ return { mockPlugin, service };
+ }
+
+ const occurrenceTaskData: Partial = {
+ title: 'Pay rent',
+ recurrence_parent: '[[Tasks/Pay rent]]',
+ occurrence_date: '2026-09-01',
+ occurrenceFilenameTemplate: '{{title}} — {{occurrenceMonth}}',
+ };
+
+ it('omits title frontmatter when the templated occurrence filename is used as-is', async () => {
+ const { mockPlugin, service } = createService();
+
+ await service.createTask({ ...occurrenceTaskData }, { applyDefaults: false });
+
+ const [path, content] = mockPlugin.app.vault.create.mock.calls[0] as [string, string];
+
+ expect(path).toBe('Tasks/Pay rent — 2026-09.md');
+ expect(content).not.toContain('title:');
+ expect(mockPlugin.cacheManager.updateTaskInfoInCache).toHaveBeenCalledWith(
+ 'Tasks/Pay rent — 2026-09.md',
+ expect.objectContaining({ title: 'Pay rent' })
+ );
+ });
+
+ it('preserves title frontmatter when the templated filename needs a collision suffix', async () => {
+ mockGenerateUniqueFilename.mockResolvedValue('Pay rent — 2026-09-1');
+ const { mockPlugin, service } = createService();
+
+ await service.createTask({ ...occurrenceTaskData }, { applyDefaults: false });
+
+ const [path, content] = mockPlugin.app.vault.create.mock.calls[0] as [string, string];
+
+ expect(path).toBe('Tasks/Pay rent — 2026-09-1.md');
+ expect(content).toContain('title: Pay rent');
+ });
+
+ it('preserves title frontmatter when filename sanitization changes the title', async () => {
+ mockGenerateOccurrenceFilename.mockReturnValue('Pay rent — 2026-09');
+ const { mockPlugin, service } = createService({
+ sanitizeForFilename: (input) => input.replace(/:/g, ''),
+ });
+
+ await service.createTask(
+ { ...occurrenceTaskData, title: 'Pay: rent' },
+ { applyDefaults: false }
+ );
+
+ const [, content] = mockPlugin.app.vault.create.mock.calls[0] as [string, string];
+
+ expect(content).toContain('title: "Pay: rent"');
+ });
+});
diff --git a/tests/unit/issues/issue-2247-inline-task-link-alias.test.ts b/tests/unit/issues/issue-2247-inline-task-link-alias.test.ts
new file mode 100644
index 000000000..e7cf0acc1
--- /dev/null
+++ b/tests/unit/issues/issue-2247-inline-task-link-alias.test.ts
@@ -0,0 +1,93 @@
+import { TFile } from "obsidian";
+import TaskNotesPlugin from "../../../src/main";
+import type { TaskInfo } from "../../../src/types";
+import { sanitizeLinkAliasText } from "../../../src/utils/linkAliasUtils";
+import { App } from "../../helpers/obsidian-runtime";
+
+describe("Issue #2247: create-inline-task link aliases", () => {
+ it("strips nested wikilink markup from the inserted task-link alias", () => {
+ const app = new App();
+ const plugin = new TaskNotesPlugin(app as never, {} as never);
+ const taskFile = new TFile("Tasks/some task John Smith.md");
+ const editor = {
+ replaceRange: jest.fn(),
+ setCursor: jest.fn(),
+ };
+
+ plugin.app.vault.getAbstractFileByPath = jest.fn().mockReturnValue(taskFile);
+ plugin.app.workspace.getActiveFile = jest
+ .fn()
+ .mockReturnValue(new TFile("Notes/source.md"));
+ plugin.app.fileManager.generateMarkdownLink = jest
+ .fn()
+ .mockImplementation((_file, _sourcePath, _subpath, alias) =>
+ `[[some task John Smith|${alias}]]`
+ );
+
+ (plugin as any).handleInlineTaskCreated(
+ {
+ id: taskFile.path,
+ path: taskFile.path,
+ title: "some task [[John Smith]]",
+ status: "open",
+ priority: "normal",
+ archived: false,
+ } as TaskInfo,
+ { editor, insertionPoint: { line: 2, ch: 0 } }
+ );
+
+ expect(plugin.app.fileManager.generateMarkdownLink).toHaveBeenCalledWith(
+ taskFile,
+ "Notes/source.md",
+ "",
+ "some task John Smith"
+ );
+ expect(editor.replaceRange).toHaveBeenCalledWith(
+ "[[some task John Smith|some task John Smith]]",
+ { line: 2, ch: 0 }
+ );
+ });
+
+ it("flattens a wikilink nested inside a Markdown-link label", () => {
+ expect(
+ sanitizeLinkAliasText("review [the [[Projects/Q2|Q2]] plan](Projects/Q2.md)")
+ ).toBe("review the Q2 plan");
+ });
+
+ it("uses wikilink aliases and markdown-link labels in the inserted alias", () => {
+ const app = new App();
+ const plugin = new TaskNotesPlugin(app as never, {} as never);
+ const taskFile = new TFile("Tasks/review Q2 with Sam.md");
+ const editor = {
+ replaceRange: jest.fn(),
+ setCursor: jest.fn(),
+ };
+
+ plugin.app.vault.getAbstractFileByPath = jest.fn().mockReturnValue(taskFile);
+ plugin.app.workspace.getActiveFile = jest.fn().mockReturnValue(null);
+ plugin.app.fileManager.generateMarkdownLink = jest
+ .fn()
+ .mockImplementation((_file, _sourcePath, _subpath, alias) =>
+ `[[review Q2 with Sam|${alias}]]`
+ );
+
+ (plugin as any).handleInlineTaskCreated(
+ {
+ id: taskFile.path,
+ path: taskFile.path,
+ title: "review [[Projects/Q2|Q2]] with [Sam](People/Sam.md)",
+ status: "open",
+ priority: "normal",
+ archived: false,
+ } as TaskInfo,
+ { editor, insertionPoint: { line: 0, ch: 0 } }
+ );
+
+ expect(plugin.app.fileManager.generateMarkdownLink).toHaveBeenCalledWith(
+ taskFile,
+ "",
+ "",
+ "review Q2 with Sam"
+ );
+ });
+});
diff --git a/tests/unit/issues/issue-2255-reading-mode-scroll-churn.test.ts b/tests/unit/issues/issue-2255-reading-mode-scroll-churn.test.ts
new file mode 100644
index 000000000..4122c6932
--- /dev/null
+++ b/tests/unit/issues/issue-2255-reading-mode-scroll-churn.test.ts
@@ -0,0 +1,121 @@
+import { MarkdownView } from "obsidian";
+import { observeReadingModeWidgetMutations } from "../../../src/editor/ReadingModeWidgetObserver";
+
+const WIDGET_SELECTOR = ".tasknotes-task-card-note-widget";
+
+function createPreviewDom(): HTMLElement {
+ const containerEl = document.createElement("div");
+ containerEl.innerHTML = `
+
+ `;
+ document.body.appendChild(containerEl);
+ return containerEl;
+}
+
+function flushFrame(): Promise {
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * Emits a mutation batch that touches the widget selector without leaving a
+ * widget behind — mirroring Obsidian's virtualiser deleting the injected card.
+ */
+function simulateWidgetRemoval(containerEl: HTMLElement): void {
+ const sizer = containerEl.querySelector(".markdown-preview-sizer");
+ if (!sizer) throw new Error("missing sizer");
+ const widget = document.createElement("div");
+ widget.className = "tasknotes-plugin tasknotes-task-card-note-widget";
+ sizer.appendChild(widget);
+ sizer.removeChild(widget);
+}
+
+describe("Issue #2255: defer reading mode widget re-injection while scrolling", () => {
+ let cleanups: Array<() => void>;
+ let observedContainers: WeakSet;
+
+ beforeEach(() => {
+ cleanups = [];
+ observedContainers = new WeakSet();
+ });
+
+ afterEach(() => {
+ cleanups.forEach((cleanup) => cleanup());
+ document.body.innerHTML = "";
+ });
+
+ function register(
+ containerEl: HTMLElement,
+ options?: Parameters[6],
+ scheduleInjection = jest.fn()
+ ) {
+ const view = Object.assign(Object.create(MarkdownView.prototype), {
+ containerEl,
+ previewMode: { containerEl },
+ getMode: jest.fn(() => "preview"),
+ }) as MarkdownView;
+ const leaf = { parent: {}, view } as any;
+
+ observeReadingModeWidgetMutations(
+ leaf,
+ WIDGET_SELECTOR,
+ scheduleInjection,
+ observedContainers,
+ cleanups,
+ () => true,
+ options
+ );
+ return scheduleInjection;
+ }
+
+ it("injects immediately when no scrolling has occurred", async () => {
+ const containerEl = createPreviewDom();
+ const scheduleInjection = register(containerEl);
+
+ simulateWidgetRemoval(containerEl);
+ await flushFrame();
+
+ expect(scheduleInjection).toHaveBeenCalled();
+ });
+
+ it("waits for scrolling to settle before re-injecting", async () => {
+ const containerEl = createPreviewDom();
+ const scheduleInjection = register(containerEl, { scrollQuietPeriodMs: 80 });
+
+ containerEl.dispatchEvent(new Event("scroll"));
+ simulateWidgetRemoval(containerEl);
+
+ await flushFrame();
+ await flushFrame();
+
+ // Still inside the quiet period: no injection despite the widget being gone.
+ expect(scheduleInjection).not.toHaveBeenCalled();
+
+ await sleep(120);
+
+ // Polling continues past the quiet period and restores the widget once.
+ expect(scheduleInjection).toHaveBeenCalledTimes(1);
+ });
+
+ it("re-injects right away when the last scroll is older than the quiet period", async () => {
+ const containerEl = createPreviewDom();
+ const scheduleInjection = register(containerEl, { scrollQuietPeriodMs: 40 });
+
+ containerEl.dispatchEvent(new Event("scroll"));
+ await sleep(60);
+
+ simulateWidgetRemoval(containerEl);
+ await flushFrame();
+
+ expect(scheduleInjection).toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/issues/issue-2256-kanban-swimlane-sort-order.test.ts b/tests/unit/issues/issue-2256-kanban-swimlane-sort-order.test.ts
new file mode 100644
index 000000000..fc2897faf
--- /dev/null
+++ b/tests/unit/issues/issue-2256-kanban-swimlane-sort-order.test.ts
@@ -0,0 +1,64 @@
+import {
+ applyDefaultKanbanSwimLaneOrder,
+ applyKanbanSwimLaneOrder,
+} from "../../../src/bases/kanbanGrouping";
+
+describe("Issue #2256: Bases sort drives swimlane row order", () => {
+ const genericOptions = {
+ swimLanePropertyId: "note.projects" as string | null,
+ isPriorityField: () => false,
+ isStatusField: () => false,
+ getPriorityWeight: () => 0,
+ getStatusOrder: () => 0,
+ };
+
+ it("preserves encounter order (derived from the Bases sort) instead of sorting alphabetically", () => {
+ // Encounter order comes from iterating Bases-sorted tasks: the swimlane
+ // holding the first (nearest due) task must stay on top.
+ const actualKeys = ["project b", "project a", "project c"];
+
+ expect(applyDefaultKanbanSwimLaneOrder({ ...genericOptions, actualKeys })).toEqual([
+ "project b",
+ "project a",
+ "project c",
+ ]);
+ });
+
+ it("does not flatten a single-key difference into alphabetical order", () => {
+ const actualKeys = ["zeta", "alpha"];
+
+ expect(applyDefaultKanbanSwimLaneOrder({ ...genericOptions, actualKeys })).toEqual([
+ "zeta",
+ "alpha",
+ ]);
+ });
+
+ it("still appends keys missing from a configured order in encounter order", () => {
+ const ordered = applyKanbanSwimLaneOrder({
+ swimLanePropertyId: "note.projects",
+ actualKeys: ["late", "early"],
+ swimLaneOrders: { "note.projects": ["early"] },
+ hideEmptySwimLanes: false,
+ ...genericOptions,
+ isPriorityField: genericOptions.isPriorityField,
+ isStatusField: genericOptions.isStatusField,
+ });
+
+ expect(ordered).toEqual(["early", "late"]);
+ });
+
+ it("keeps priority swimlanes ordered by weight regardless of encounter order", () => {
+ const ordered = applyKanbanSwimLaneOrder({
+ swimLanePropertyId: "task.priority",
+ actualKeys: ["low", "high"],
+ swimLaneOrders: {},
+ hideEmptySwimLanes: false,
+ isPriorityField: (propertyId) => propertyId === "task.priority",
+ isStatusField: () => false,
+ getPriorityWeight: (key) => ({ high: 3, medium: 2, low: 1 })[key] ?? 0,
+ getStatusOrder: () => 0,
+ });
+
+ expect(ordered).toEqual(["high", "low"]);
+ });
+});
diff --git a/tests/unit/issues/issue-google-calendar-delete-retry-queue.test.ts b/tests/unit/issues/issue-google-calendar-delete-retry-queue.test.ts
index ccfe74e41..a6e4bb3d7 100644
--- a/tests/unit/issues/issue-google-calendar-delete-retry-queue.test.ts
+++ b/tests/unit/issues/issue-google-calendar-delete-retry-queue.test.ts
@@ -106,10 +106,59 @@ describe("Google Calendar deletion retry queue", () => {
const result = await syncService.processDeletionQueue();
expect(result).toEqual({ deleted: 1, failed: 0, remaining: 0 });
- expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith("primary", "event-1");
+ expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith(
+ "primary",
+ "event-1",
+ expect.any(Number)
+ );
expect(pluginData.googleCalendarDeletionQueue).toEqual([]);
});
+ it("clears recurring exception metadata after its queued deletion succeeds", async () => {
+ const taskPath = "TaskNotes/Tasks/recurring-exception.md";
+ const pluginData = {
+ googleCalendarDeletionQueue: [
+ {
+ taskPath,
+ calendarId: "primary",
+ eventId: "exception-event",
+ createdAt: 1,
+ attempts: 1,
+ lastAttemptAt: 1,
+ },
+ ],
+ };
+ const plugin = createPlugin(pluginData);
+ plugin.cacheManager.getTaskInfo = jest.fn().mockResolvedValue(
+ TaskFactory.createTask({
+ path: taskPath,
+ googleCalendarEventId: "primary-event",
+ googleCalendarExceptionEventId: "exception-event",
+ googleCalendarExceptionOriginalScheduled: "2026-08-05",
+ })
+ );
+ const syncService = new TaskCalendarSyncService(
+ plugin,
+ createGoogleCalendarService() as any
+ );
+ const saveExceptionMetadata = jest
+ .spyOn(syncService as any, "saveTaskExceptionMetadata")
+ .mockResolvedValue(undefined);
+
+ const result = await syncService.processDeletionQueue();
+
+ expect(result).toEqual({ deleted: 1, failed: 0, remaining: 0 });
+ expect(saveExceptionMetadata).toHaveBeenCalledWith(
+ taskPath,
+ {
+ googleCalendarExceptionEventId: undefined,
+ googleCalendarExceptionOriginalScheduled: undefined,
+ },
+ "primary",
+ expect.any(Number)
+ );
+ });
+
it("treats already-deleted Google events as successful cleanup", async () => {
const pluginData = {
googleCalendarDeletionQueue: [
@@ -200,7 +249,11 @@ describe("Google Calendar deletion retry queue", () => {
const result = await syncService.processDeletionQueue();
expect(result).toEqual({ deleted: 1, failed: 0, remaining: 0 });
- expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith("primary", "event-from-index");
+ expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith(
+ "primary",
+ "event-from-index",
+ expect.any(Number)
+ );
expect(pluginData.googleCalendarDeletionQueue).toEqual([]);
expect(pluginData.googleCalendarEventIndex).toEqual([]);
});
@@ -224,7 +277,11 @@ describe("Google Calendar deletion retry queue", () => {
await syncService.processRecoveryQueues();
- expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith("primary", "event-from-index");
+ expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith(
+ "primary",
+ "event-from-index",
+ expect.any(Number)
+ );
expect(pluginData.googleCalendarDeletionQueue).toEqual([]);
expect(pluginData.googleCalendarEventIndex).toEqual([]);
});
@@ -385,7 +442,11 @@ describe("Google Calendar deletion retry queue", () => {
);
expect(synced).toBe(true);
- expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith("primary", "old-event");
+ expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith(
+ "primary",
+ "old-event",
+ expect.any(Number)
+ );
expect(pluginData.googleCalendarDeletionQueue).toBeUndefined();
expect(pluginData.googleCalendarEventIndex).toEqual([
expect.objectContaining({
@@ -499,7 +560,8 @@ describe("Google Calendar deletion retry queue", () => {
"primary",
expect.objectContaining({
start: { date: "2026-04-29" },
- })
+ }),
+ expect.any(Number)
);
expect(pluginData.googleCalendarSyncQueue).toEqual([]);
expect(pluginData.googleCalendarEventIndex).toEqual([
@@ -540,7 +602,8 @@ describe("Google Calendar deletion retry queue", () => {
"existing-event-id",
expect.objectContaining({
start: { date: "2026-05-02" },
- })
+ }),
+ expect.any(Number)
);
expect(pluginData.googleCalendarSyncQueue).toEqual([]);
});
@@ -569,7 +632,11 @@ describe("Google Calendar deletion retry queue", () => {
const result = await syncService.processPendingSyncQueue();
expect(result).toEqual({ synced: 0, failed: 0, deleted: 1, dropped: 0, remaining: 0 });
- expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith("primary", "event-to-delete");
+ expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith(
+ "primary",
+ "event-to-delete",
+ expect.any(Number)
+ );
expect(pluginData.googleCalendarSyncQueue).toEqual([]);
});
});
diff --git a/tests/unit/issues/issue-google-calendar-duplicate-sync.test.ts b/tests/unit/issues/issue-google-calendar-duplicate-sync.test.ts
index 145b43029..5a3be9af9 100644
--- a/tests/unit/issues/issue-google-calendar-duplicate-sync.test.ts
+++ b/tests/unit/issues/issue-google-calendar-duplicate-sync.test.ts
@@ -54,6 +54,12 @@ const createPlugin = (
modify: jest.fn().mockImplementation(async (_file: TFile, content: string) => {
fileContent = content;
}),
+ process: jest
+ .fn()
+ .mockImplementation(async (_file: TFile, update: (content: string) => string) => {
+ fileContent = update(fileContent);
+ return fileContent;
+ }),
},
fileManager: {
processFrontMatter:
@@ -158,7 +164,8 @@ describe("Google Calendar duplicate sync prevention", () => {
"created-event-id",
expect.objectContaining({
start: { date: "2026-04-30" },
- })
+ }),
+ expect.any(Number)
);
});
@@ -201,7 +208,7 @@ describe("Google Calendar duplicate sync prevention", () => {
expect(frontmatter.googleCalendarEventId).toBe("first-event-id");
});
- it("keeps in-flight creates shared if the original sync service is destroyed", async () => {
+ it("does not let an in-flight create from a destroyed service write metadata", async () => {
const frontmatter: Record = {};
const firstPlugin = createPlugin(frontmatter);
const secondPlugin = createPlugin(frontmatter);
@@ -236,7 +243,9 @@ describe("Google Calendar duplicate sync prevention", () => {
};
const firstSync = firstSyncService.syncTaskToCalendar(task);
- await Promise.resolve();
+ for (let index = 0; index < 10 && googleCalendarService.createEvent.mock.calls.length === 0; index++) {
+ await Promise.resolve();
+ }
expect(googleCalendarService.createEvent).toHaveBeenCalledTimes(1);
firstSyncService.destroy();
@@ -245,9 +254,51 @@ describe("Google Calendar duplicate sync prevention", () => {
expect(googleCalendarService.createEvent).toHaveBeenCalledTimes(1);
resolveCreate({ id: "google-primary-created-event-id" });
- await Promise.all([firstSync, secondSync]);
+ await expect(Promise.all([firstSync, secondSync])).resolves.toEqual([false, false]);
- expect(frontmatter.googleCalendarEventId).toBe("created-event-id");
+ expect(frontmatter.googleCalendarEventId).toBeUndefined();
+ expect(firstPlugin.saveData).toHaveBeenCalledWith(
+ expect.objectContaining({
+ googleCalendarDeletionQueue: [
+ expect.objectContaining({ eventId: "created-event-id" }),
+ ],
+ })
+ );
+ });
+
+ it("queues a created event for deletion when metadata persistence fails", async () => {
+ const frontmatter: Record = {};
+ const plugin = createPlugin(frontmatter, {
+ processFrontMatter: jest.fn().mockRejectedValue(new Error("disk write failed")),
+ });
+ const googleCalendarService = {
+ getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]),
+ createEvent: jest
+ .fn()
+ .mockResolvedValue({ id: "google-primary-created-without-metadata" }),
+ updateEvent: jest.fn().mockResolvedValue(undefined),
+ deleteEvent: jest.fn().mockResolvedValue(undefined),
+ };
+ const syncService = new TaskCalendarSyncService(plugin as any, googleCalendarService as any);
+ const task: TaskInfo = {
+ path: "TaskNotes/Tasks/metadata-write-failure.md",
+ title: "Metadata write failure",
+ status: "open",
+ priority: "normal",
+ scheduled: "2026-04-29",
+ archived: false,
+ };
+
+ await expect(syncService.syncTaskToCalendar(task)).resolves.toBe(false);
+
+ expect(frontmatter.googleCalendarEventId).toBeUndefined();
+ expect(plugin.saveData).toHaveBeenCalledWith(
+ expect.objectContaining({
+ googleCalendarDeletionQueue: [
+ expect.objectContaining({ eventId: "created-without-metadata" }),
+ ],
+ })
+ );
});
it("does not create duplicate detached recurring exception events across sync services", async () => {
@@ -295,6 +346,44 @@ describe("Google Calendar duplicate sync prevention", () => {
expect(frontmatter.googleCalendarExceptionEventId).toBe("detached-exception-id");
});
+ it("preserves concurrent orphan-event deletion queue additions", async () => {
+ const frontmatter: Record = {};
+ const firstPlugin = createPlugin(frontmatter);
+ const secondPlugin = createPlugin(frontmatter);
+ let pluginData: Record = {};
+ for (const plugin of [firstPlugin, secondPlugin]) {
+ plugin.loadData = jest.fn(async () => pluginData);
+ plugin.loadPluginDataForSafeWrite = jest.fn(async () => ({ ...pluginData }));
+ plugin.saveData = jest.fn(async (nextData: Record) => {
+ pluginData = nextData;
+ });
+ }
+ const googleCalendarService = {
+ getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]),
+ };
+ const firstService = new TaskCalendarSyncService(
+ firstPlugin as any,
+ googleCalendarService as any
+ ) as any;
+ const secondService = new TaskCalendarSyncService(
+ secondPlugin as any,
+ googleCalendarService as any
+ ) as any;
+
+ await Promise.all([
+ firstService.queueCalendarDeletion("Tasks/one.md", "primary", "event-one"),
+ secondService.queueCalendarDeletion("Tasks/two.md", "primary", "event-two"),
+ ]);
+
+ expect(pluginData.googleCalendarDeletionQueue).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ eventId: "event-one" }),
+ expect.objectContaining({ eventId: "event-two" }),
+ ])
+ );
+ expect(pluginData.googleCalendarDeletionQueue).toHaveLength(2);
+ });
+
it("does not leave a failed create in flight and allows a later retry", async () => {
const frontmatter: Record = {};
const plugin = createPlugin(frontmatter);
@@ -324,6 +413,115 @@ describe("Google Calendar duplicate sync prevention", () => {
expect(frontmatter.googleCalendarEventId).toBe("created-event-id");
});
+ it("does not write event metadata after the OAuth connection generation changes", async () => {
+ const frontmatter: Record = {};
+ const plugin = createPlugin(frontmatter);
+ let connectionGeneration = 1;
+ let resolveCreate!: (value: { id: string }) => void;
+ const createPromise = new Promise<{ id: string }>((resolve) => {
+ resolveCreate = resolve;
+ });
+ const googleCalendarService = {
+ getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]),
+ getConnectionGeneration: jest.fn(() => connectionGeneration),
+ isConnectionGenerationCurrent: jest.fn(
+ async (expected: number) => expected === connectionGeneration
+ ),
+ createEvent: jest.fn().mockReturnValue(createPromise),
+ updateEvent: jest.fn().mockResolvedValue(undefined),
+ deleteEvent: jest.fn().mockResolvedValue(undefined),
+ };
+ const syncService = new TaskCalendarSyncService(plugin as any, googleCalendarService as any);
+ const task: TaskInfo = {
+ path: "TaskNotes/Tasks/disconnected-create.md",
+ title: "Disconnected create",
+ status: "open",
+ priority: "normal",
+ scheduled: "2026-04-29",
+ archived: false,
+ };
+
+ const sync = syncService.syncTaskToCalendar(task);
+ for (let index = 0; index < 10 && googleCalendarService.createEvent.mock.calls.length === 0; index++) {
+ await Promise.resolve();
+ }
+ expect(googleCalendarService.createEvent).toHaveBeenCalledTimes(1);
+
+ connectionGeneration = 2;
+ resolveCreate({ id: "google-primary-created-after-disconnect" });
+
+ await expect(sync).resolves.toBe(false);
+ expect(plugin.app.fileManager.processFrontMatter).not.toHaveBeenCalled();
+ expect(frontmatter.googleCalendarEventId).toBeUndefined();
+ expect(plugin.saveData).toHaveBeenCalledWith(
+ expect.objectContaining({
+ googleCalendarDeletionQueue: [
+ expect.objectContaining({
+ calendarId: "primary",
+ eventId: "created-after-disconnect",
+ }),
+ ],
+ })
+ );
+ });
+
+ it("rolls back metadata when disconnect occurs during the Obsidian write", async () => {
+ const frontmatter: Record = {};
+ let releaseWrite!: () => void;
+ let markWriteStarted!: () => void;
+ const writeGate = new Promise((resolve) => {
+ releaseWrite = resolve;
+ });
+ const writeStarted = new Promise((resolve) => {
+ markWriteStarted = resolve;
+ });
+ let processCallCount = 0;
+ const processFrontMatter = jest.fn(
+ async (_file: TFile, update: (fm: Record) => void) => {
+ processCallCount += 1;
+ update(frontmatter);
+ if (processCallCount === 1) {
+ markWriteStarted();
+ await writeGate;
+ }
+ }
+ );
+ const plugin = createPlugin(frontmatter, { processFrontMatter });
+ let connectionGeneration = 1;
+ const googleCalendarService = {
+ getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]),
+ getConnectionGeneration: jest.fn(() => connectionGeneration),
+ isConnectionGenerationCurrent: jest.fn(
+ async (expected: number) => expected === connectionGeneration
+ ),
+ createEvent: jest
+ .fn()
+ .mockResolvedValue({ id: "google-primary-created-during-disconnect" }),
+ updateEvent: jest.fn().mockResolvedValue(undefined),
+ deleteEvent: jest.fn().mockResolvedValue(undefined),
+ };
+ const syncService = new TaskCalendarSyncService(plugin as any, googleCalendarService as any);
+ const task: TaskInfo = {
+ path: "TaskNotes/Tasks/disconnect-during-write.md",
+ title: "Disconnect during write",
+ status: "open",
+ priority: "normal",
+ scheduled: "2026-04-29",
+ archived: false,
+ };
+
+ const sync = syncService.syncTaskToCalendar(task);
+ await writeStarted;
+ expect(frontmatter.googleCalendarEventId).toBe("created-during-disconnect");
+
+ connectionGeneration = 2;
+ releaseWrite();
+
+ await expect(sync).resolves.toBe(false);
+ expect(processFrontMatter).toHaveBeenCalledTimes(2);
+ expect(frontmatter.googleCalendarEventId).toBeUndefined();
+ });
+
it("repairs duplicate Google Calendar event ID frontmatter when saving a new event ID", async () => {
const duplicateKeyError = Object.assign(new Error("Map keys must be unique"), {
code: "DUPLICATE_KEY",
@@ -341,6 +539,8 @@ describe("Google Calendar duplicate sync prevention", () => {
" - task",
"googleCalendarEventId: first-event",
"googleCalendarEventId: second-event",
+ "googleCalendarExceptionOriginalScheduled: 2026-05-28",
+ "googleCalendarExceptionOriginalScheduled: 2026-05-29",
"---",
"",
"Duplicate event ID task",
@@ -370,13 +570,22 @@ describe("Google Calendar duplicate sync prevention", () => {
await syncService.syncTaskToCalendar(task);
expect(googleCalendarService.createEvent).toHaveBeenCalledTimes(1);
- expect(plugin.app.vault.modify).toHaveBeenCalledTimes(1);
+ expect(plugin.app.vault.process).toHaveBeenCalledTimes(1);
+ expect(plugin.app.vault.read).not.toHaveBeenCalled();
+ expect(plugin.app.vault.modify).not.toHaveBeenCalled();
- const repairedContent = plugin.app.vault.modify.mock.calls[0][1];
+ const repairedContent = await plugin.app.vault.read();
expect(repairedContent.match(/^googleCalendarEventId:/gm)).toHaveLength(1);
expect(repairedContent).toContain("googleCalendarEventId: created-event-id");
expect(repairedContent).not.toContain("googleCalendarEventId: first-event");
expect(repairedContent).not.toContain("googleCalendarEventId: second-event");
+ expect(repairedContent.match(/^googleCalendarExceptionOriginalScheduled:/gm)).toHaveLength(1);
+ expect(repairedContent).toContain(
+ "googleCalendarExceptionOriginalScheduled: 2026-05-29"
+ );
+ expect(repairedContent).not.toContain(
+ "googleCalendarExceptionOriginalScheduled: 2026-05-28"
+ );
expect(repairedContent).toContain("tags:\n - task");
});
});
diff --git a/tests/unit/issues/issue-google-calendar-external-file-reconciliation.test.ts b/tests/unit/issues/issue-google-calendar-external-file-reconciliation.test.ts
index 8286dd637..eb419b143 100644
--- a/tests/unit/issues/issue-google-calendar-external-file-reconciliation.test.ts
+++ b/tests/unit/issues/issue-google-calendar-external-file-reconciliation.test.ts
@@ -164,7 +164,8 @@ describe("Google Calendar external file reconciliation", () => {
expect.objectContaining({
summary: "✓ Prepare plan",
description: expect.stringContaining("Status: Done"),
- })
+ }),
+ expect.any(Number)
);
expect(pluginData.googleCalendarTaskFingerprints).toMatchObject({
[doneTask.path]: (syncService as any).getCalendarRelevantFingerprint(doneTask),
@@ -290,10 +291,146 @@ describe("Google Calendar external file reconciliation", () => {
expect.objectContaining({
summary: "✓ Offline linked",
description: expect.stringContaining("Status: Done"),
- })
+ }),
+ expect.any(Number)
+ );
+ });
+
+ it("exports a task created while Obsidian was closed", async () => {
+ const knownTask = {
+ path: "TaskNotes/Tasks/already-known.md",
+ title: "Already known",
+ status: "ready",
+ priority: "3-medium",
+ archived: false,
+ scheduled: "2026-05-14",
+ googleCalendarEventId: "event-1",
+ } as TaskInfo;
+ const newTask = {
+ path: "TaskNotes/Tasks/created-offline.md",
+ title: "Created offline",
+ status: "ready",
+ priority: "3-medium",
+ archived: false,
+ scheduled: "2026-05-15",
+ } as TaskInfo;
+ const pluginData: Record = {};
+ const plugin = createPlugin([knownTask, newTask], {}, pluginData);
+ const googleCalendarService = createGoogleCalendarService();
+ const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any);
+ pluginData.googleCalendarTaskFingerprints = {
+ [knownTask.path]: (syncService as any).getCalendarRelevantFingerprint(knownTask),
+ };
+
+ await syncService.initializeExternalFileReconciliation();
+
+ expect(googleCalendarService.createEvent).toHaveBeenCalledTimes(1);
+ expect(googleCalendarService.createEvent).toHaveBeenCalledWith(
+ "primary",
+ expect.objectContaining({ summary: "Created offline" }),
+ expect.any(Number)
);
});
+ it("does not export tasks on the first reconciliation of a vault", async () => {
+ const firstTask = {
+ path: "TaskNotes/Tasks/first-run-a.md",
+ title: "First run A",
+ status: "ready",
+ priority: "3-medium",
+ archived: false,
+ scheduled: "2026-05-14",
+ } as TaskInfo;
+ const secondTask = {
+ ...firstTask,
+ path: "TaskNotes/Tasks/first-run-b.md",
+ title: "First run B",
+ } as TaskInfo;
+ const pluginData: Record = {};
+ const plugin = createPlugin([firstTask, secondTask], {}, pluginData);
+ const googleCalendarService = createGoogleCalendarService();
+ const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any);
+
+ await syncService.initializeExternalFileReconciliation();
+
+ expect(googleCalendarService.createEvent).not.toHaveBeenCalled();
+ expect(pluginData.googleCalendarTaskFingerprints).toMatchObject({
+ [firstTask.path]: (syncService as any).getCalendarRelevantFingerprint(firstTask),
+ [secondTask.path]: (syncService as any).getCalendarRelevantFingerprint(secondTask),
+ });
+ });
+
+ it("does not duplicate an event when the new task already carries an event id", async () => {
+ const knownTask = {
+ path: "TaskNotes/Tasks/already-known.md",
+ title: "Already known",
+ status: "ready",
+ priority: "3-medium",
+ archived: false,
+ scheduled: "2026-05-14",
+ googleCalendarEventId: "event-1",
+ } as TaskInfo;
+ const newTaskWithEvent = {
+ path: "TaskNotes/Tasks/created-offline-with-event.md",
+ title: "Created offline with event",
+ status: "ready",
+ priority: "3-medium",
+ archived: false,
+ scheduled: "2026-05-15",
+ googleCalendarEventId: "event-created-elsewhere",
+ } as TaskInfo;
+ const pluginData: Record = {};
+ const plugin = createPlugin([knownTask, newTaskWithEvent], {}, pluginData);
+ const googleCalendarService = createGoogleCalendarService();
+ const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any);
+ pluginData.googleCalendarTaskFingerprints = {
+ [knownTask.path]: (syncService as any).getCalendarRelevantFingerprint(knownTask),
+ };
+
+ await syncService.initializeExternalFileReconciliation();
+
+ expect(googleCalendarService.createEvent).not.toHaveBeenCalled();
+ expect(googleCalendarService.updateEvent).not.toHaveBeenCalled();
+ expect(pluginData.googleCalendarTaskFingerprints).toMatchObject({
+ [newTaskWithEvent.path]: (syncService as any).getCalendarRelevantFingerprint(
+ newTaskWithEvent
+ ),
+ });
+ });
+
+ it("does not export an ineligible task created while Obsidian was closed", async () => {
+ const knownTask = {
+ path: "TaskNotes/Tasks/already-known.md",
+ title: "Already known",
+ status: "ready",
+ priority: "3-medium",
+ archived: false,
+ scheduled: "2026-05-14",
+ googleCalendarEventId: "event-1",
+ } as TaskInfo;
+ const undatedTask = {
+ path: "TaskNotes/Tasks/created-offline-undated.md",
+ title: "Created offline undated",
+ status: "ready",
+ priority: "3-medium",
+ archived: false,
+ } as TaskInfo;
+ const pluginData: Record = {};
+ const plugin = createPlugin([knownTask, undatedTask], {}, pluginData);
+ const googleCalendarService = createGoogleCalendarService();
+ const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any);
+ pluginData.googleCalendarTaskFingerprints = {
+ [knownTask.path]: (syncService as any).getCalendarRelevantFingerprint(knownTask),
+ };
+
+ await syncService.initializeExternalFileReconciliation();
+
+ expect(googleCalendarService.createEvent).not.toHaveBeenCalled();
+ expect(pluginData.googleCalendarTaskFingerprints).toMatchObject({
+ [undatedTask.path]: (syncService as any).getCalendarRelevantFingerprint(undatedTask),
+ });
+ });
+
it("baselines missing startup fingerprints for linked tasks without API writes", async () => {
const task = {
path: "TaskNotes/Tasks/existing-linked.md",
diff --git a/tests/unit/issues/issue-provider-calendar-source-name.test.ts b/tests/unit/issues/issue-provider-calendar-source-name.test.ts
new file mode 100644
index 000000000..383c14a09
--- /dev/null
+++ b/tests/unit/issues/issue-provider-calendar-source-name.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it, jest } from "@jest/globals";
+import { createICSEventCard, updateICSEventCard } from "../../../src/ui/ICSCard";
+import type { ICSEvent } from "../../../src/types";
+
+function createEvent(overrides: Partial = {}): ICSEvent {
+ return {
+ id: "google-primary-event-1",
+ subscriptionId: "google-primary",
+ title: "Team sync",
+ start: "2026-08-03T10:00:00",
+ end: "2026-08-03T11:00:00",
+ allDay: false,
+ ...overrides,
+ };
+}
+
+function createPlugin() {
+ const provider = {
+ providerName: "Google Calendar",
+ extractEventIds: (event: ICSEvent) => ({
+ calendarId: event.subscriptionId.replace("google-", ""),
+ eventId: event.id,
+ }),
+ getAvailableCalendars: jest.fn(() => [
+ {
+ id: "person@example.com",
+ summary: "Personal",
+ primary: true,
+ },
+ ]),
+ };
+
+ return {
+ app: {},
+ i18n: {
+ translate: (key: string) => (key === "ui.icsCard.calendarFallback" ? "Calendar" : key),
+ },
+ settings: {
+ calendarViewSettings: {
+ timeFormat: "24",
+ },
+ },
+ icsSubscriptionService: {
+ getSubscriptions: () => [],
+ },
+ calendarProviderRegistry: {
+ findProviderForEvent: jest.fn(() => provider),
+ },
+ };
+}
+
+describe("provider calendar source names on ICS cards", () => {
+ it("shows a provider calendar name for primary-alias Google events", () => {
+ const card = createICSEventCard(createEvent(), createPlugin() as any);
+
+ expect(card.querySelector(".task-card__metadata")?.textContent).toContain("Personal");
+ expect(card.querySelector(".task-card__metadata")?.textContent).not.toContain("Calendar");
+ });
+
+ it("refreshes the provider calendar name when an existing card updates", () => {
+ const plugin = createPlugin();
+ const card = createICSEventCard(createEvent(), plugin as any);
+ plugin.calendarProviderRegistry.findProviderForEvent.mockReturnValue({
+ providerName: "Microsoft Calendar",
+ extractEventIds: () => ({ calendarId: "work", eventId: "event-1" }),
+ getAvailableCalendars: () => [{ id: "work", summary: "Work" }],
+ });
+
+ updateICSEventCard(
+ card,
+ createEvent({ id: "microsoft-work-event-1", subscriptionId: "microsoft-work" }),
+ plugin as any
+ );
+
+ expect(card.querySelector(".task-card__metadata")?.textContent).toContain("Work");
+ expect(card.querySelector(".task-card__metadata")?.textContent).not.toContain("Personal");
+ });
+
+ it("uses the translated fallback when no subscription or provider owns the event", () => {
+ const plugin = createPlugin();
+ plugin.calendarProviderRegistry.findProviderForEvent.mockReturnValue(undefined);
+
+ const card = createICSEventCard(
+ createEvent({ id: "unknown-event", subscriptionId: "unknown" }),
+ plugin as any
+ );
+
+ expect(card.querySelector(".task-card__metadata")?.textContent).toContain("Calendar");
+ });
+});
diff --git a/tests/unit/modals/TimeEntryEditorModal.test.ts b/tests/unit/modals/TimeEntryEditorModal.test.ts
index 85ca8096c..6bb5edec9 100644
--- a/tests/unit/modals/TimeEntryEditorModal.test.ts
+++ b/tests/unit/modals/TimeEntryEditorModal.test.ts
@@ -11,11 +11,52 @@ describe("TimeEntryEditorModal", () => {
typeof createTaskModalMarkdownEditor
>;
+ function focusFirstDescriptionEditor(modal: TimeEntryEditorModal) {
+ const textarea = modal.contentEl.querySelector(
+ ".time-entry-editor-modal__description-editor-fallback"
+ );
+ expect(textarea).not.toBeNull();
+
+ textarea?.dispatchEvent(new Event("focus"));
+ expect(createTaskModalMarkdownEditorMock).toHaveBeenCalled();
+
+ return createTaskModalMarkdownEditorMock.mock.calls[
+ createTaskModalMarkdownEditorMock.mock.calls.length - 1
+ ][2];
+ }
+
beforeEach(() => {
jest.clearAllMocks();
});
- it("saves description updates coming from the markdown editor", () => {
+ it("saves description updates from the lightweight description field", () => {
+ const plugin = PluginFactory.createMockPlugin();
+ const task = TaskFactory.createTask({
+ timeEntries: [TimeEntryFactory.createEntry({ description: "Initial work" })],
+ });
+ const onSave = jest.fn();
+
+ const modal = new TimeEntryEditorModal(plugin.app as any, plugin as any, task, onSave);
+ modal.onOpen();
+
+ const textarea = modal.contentEl.querySelector(
+ ".time-entry-editor-modal__description-editor-fallback"
+ );
+ expect(textarea).not.toBeNull();
+ expect(createTaskModalMarkdownEditorMock).not.toHaveBeenCalled();
+
+ if (textarea) {
+ textarea.value = "Worked on #learning";
+ textarea.dispatchEvent(new Event("input"));
+ }
+ (modal as any).save();
+
+ expect(onSave).toHaveBeenCalledWith([
+ expect.objectContaining({ description: "Worked on #learning" }),
+ ]);
+ });
+
+ it("saves description updates coming from the lazily hydrated markdown editor", () => {
const plugin = PluginFactory.createMockPlugin();
const task = TaskFactory.createTask({
timeEntries: [TimeEntryFactory.createEntry({ description: "Initial work" })],
@@ -29,8 +70,9 @@ describe("TimeEntryEditorModal", () => {
const modal = new TimeEntryEditorModal(plugin.app as any, plugin as any, task, onSave);
modal.onOpen();
+ expect(createTaskModalMarkdownEditorMock).not.toHaveBeenCalled();
+ const editorOptions = focusFirstDescriptionEditor(modal);
expect(createTaskModalMarkdownEditorMock).toHaveBeenCalledTimes(1);
- const editorOptions = createTaskModalMarkdownEditorMock.mock.calls[0][2];
editorOptions.onChange("Worked on #learning");
(modal as any).save();
@@ -54,7 +96,7 @@ describe("TimeEntryEditorModal", () => {
const closeSpy = jest.spyOn(modal, "close").mockImplementation(jest.fn());
modal.onOpen();
- const editorOptions = createTaskModalMarkdownEditorMock.mock.calls[0][2];
+ const editorOptions = focusFirstDescriptionEditor(modal);
editorOptions.onEscape();
expect(closeSpy).toHaveBeenCalledTimes(1);
@@ -72,20 +114,22 @@ describe("TimeEntryEditorModal", () => {
createTaskModalMarkdownEditorMock
.mockReturnValueOnce(firstEditor)
- .mockReturnValueOnce(secondEditor)
- .mockReturnValueOnce(thirdEditor);
+ .mockReturnValueOnce(secondEditor);
const modal = new TimeEntryEditorModal(plugin.app as any, plugin as any, task, jest.fn());
modal.onOpen();
+ focusFirstDescriptionEditor(modal);
(modal as any).addNewEntry();
expect(firstEditor.destroy).toHaveBeenCalledTimes(1);
- expect(createTaskModalMarkdownEditorMock).toHaveBeenCalledTimes(3);
+ expect(createTaskModalMarkdownEditorMock).toHaveBeenCalledTimes(1);
+
+ focusFirstDescriptionEditor(modal);
modal.onClose();
expect(secondEditor.destroy).toHaveBeenCalledTimes(1);
- expect(thirdEditor.destroy).toHaveBeenCalledTimes(1);
+ expect(createTaskModalMarkdownEditorMock).toHaveBeenCalledTimes(2);
});
});
diff --git a/tests/unit/services/TaskService.test.ts b/tests/unit/services/TaskService.test.ts
index 28762757b..079e311a1 100644
--- a/tests/unit/services/TaskService.test.ts
+++ b/tests/unit/services/TaskService.test.ts
@@ -838,6 +838,245 @@ describe('TaskService', () => {
expect(result.completedDate).toBeUndefined();
});
+ it('should record an explicit completion date on a non-recurring status completion', async () => {
+ const nonRecurringTask = TaskFactory.createTask({ recurrence: undefined });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(nonRecurringTask);
+
+ const result = await taskService.updateProperty(nonRecurringTask, 'status', 'done', {
+ completionDate: '2025-05-20',
+ });
+
+ expect(result.status).toBe('done');
+ expect(result.completedDate).toBe('2025-05-20');
+ });
+
+ it('should record a chosen completion date for an undated non-recurring task', async () => {
+ const undatedTask = TaskFactory.createTask({
+ recurrence: undefined,
+ scheduled: undefined,
+ due: undefined,
+ });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(undatedTask);
+
+ const result = await taskService.updateProperty(undatedTask, 'status', 'done', {
+ completionDate: '2025-04-15',
+ });
+
+ expect(result.status).toBe('done');
+ expect(result.completedDate).toBe('2025-04-15');
+ });
+
+ it('should default the completion date to today when none is provided', async () => {
+ const nonRecurringTask = TaskFactory.createTask({ recurrence: undefined });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(nonRecurringTask);
+
+ const result = await taskService.updateProperty(nonRecurringTask, 'status', 'done');
+
+ expect(result.completedDate).toBe('2025-01-01'); // mocked today, unchanged
+ });
+
+ it('should ignore an explicit completion date for recurring tasks', async () => {
+ const recurringTask = TaskFactory.createTask({ recurrence: 'FREQ=DAILY' });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
+
+ const result = await taskService.updateProperty(recurringTask, 'status', 'done', {
+ completionDate: '2025-05-20',
+ });
+
+ expect(result.completedDate).toBeUndefined();
+ });
+
+ // #3: rescheduling a recurring task onto a recorded date reactivates that occurrence.
+ it('removes the rescheduled date from complete_instances for a recurring task', async () => {
+ const recurringTask = TaskFactory.createTask({
+ recurrence: 'FREQ=WEEKLY',
+ scheduled: '2026-06-09',
+ complete_instances: ['2026-06-02', '2026-05-26'],
+ skipped_instances: [],
+ });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
+
+ const result = await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-02');
+
+ expect(result.complete_instances).toEqual(['2026-05-26']);
+ });
+
+ it('removes the rescheduled date from skipped_instances for a recurring task', async () => {
+ const recurringTask = TaskFactory.createTask({
+ recurrence: 'FREQ=WEEKLY',
+ scheduled: '2026-06-09',
+ complete_instances: [],
+ skipped_instances: ['2026-06-02'],
+ });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
+
+ const result = await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-02');
+
+ expect(result.skipped_instances).toEqual([]);
+ });
+
+ it('ignores the time component when matching the rescheduled date', async () => {
+ const recurringTask = TaskFactory.createTask({
+ recurrence: 'FREQ=WEEKLY',
+ scheduled: '2026-06-09',
+ complete_instances: ['2026-06-02'],
+ skipped_instances: [],
+ });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
+
+ const result = await taskService.updateProperty(
+ recurringTask,
+ 'scheduled',
+ '2026-06-02T09:30'
+ );
+
+ expect(result.complete_instances).toEqual([]);
+ });
+
+ it('preserves recurring history for an unchanged scheduled value', async () => {
+ const recurringTask = TaskFactory.createTask({
+ recurrence: 'FREQ=WEEKLY',
+ scheduled: '2026-06-12T09:00',
+ complete_instances: ['2026-06-12', '2026-06-19'],
+ skipped_instances: ['2026-06-26'],
+ });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
+ const confirm = jest.fn(async () => true);
+
+ const result = await taskService.updateProperty(
+ recurringTask,
+ 'scheduled',
+ '2026-06-12T09:00',
+ { confirmClearInstances: confirm }
+ );
+
+ expect(result.complete_instances).toEqual(['2026-06-12', '2026-06-19']);
+ expect(result.skipped_instances).toEqual(['2026-06-26']);
+ expect(confirm).not.toHaveBeenCalled();
+ });
+
+ it('preserves recurring history when only the scheduled time changes', async () => {
+ const recurringTask = TaskFactory.createTask({
+ recurrence: 'FREQ=WEEKLY',
+ scheduled: '2026-06-12T09:00',
+ complete_instances: ['2026-06-12', '2026-06-19'],
+ skipped_instances: ['2026-06-26'],
+ });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
+ const confirm = jest.fn(async () => true);
+
+ const result = await taskService.updateProperty(
+ recurringTask,
+ 'scheduled',
+ '2026-06-12T10:00',
+ { confirmClearInstances: confirm }
+ );
+
+ expect(result.scheduled).toBe('2026-06-12T10:00');
+ expect(result.complete_instances).toEqual(['2026-06-12', '2026-06-19']);
+ expect(result.skipped_instances).toEqual(['2026-06-26']);
+ expect(confirm).not.toHaveBeenCalled();
+ });
+
+ it('clears an off-schedule completion/skip recorded on or after the rescheduled date, keeping older history', async () => {
+ // Off-schedule completion (06-08) + later skip (06-19), reschedule back to 06-05:
+ // the ">= new date" rule clears both while keeping the older 05-29 history.
+ const recurringTask = TaskFactory.createTask({
+ recurrence: 'FREQ=WEEKLY',
+ scheduled: '2026-06-12',
+ complete_instances: ['2026-05-29', '2026-06-08'],
+ skipped_instances: ['2026-06-19'],
+ });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
+
+ const result = await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-05');
+
+ expect(result.complete_instances).toEqual(['2026-05-29']);
+ expect(result.skipped_instances).toEqual([]);
+ });
+
+ it('leaves complete/skipped instances untouched when nothing is on or after the rescheduled date', async () => {
+ const recurringTask = TaskFactory.createTask({
+ recurrence: 'FREQ=WEEKLY',
+ scheduled: '2026-06-09',
+ complete_instances: ['2026-06-02'],
+ skipped_instances: ['2026-05-26'],
+ });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
+
+ const result = await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-16');
+
+ expect(result.complete_instances).toEqual(['2026-06-02']);
+ expect(result.skipped_instances).toEqual(['2026-05-26']);
+ });
+
+ it('does not touch instances for a non-recurring reschedule', async () => {
+ const nonRecurring = TaskFactory.createTask({
+ recurrence: undefined,
+ scheduled: '2026-06-09',
+ });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(nonRecurring);
+
+ await expect(
+ taskService.updateProperty(nonRecurring, 'scheduled', '2026-06-02')
+ ).resolves.toBeDefined();
+ });
+
+ it('aborts the reschedule (no write, instances intact) when confirmClearInstances returns false', async () => {
+ const recurringTask = TaskFactory.createTask({
+ recurrence: 'FREQ=WEEKLY',
+ scheduled: '2026-06-12',
+ complete_instances: ['2026-06-08'],
+ skipped_instances: ['2026-06-19'],
+ });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
+ const confirm = jest.fn(async () => false);
+
+ const result = await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-05', {
+ confirmClearInstances: confirm,
+ });
+
+ expect(confirm).toHaveBeenCalledWith({ complete: ['2026-06-08'], skipped: ['2026-06-19'] });
+ expect(result.complete_instances).toEqual(['2026-06-08']);
+ expect(result.scheduled).toBe('2026-06-12'); // unchanged
+ expect(mockPlugin.app.fileManager.processFrontMatter).not.toHaveBeenCalled();
+ });
+
+ it('proceeds and clears when confirmClearInstances returns true', async () => {
+ const recurringTask = TaskFactory.createTask({
+ recurrence: 'FREQ=WEEKLY',
+ scheduled: '2026-06-12',
+ complete_instances: ['2026-06-08'],
+ skipped_instances: [],
+ });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
+ const confirm = jest.fn(async () => true);
+
+ const result = await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-05', {
+ confirmClearInstances: confirm,
+ });
+
+ expect(confirm).toHaveBeenCalled();
+ expect(result.complete_instances).toEqual([]);
+ });
+
+ it('does not call confirmClearInstances when nothing would be cleared', async () => {
+ const recurringTask = TaskFactory.createTask({
+ recurrence: 'FREQ=WEEKLY',
+ scheduled: '2026-06-12',
+ complete_instances: ['2026-05-01'],
+ skipped_instances: [],
+ });
+ mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
+ const confirm = jest.fn(async () => true);
+
+ await taskService.updateProperty(recurringTask, 'scheduled', '2026-06-05', {
+ confirmClearInstances: confirm,
+ });
+
+ expect(confirm).not.toHaveBeenCalled();
+ });
+
it('should remove empty due/scheduled dates', async () => {
await taskService.updateProperty(task, 'due', undefined);
diff --git a/tests/unit/services/taskRecurringPlanning.test.ts b/tests/unit/services/taskRecurringPlanning.test.ts
index bb70cb41d..bfd607a73 100644
--- a/tests/unit/services/taskRecurringPlanning.test.ts
+++ b/tests/unit/services/taskRecurringPlanning.test.ts
@@ -30,6 +30,15 @@ function createRecurringTask(overrides: Partial = {}): TaskInfo {
} as TaskInfo;
}
+function createMovedWeeklyTask(overrides: Partial = {}): TaskInfo {
+ return createRecurringTask({
+ recurrence: "DTSTART:20260406;FREQ=WEEKLY;BYDAY=MO",
+ scheduled: "2026-04-10",
+ googleCalendarExceptionOriginalScheduled: "2026-04-13",
+ ...overrides,
+ });
+}
+
describe("taskRecurringPlanning", () => {
beforeEach(() => {
mockGetTodayString.mockReturnValue("2026-05-19");
@@ -193,4 +202,156 @@ describe("taskRecurringPlanning", () => {
expect(frontmatter.scheduled).toBe("2026-05-20");
expect(frontmatter.dateModified).toBe("2026-05-19T07:15:00+10:00");
});
+
+ describe("Google Calendar occurrences moved before their series date", () => {
+ beforeEach(() => {
+ mockGetTodayString.mockReturnValue("2026-04-10");
+ });
+
+ it("completes the moved date and advances past the replaced series date", () => {
+ const plan = buildRecurringTaskCompletePlan({
+ freshTask: createMovedWeeklyTask(),
+ targetDate: new Date("2026-04-10T12:00:00.000Z"),
+ currentTimestamp: "2026-04-10T12:30:00.000Z",
+ maintainDueDateOffsetInRecurring: true,
+ });
+
+ expect(plan.updatedTask.complete_instances).toEqual(["2026-04-10"]);
+ expect(plan.updatedTask.skipped_instances).toEqual([]);
+ expect(plan.updatedTask.scheduled).toBe("2026-04-20");
+ expect(plan.updatedTask.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]);
+ expect(plan.updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined();
+ });
+
+ it("skips the moved date and advances past the replaced series date", () => {
+ const plan = buildRecurringTaskSkippedPlan({
+ freshTask: createMovedWeeklyTask(),
+ targetDate: new Date("2026-04-10T12:00:00.000Z"),
+ currentTimestamp: "2026-04-10T12:30:00.000Z",
+ maintainDueDateOffsetInRecurring: true,
+ });
+
+ expect(plan.updatedTask.skipped_instances).toEqual(["2026-04-10"]);
+ expect(plan.updatedTask.complete_instances).toEqual([]);
+ expect(plan.updatedTask.scheduled).toBe("2026-04-20");
+ expect(plan.updatedTask.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]);
+ expect(plan.updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined();
+ });
+
+ it("keeps the replaced date excluded after intervening occurrences", () => {
+ const movedPlan = buildRecurringTaskCompletePlan({
+ freshTask: createMovedWeeklyTask({
+ recurrence: "DTSTART:20260409;FREQ=DAILY",
+ }),
+ targetDate: new Date("2026-04-10T12:00:00.000Z"),
+ currentTimestamp: "2026-04-10T12:30:00.000Z",
+ maintainDueDateOffsetInRecurring: true,
+ });
+ const interveningPlan = buildRecurringTaskCompletePlan({
+ freshTask: movedPlan.updatedTask,
+ targetDate: new Date("2026-04-11T12:00:00.000Z"),
+ currentTimestamp: "2026-04-11T12:30:00.000Z",
+ maintainDueDateOffsetInRecurring: true,
+ });
+ const beforeReplacedDatePlan = buildRecurringTaskCompletePlan({
+ freshTask: interveningPlan.updatedTask,
+ targetDate: new Date("2026-04-12T12:00:00.000Z"),
+ currentTimestamp: "2026-04-12T12:30:00.000Z",
+ maintainDueDateOffsetInRecurring: true,
+ });
+
+ expect(movedPlan.updatedTask.scheduled).toBe("2026-04-11");
+ expect(interveningPlan.updatedTask.scheduled).toBe("2026-04-12");
+ expect(beforeReplacedDatePlan.updatedTask.scheduled).toBe("2026-04-14");
+ expect(beforeReplacedDatePlan.updatedTask.complete_instances).toEqual([
+ "2026-04-10",
+ "2026-04-11",
+ "2026-04-12",
+ ]);
+ expect(beforeReplacedDatePlan.updatedTask.skipped_instances).toEqual([]);
+ expect(beforeReplacedDatePlan.updatedTask.googleCalendarMovedOriginalDates).toEqual([
+ "2026-04-13",
+ ]);
+ });
+
+ it("does not persist the replaced series date when uncompleting or unskipping", () => {
+ const completePlan = buildRecurringTaskCompletePlan({
+ freshTask: createMovedWeeklyTask({ complete_instances: ["2026-04-10"] }),
+ targetDate: new Date("2026-04-10T12:00:00.000Z"),
+ currentTimestamp: "2026-04-10T12:30:00.000Z",
+ maintainDueDateOffsetInRecurring: true,
+ });
+ const skipPlan = buildRecurringTaskSkippedPlan({
+ freshTask: createMovedWeeklyTask({ skipped_instances: ["2026-04-10"] }),
+ targetDate: new Date("2026-04-10T12:00:00.000Z"),
+ currentTimestamp: "2026-04-10T12:30:00.000Z",
+ maintainDueDateOffsetInRecurring: true,
+ });
+
+ expect(completePlan.newComplete).toBe(false);
+ expect(completePlan.updatedTask.complete_instances).toEqual([]);
+ expect(completePlan.updatedTask.skipped_instances).toEqual([]);
+ expect(skipPlan.newSkipped).toBe(false);
+ expect(skipPlan.updatedTask.complete_instances).toEqual([]);
+ expect(skipPlan.updatedTask.skipped_instances).toEqual([]);
+ for (const plan of [completePlan, skipPlan]) {
+ expect(plan.updatedTask.scheduled).toBe("2026-04-20");
+ expect(plan.updatedTask.complete_instances).not.toContain("2026-04-13");
+ expect(plan.updatedTask.skipped_instances).not.toContain("2026-04-13");
+ }
+ });
+
+ it("preserves the moved date's due offset when advancing", () => {
+ const plan = buildRecurringTaskCompletePlan({
+ freshTask: createMovedWeeklyTask({ due: "2026-04-12" }),
+ targetDate: new Date("2026-04-10T12:00:00.000Z"),
+ currentTimestamp: "2026-04-10T12:30:00.000Z",
+ maintainDueDateOffsetInRecurring: true,
+ });
+
+ expect(plan.updatedTask.scheduled).toBe("2026-04-20");
+ expect(plan.updatedTask.due).toBe("2026-04-22");
+ });
+
+ it("keeps later moved occurrences advancing normally", () => {
+ const plan = buildRecurringTaskCompletePlan({
+ freshTask: createMovedWeeklyTask({ scheduled: "2026-04-15" }),
+ targetDate: new Date("2026-04-15T12:00:00.000Z"),
+ currentTimestamp: "2026-04-15T12:30:00.000Z",
+ maintainDueDateOffsetInRecurring: true,
+ });
+
+ expect(plan.updatedTask.complete_instances).toEqual(["2026-04-15"]);
+ expect(plan.updatedTask.scheduled).toBe("2026-04-20");
+ });
+
+ it("does not change completion-anchored recurrence calculation", () => {
+ const baseTask = createMovedWeeklyTask({ recurrence_anchor: "completion" });
+ const withoutGoogleException = {
+ ...baseTask,
+ googleCalendarExceptionOriginalScheduled: undefined,
+ };
+ const input = {
+ targetDate: new Date("2026-04-10T12:00:00.000Z"),
+ currentTimestamp: "2026-04-10T12:30:00.000Z",
+ maintainDueDateOffsetInRecurring: true,
+ };
+
+ const withException = buildRecurringTaskCompletePlan({
+ freshTask: baseTask,
+ ...input,
+ });
+ const withoutException = buildRecurringTaskCompletePlan({
+ freshTask: withoutGoogleException,
+ ...input,
+ });
+
+ expect(withException.updatedTask.scheduled).toBe(
+ withoutException.updatedTask.scheduled
+ );
+ expect(withException.updatedTask.recurrence).toBe(
+ withoutException.updatedTask.recurrence
+ );
+ });
+ });
});
diff --git a/tests/unit/services/taskUpdatePlanning.test.ts b/tests/unit/services/taskUpdatePlanning.test.ts
index 31215bfc9..5c83ca4fe 100644
--- a/tests/unit/services/taskUpdatePlanning.test.ts
+++ b/tests/unit/services/taskUpdatePlanning.test.ts
@@ -232,6 +232,85 @@ describe("taskUpdatePlanning", () => {
expect(result.finalTags).toEqual([]);
});
+ it("clears contexts and blockedBy when an update explicitly sets them to empty arrays", () => {
+ const frontmatter: Record = {
+ title: "Old",
+ status: "open",
+ contexts: ["old"],
+ blockedBy: [{ uid: "[[Other]]", reltype: "FINISHTOSTART" }],
+ tags: ["task"],
+ };
+
+ applyTaskUpdateFrontmatterChange({
+ frontmatter,
+ originalTask: createTask(),
+ updates: { contexts: [], blockedBy: [] },
+ recurrenceUpdates: {},
+ dateModified: "2026-05-19T09:00:00.000Z",
+ fieldMapper: createFieldMapper(),
+ taskIdentification: {
+ method: "tag",
+ tag: "task",
+ propertyName: "",
+ propertyValue: "",
+ },
+ storeTitleInFilename: false,
+ updateCompletedDateInFrontmatter: jest.fn(),
+ });
+
+ expect(frontmatter).not.toHaveProperty("contexts");
+ expect(frontmatter).not.toHaveProperty("blockedBy");
+ });
+
+ it("leaves contexts and blockedBy alone when the update omits them or keeps values", () => {
+ const frontmatter: Record = {
+ title: "Old",
+ status: "open",
+ contexts: ["old"],
+ blockedBy: [{ uid: "[[Other]]", reltype: "FINISHTOSTART" }],
+ tags: ["task"],
+ };
+
+ applyTaskUpdateFrontmatterChange({
+ frontmatter,
+ originalTask: createTask(),
+ updates: { title: "Renamed" },
+ recurrenceUpdates: {},
+ dateModified: "2026-05-19T09:00:00.000Z",
+ fieldMapper: createFieldMapper(),
+ taskIdentification: {
+ method: "tag",
+ tag: "task",
+ propertyName: "",
+ propertyValue: "",
+ },
+ storeTitleInFilename: false,
+ updateCompletedDateInFrontmatter: jest.fn(),
+ });
+
+ expect(frontmatter.contexts).toEqual(["old"]);
+ expect(frontmatter.blockedBy).toEqual([{ uid: "[[Other]]", reltype: "FINISHTOSTART" }]);
+
+ applyTaskUpdateFrontmatterChange({
+ frontmatter,
+ originalTask: createTask(),
+ updates: { contexts: ["new"] },
+ recurrenceUpdates: {},
+ dateModified: "2026-05-19T09:00:00.000Z",
+ fieldMapper: createFieldMapper(),
+ taskIdentification: {
+ method: "tag",
+ tag: "task",
+ propertyName: "",
+ propertyValue: "",
+ },
+ storeTitleInFilename: false,
+ updateCompletedDateInFrontmatter: jest.fn(),
+ });
+
+ expect(frontmatter.contexts).toEqual(["new"]);
+ });
+
it("builds the returned task state from the same planned mutation", () => {
const updated = buildUpdatedTaskFromPlan({
originalTask: createTask({ completedDate: undefined }),
diff --git a/tests/unit/ui/completionDateResolver.test.ts b/tests/unit/ui/completionDateResolver.test.ts
new file mode 100644
index 000000000..655a7fdbc
--- /dev/null
+++ b/tests/unit/ui/completionDateResolver.test.ts
@@ -0,0 +1,134 @@
+import { resolveCompletionDate } from "../../../src/ui/completionDateResolver";
+import { formatDateForStorage, getTodayString } from "../../../src/utils/dateUtils";
+import type { TaskInfo } from "../../../src/types";
+
+function task(overrides: Partial = {}): TaskInfo {
+ return {
+ id: "Tasks/t.md",
+ path: "Tasks/t.md",
+ title: "T",
+ status: "open",
+ priority: "normal",
+ ...overrides,
+ } as TaskInfo;
+}
+
+describe("resolveCompletionDate", () => {
+ describe("today", () => {
+ it("resolves to today for recurring and non-recurring", () => {
+ const nonRecurring = resolveCompletionDate(task({ scheduled: "2026-06-02" }), "today");
+ const recurring = resolveCompletionDate(
+ task({ recurrence: "DTSTART:20260601;FREQ=WEEKLY;BYDAY=TU", scheduled: "2026-06-02" }),
+ "today"
+ );
+ expect(nonRecurring.available).toBe(true);
+ expect(recurring.available).toBe(true);
+ if (nonRecurring.available) {
+ expect(formatDateForStorage(nonRecurring.date)).toBe(getTodayString());
+ }
+ });
+ });
+
+ describe("asScheduled", () => {
+ it("resolves to the task scheduled date when present", () => {
+ const result = resolveCompletionDate(task({ scheduled: "2026-06-02" }), "asScheduled");
+ expect(result).toEqual({ available: true, date: expect.any(Date) });
+ if (result.available) {
+ expect(formatDateForStorage(result.date)).toBe("2026-06-02");
+ }
+ });
+
+ it("prefers the clicked occurrence over the task scheduled date", () => {
+ const occurrenceDate = new Date("2026-06-09T00:00:00Z");
+ const result = resolveCompletionDate(
+ task({ recurrence: "x", scheduled: "2026-06-02" }),
+ "asScheduled",
+ { occurrenceDate }
+ );
+ if (result.available) {
+ expect(formatDateForStorage(result.date)).toBe("2026-06-09");
+ } else {
+ throw new Error("expected available");
+ }
+ });
+
+ it("is unavailable when there is no scheduled date (including due-only recurring)", () => {
+ const nonRecurring = resolveCompletionDate(task({ due: "2026-06-02" }), "asScheduled");
+ const dueOnlyRecurring = resolveCompletionDate(
+ task({ recurrence: "x", due: "2026-06-02" }),
+ "asScheduled"
+ );
+ expect(nonRecurring).toEqual({
+ available: false,
+ reasonKey: "contextMenus.task.completion.noScheduledDate",
+ });
+ expect(dueOnlyRecurring.available).toBe(false);
+ });
+
+ it("never falls through to due", () => {
+ const result = resolveCompletionDate(task({ due: "2026-06-30" }), "asScheduled");
+ expect(result.available).toBe(false);
+ });
+
+ it("resolves to scheduled for a completion-anchored recurrence (explicit re-anchor)", () => {
+ const result = resolveCompletionDate(
+ task({
+ recurrence: "x",
+ recurrence_anchor: "completion",
+ scheduled: "2026-06-02",
+ }),
+ "asScheduled"
+ );
+ if (result.available) {
+ expect(formatDateForStorage(result.date)).toBe("2026-06-02");
+ } else {
+ throw new Error("expected available");
+ }
+ });
+ });
+
+ describe("onDue", () => {
+ it("resolves to the due date when present", () => {
+ const result = resolveCompletionDate(task({ due: "2026-06-30" }), "onDue");
+ if (result.available) {
+ expect(formatDateForStorage(result.date)).toBe("2026-06-30");
+ } else {
+ throw new Error("expected available");
+ }
+ });
+
+ it("is unavailable when there is no due date", () => {
+ const result = resolveCompletionDate(task({ scheduled: "2026-06-02" }), "onDue");
+ expect(result).toEqual({
+ available: false,
+ reasonKey: "contextMenus.task.completion.noDueDate",
+ });
+ });
+ });
+
+ it("asScheduled and onDue never resolve to the same date when both exist", () => {
+ const t = task({ scheduled: "2026-06-02", due: "2026-06-30" });
+ const scheduled = resolveCompletionDate(t, "asScheduled");
+ const due = resolveCompletionDate(t, "onDue");
+ expect(scheduled.available && due.available).toBe(true);
+ if (scheduled.available && due.available) {
+ expect(formatDateForStorage(scheduled.date)).not.toBe(formatDateForStorage(due.date));
+ }
+ });
+
+ describe("onPicked", () => {
+ it("returns the picked date, preserving time", () => {
+ const pickedDate = new Date("2026-05-20T14:30:00Z");
+ const result = resolveCompletionDate(task(), "onPicked", { pickedDate });
+ expect(result).toEqual({ available: true, date: pickedDate });
+ });
+
+ it("is unavailable when no date was picked", () => {
+ const result = resolveCompletionDate(task(), "onPicked");
+ expect(result).toEqual({
+ available: false,
+ reasonKey: "contextMenus.task.completion.noPickedDate",
+ });
+ });
+ });
+});
diff --git a/tests/unit/utils/calendarDescription.test.ts b/tests/unit/utils/calendarDescription.test.ts
new file mode 100644
index 000000000..b62294200
--- /dev/null
+++ b/tests/unit/utils/calendarDescription.test.ts
@@ -0,0 +1,125 @@
+import {
+ htmlToPlainText,
+ looksLikeHtml,
+ normalizeCalendarDescription,
+} from "../../../src/utils/calendarDescription";
+
+describe("calendarDescription", () => {
+ describe("looksLikeHtml", () => {
+ it("detects markup", () => {
+ expect(looksLikeHtml("Hello
")).toBe(true);
+ expect(looksLikeHtml("Line
Break")).toBe(true);
+ expect(looksLikeHtml('Link')).toBe(true);
+ expect(looksLikeHtml("Text")).toBe(true);
+ });
+
+ it("does not treat comparison operators as markup", () => {
+ expect(looksLikeHtml("Bring < 10 items and > 2 bags")).toBe(false);
+ expect(looksLikeHtml("Budget: 5 < 10")).toBe(false);
+ });
+
+ it("does not treat angle-bracketed plain text as markup", () => {
+ expect(looksLikeHtml("Contact ")).toBe(false);
+ expect(looksLikeHtml("Venue: ")).toBe(false);
+ });
+
+ it("does not treat plain text as markup", () => {
+ expect(looksLikeHtml("Reference: ABC123\n\nGuests: 2")).toBe(false);
+ });
+ });
+
+ describe("normalizeCalendarDescription", () => {
+ it("returns plain-text descriptions unchanged", () => {
+ const plain =
+ "Appointment with the clinic\n\nContact: \nVenue: ";
+ expect(normalizeCalendarDescription(plain)).toBe(plain);
+ });
+
+ it("returns undefined for missing or empty values", () => {
+ expect(normalizeCalendarDescription(undefined)).toBeUndefined();
+ expect(normalizeCalendarDescription(null)).toBeUndefined();
+ expect(normalizeCalendarDescription("")).toBeUndefined();
+ });
+
+ it("returns undefined when markup carries no text", () => {
+ expect(normalizeCalendarDescription("
")).toBeUndefined();
+ });
+
+ it("flattens paragraph markup", () => {
+ const html =
+ "Reservation confirmed on 2024-05-01.
\n" +
+ "Party of 4.\nDuration: 90 minutes.
";
+
+ expect(normalizeCalendarDescription(html)).toBe(
+ "Reservation confirmed on 2024-05-01.\n\nParty of 4.\nDuration: 90 minutes."
+ );
+ });
+
+ it("flattens list markup, as written by the Google Calendar editor", () => {
+ const html =
+ "- Matinee at the Example Cinema, Screen 2
" +
+ "- Reference: ABC123
" +
+ "- Adult (2)
" +
+ "- Seats: A1,A2
";
+
+ expect(normalizeCalendarDescription(html)).toBe(
+ "- Matinee at the Example Cinema, Screen 2\n" +
+ "- Reference: ABC123\n" +
+ "- Adult (2)\n" +
+ "- Seats: A1,A2"
+ );
+ });
+
+ it("decodes entities so escaped punctuation is not shown literally", () => {
+ const html = "Meet at the Queen's Hall, tea & cake after.
";
+ expect(normalizeCalendarDescription(html)).toBe(
+ "Meet at the Queen's Hall, tea & cake after."
+ );
+ });
+ });
+
+ describe("htmlToPlainText", () => {
+ it("turns line breaks into newlines", () => {
+ expect(htmlToPlainText("First
Second
Third")).toBe("First\nSecond\nThird");
+ });
+
+ it("keeps link targets reachable", () => {
+ expect(htmlToPlainText('Booking')).toBe(
+ "Booking (https://example.com/booking)"
+ );
+ });
+
+ it("does not duplicate a link whose label is its target", () => {
+ expect(htmlToPlainText('https://example.com')).toBe(
+ "https://example.com"
+ );
+ });
+
+ it("preserves an Obsidian URI written by task export", () => {
+ const html =
+ "Project: " +
+ 'Note
';
+ expect(htmlToPlainText(html)).toBe(
+ "Project: Note (obsidian://open?vault=Example%20Vault&file=Note.md)"
+ );
+ });
+
+ it("drops script and style content", () => {
+ const html = "Visible
";
+ expect(htmlToPlainText(html)).toBe("Visible");
+ });
+
+ it("drops non-content nested inside links", () => {
+ const html = 'Visible';
+ expect(htmlToPlainText(html)).toBe("Visible (https://example.com)");
+ });
+
+ it("collapses runs of blank lines", () => {
+ expect(htmlToPlainText("One
Two
")).toBe("One\n\nTwo");
+ });
+
+ it("normalizes non-breaking spaces", () => {
+ expect(htmlToPlainText("Room 12
")).toBe("Room 12");
+ });
+ });
+});
diff --git a/versions.json b/versions.json
index 60092a3c6..6491e6785 100644
--- a/versions.json
+++ b/versions.json
@@ -33,5 +33,7 @@
"4.12.0": "1.12.2",
"4.12.1": "1.12.2",
"4.12.2": "1.12.2",
- "4.12.3": "1.12.2"
+ "4.12.3": "1.12.2",
+ "4.12.4": "1.12.2",
+ "4.12.5": "1.12.2"
}
\ No newline at end of file