Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 189 additions & 7 deletions src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, it, expect } from "vitest"
import { execFileSync } from "node:child_process"
import { readFileSync } from "node:fs"
import { describe, it, expect, vi } from "vitest"
import {
tasks,
type ParsedTask,
Expand Down Expand Up @@ -268,7 +270,9 @@ describe("tasks.extractTasks", () => {
])

expect(() => tasks.charForStatus("todo", registry)).toThrow(
'no checkbox symbol for status "todo" in the Tasks plugin registry (the default " " is typed non_task)',
new Error(
'no checkbox symbol for status "todo" in the Tasks plugin registry (the default " " is typed non_task)',
),
)
})

Expand All @@ -290,7 +294,9 @@ describe("tasks.extractTasks", () => {
])

expect(() => tasks.charForStatus("cancelled", registry)).toThrow(
'no checkbox symbol for status "cancelled" in the Tasks plugin registry (the default "-" is typed todo)',
new Error(
'no checkbox symbol for status "cancelled" in the Tasks plugin registry (the default "-" is typed todo)',
),
)
})

Expand Down Expand Up @@ -535,6 +541,18 @@ describe("tasks.extractTasks", () => {
expect(extracted).toEqual([task({ description: "T", priority })])
})

it.each([
{ signifierCount: 21, description: "T" },
{ signifierCount: 22, description: "T ⏫" },
])(
"leaves '$description' after stripping $signifierCount trailing priority signifiers",
({ signifierCount, description }) => {
const extracted = tasks.extractTasks(`- [ ] T${" ⏫".repeat(signifierCount)}`)

expect(extracted).toEqual([task({ description, priority: "high" })])
},
)

it("leaves priority null when no signifier is present", () => {
const extracted = tasks.extractTasks("- [ ] T")
expect(extracted).toEqual([task({ description: "T" })])
Expand Down Expand Up @@ -580,6 +598,131 @@ describe("tasks.extractTasks", () => {
])
})

it.each([
{ name: "emoji", field: (value: string) => `⛔ ${value}`, idCount: 60, trailingSpaces: 0 },
{
name: "Dataview",
field: (value: string) => `[dependsOn:: ${value}]`,
idCount: 60,
trailingSpaces: 0,
},
{
name: "Dataview with a long whitespace suffix",
field: (value: string) => `[dependsOn:: ${value}]`,
idCount: 2,
// A long run exposes retries between the list and its surrounding field grammar.
trailingSpaces: 60000,
},
])(
"rejects an adversarial $name dependency list in under 50 ms",
({ field, idCount, trailingSpaces }) => {
const ids = Array.from({ length: idCount }, () => "a").join(" , ")
const dependencies = `${ids}${" ".repeat(trailingSpaces)}`
const line = `- [ ] x ${field(`${dependencies}!`)}`
const parserUrl = new URL("../tasks.ts", import.meta.url).href
// The child deadline can interrupt a synchronous regex that blocks the event loop.
const output = execFileSync(
process.execPath,
[
"--import",
"tsx",
"--input-type=module",
"--eval",
`import { tasks } from ${JSON.stringify(parserUrl)};
const started = performance.now();
const extracted = tasks.extractTasks(${JSON.stringify(line)});
const elapsed = performance.now() - started;
process.stdout.write(JSON.stringify({ extracted, elapsed }));`,
],
{ encoding: "utf8", timeout: 10000 },
)
const result = JSON.parse(output)

expect(result.extracted).toEqual([
task({ description: `x ${field(`${dependencies}!`)}`, dependsOn: [] }),
])
expect(result.elapsed).toBeLessThan(50)
},
)

it("preserves dependency matches and group 1 across fixed-seed lists and existing fixtures", () => {
/** The former dependency grammar, retained as the compatibility oracle. */
const oldSequence = /[a-zA-Z0-9_-]+( *, *[a-zA-Z0-9_-]+ *)*/
const observedPatterns: RegExp[] = []
const originalExec = RegExp.prototype.exec
// A regular function preserves exec's receiver so the real private patterns are compared.
const execSpy = vi.spyOn(RegExp.prototype, "exec").mockImplementation(function (
this: RegExp,
text,
) {
if (this.source.includes("⛔") || this.source.includes("dependsOn::")) {
observedPatterns.push(this)
}
return originalExec.call(this, text)
})

try {
tasks.extractTasks("- [ ] x")
} finally {
execSpy.mockRestore()
}

const dependencyPatterns = observedPatterns.filter((pattern) =>
pattern.source.includes("[a-zA-Z0-9_-]+"),
)
expect(dependencyPatterns.length).toBe(2)

// State advances once per draw so this corpus is reproducible.
let randomState = 0x5eed1234
const draw = (limit: number): number => {
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
return randomState % limit
}
const ids = ["a", "B2", "db-setup", "cache_setup", "0", "_-", ""]
const punctuation = ["", "!", ".", ":", ",", "é"]
// At most five IDs keeps failures of the exponential oracle short.
const randomLists = Array.from({ length: 1000 }, () => {
const values = Array.from({ length: 1 + draw(5) }, () => {
return `${" ".repeat(draw(3))}${ids[draw(ids.length)]}${" ".repeat(draw(3))}`
})
return `${values.join(",")}${punctuation[draw(punctuation.length)]}`
})
const testSource = readFileSync(new URL("./tasks.test.ts", import.meta.url), "utf8")
/** Quoted strings containing dependency fields include all existing literal fixtures. */
const dependencyFixture = /["`]([^"`\n]*(?:⛔|dependsOn::)[^"`\n]*)["`]/gu
const fixtureLines = Array.from(
testSource.matchAll(dependencyFixture),
(match) => match[1] ?? "",
)
const generatedLines = randomLists.flatMap((value) => [
`- [ ] x ⛔ ${value}`,
`- [ ] x [dependsOn:: ${value}]`,
`- [ ] x (dependsOn:: ${value}),`,
])
const lines = [...fixtureLines, ...generatedLines]

for (const pattern of dependencyPatterns) {
const oldPattern = new RegExp(
pattern.source.replace(
"[a-zA-Z0-9_-]+(?:(?: *, *[a-zA-Z0-9_-]+)+ *(?! ))?",
oldSequence.source,
),
pattern.flags,
)
expect(oldPattern.source).not.toBe(pattern.source)

for (const line of lines) {
const oldMatch = oldPattern.exec(line)
const newMatch = pattern.exec(line)
expect({ line, match: newMatch?.[0], group1: newMatch?.[1] }).toEqual({
line,
match: oldMatch?.[0],
group1: oldMatch?.[1],
})
}
}
})

it("parses 🆔 id and ⛔ dependsOn with spaces around commas", () => {
const extracted = tasks.extractTasks("- [ ] Build API ⛔ db-setup , cache-setup 🆔 api-build")
expect(extracted).toEqual([
Expand Down Expand Up @@ -687,9 +830,12 @@ describe("tasks.extractTasks", () => {
})

it("excludes task lines inside a tilde fence", () => {
const content = ["~~~", "- [ ] Hidden", "~~~"].join("\n")
const content = ["- [ ] Before", "~~~", "- [ ] Hidden", "~~~", "- [ ] After"].join("\n")
const extracted = tasks.extractTasks(content)
expect(extracted).toEqual([])
expect(extracted).toEqual([
task({ line: 1, description: "Before" }),
task({ line: 5, description: "After" }),
])
})

it("skips frontmatter and reports file-relative 1-based line numbers", () => {
Expand Down Expand Up @@ -1489,6 +1635,18 @@ describe("task line mutations", () => {
expect(result).toBe("- [ ] New description ⏫ ➕ 2026-08-01 ^my-task")
})

it("separates a new description from a bare checkbox with one space", () => {
expect(tasks.replaceTaskLineDescription({ taskLine: "- [ ]", newDescription: "New" })).toBe(
"- [ ] New",
)
})

it("adds the space the Tasks plugin writes after a checkbox glued to its text", () => {
expect(
tasks.replaceTaskLineDescription({ taskLine: "- [ ]Old ^my-task", newDescription: "New" }),
).toBe("- [ ] New ^my-task")
})

it("replaces description when only a block_id follows", () => {
const line = "- [ ] Old text ^my-task"
const result = tasks.replaceTaskLineDescription({
Expand Down Expand Up @@ -1888,6 +2046,30 @@ describe("task line mutations", () => {
// ── updateTaskLineTaskId ──────────────────────────────────────

describe("updateTaskLineTaskId", () => {
it.each([
{ name: "a newline", lineBreak: "\n" },
{ name: "a carriage return", lineBreak: "\r" },
{ name: "a line separator", lineBreak: "\u2028" },
{ name: "a paragraph separator", lineBreak: "\u2029" },
])("does not mutate a body containing $name", ({ lineBreak }) => {
const taskLine = `- [ ] First${lineBreak}second`

expect(tasks.isTaskLine(taskLine)).toBe(false)
expect(tasks.updateTaskLineTaskId({ taskLine, taskId: "abc123", config: EMOJI_CONFIG })).toBe(
taskLine,
)
})

it.each([
{ name: "without a trailing space", taskLine: "- [ ]", expected: "- [ ] 🆔 abc123" },
{ name: "with a trailing space", taskLine: "- [ ] ", expected: "- [ ] 🆔 abc123" },
])("sets an ID on an empty task body $name", ({ taskLine, expected }) => {
expect(tasks.isTaskLine(taskLine)).toBe(true)
expect(tasks.updateTaskLineTaskId({ taskLine, taskId: "abc123", config: EMOJI_CONFIG })).toBe(
expected,
)
})

it("sets a task ID (emoji format)", () => {
const line = "- [ ] My task ➕ 2026-08-01 ^my-task"
const result = tasks.updateTaskLineTaskId({
Expand Down Expand Up @@ -2054,7 +2236,7 @@ describe("task line mutations", () => {
expect(result).toBe("- [ ] My task 🏁 delete ➕ 2026-08-01 📅 2026-09-01 ^my-task")
})

it("clearing strips all copies of both formats", () => {
it("clearing strips duplicated emoji-format fields", () => {
const line = "- [ ] My task 🏁 delete 🏁 keep ➕ 2026-08-01 ^my-task"
const result = tasks.updateTaskLineOnCompletion({
taskLine: line,
Expand Down Expand Up @@ -2624,7 +2806,7 @@ describe("task line mutations", () => {
"- [ ] Task A ^a",
" - [ ] Sub A ^sa",
"## Up Next",
"- [ ] Task B ^b",
" - [ ] Task B ^b",
].join("\n") + "\n"
const parsed = tasks.extractTasks(content)
expect(parsed).toEqual([
Expand Down
Loading
Loading