When a task with rec:Nd/w/m/y is checked off, a new open task is appended with the updated due date. Non-strict (rec:1m) bases the next due on today; strict (rec:+1m) bases it on the existing due date.
313 lines
9.8 KiB
TypeScript
313 lines
9.8 KiB
TypeScript
import { describe, expect, it } from "vitest"
|
|
|
|
import {
|
|
applyRecurrence,
|
|
contextsOf,
|
|
isBlank,
|
|
parseFile,
|
|
parseLine,
|
|
projectsOf,
|
|
removeTagFromBody,
|
|
removeTokenFromBody,
|
|
segmentBody,
|
|
serializeFile,
|
|
serializeTask,
|
|
tagsOf,
|
|
toggleCompleted
|
|
} from "./todotxt"
|
|
|
|
describe("todotxt parseLine", () => {
|
|
it("parses a plain task with no metadata", () => {
|
|
const task = parseLine("Pick up groceries")
|
|
expect(task.completed).toBe(false)
|
|
expect(task.priority).toBeUndefined()
|
|
expect(task.completionDate).toBeUndefined()
|
|
expect(task.creationDate).toBeUndefined()
|
|
expect(task.body).toBe("Pick up groceries")
|
|
})
|
|
|
|
it("parses priority + creation date + body", () => {
|
|
const task = parseLine("(A) 2024-01-15 Call Mom +Family @phone")
|
|
expect(task.priority).toBe("A")
|
|
expect(task.creationDate).toBe("2024-01-15")
|
|
expect(task.body).toBe("Call Mom +Family @phone")
|
|
})
|
|
|
|
it("parses a completed task with completion + creation dates", () => {
|
|
const task = parseLine("x 2024-01-20 (A) 2024-01-15 Finish report")
|
|
expect(task.completed).toBe(true)
|
|
expect(task.completionDate).toBe("2024-01-20")
|
|
expect(task.priority).toBe("A")
|
|
expect(task.creationDate).toBe("2024-01-15")
|
|
expect(task.body).toBe("Finish report")
|
|
})
|
|
|
|
it("parses a completed task with no completion date", () => {
|
|
const task = parseLine("x Buy milk")
|
|
expect(task.completed).toBe(true)
|
|
expect(task.completionDate).toBeUndefined()
|
|
expect(task.body).toBe("Buy milk")
|
|
})
|
|
|
|
it("does not treat uppercase X as completion", () => {
|
|
const task = parseLine("X-ray appointment")
|
|
expect(task.completed).toBe(false)
|
|
expect(task.body).toBe("X-ray appointment")
|
|
})
|
|
|
|
it("does not match a lowercase priority", () => {
|
|
const task = parseLine("(a) lowercase priority is body text")
|
|
expect(task.priority).toBeUndefined()
|
|
expect(task.body).toBe("(a) lowercase priority is body text")
|
|
})
|
|
})
|
|
|
|
describe("todotxt serializeTask", () => {
|
|
it("round-trips a complex task", () => {
|
|
const input = "x 2024-01-20 (A) 2024-01-15 Finish report +work @office"
|
|
expect(serializeTask(parseLine(input))).toBe(input)
|
|
})
|
|
|
|
it("round-trips a plain task", () => {
|
|
const input = "Buy milk"
|
|
expect(serializeTask(parseLine(input))).toBe(input)
|
|
})
|
|
|
|
it("round-trips priority + body", () => {
|
|
const input = "(B) Walk the dog"
|
|
expect(serializeTask(parseLine(input))).toBe(input)
|
|
})
|
|
|
|
it("drops completion date when task is not completed", () => {
|
|
const task = parseLine("(A) 2024-01-15 Hello")
|
|
const out = serializeTask({ ...task, completionDate: "2024-02-01" })
|
|
expect(out).toBe("(A) 2024-01-15 Hello")
|
|
})
|
|
})
|
|
|
|
describe("todotxt projectsOf / contextsOf / tagsOf", () => {
|
|
it("extracts projects", () => {
|
|
const task = parseLine("Plan trip +Travel +Family @home")
|
|
expect(projectsOf(task)).toEqual(["Travel", "Family"])
|
|
})
|
|
|
|
it("extracts contexts", () => {
|
|
const task = parseLine("Call +Family @phone @home")
|
|
expect(contextsOf(task)).toEqual(["phone", "home"])
|
|
})
|
|
|
|
it("extracts key:value tags", () => {
|
|
const task = parseLine("Finish report due:2024-02-01 pri:A")
|
|
expect(tagsOf(task)).toEqual({ due: "2024-02-01", pri: "A" })
|
|
})
|
|
|
|
it("does not treat an email address as a context", () => {
|
|
const task = parseLine("Email boss at boss@example.com")
|
|
expect(contextsOf(task)).toEqual([])
|
|
})
|
|
|
|
it("does not treat a URL scheme as a tag", () => {
|
|
const task = parseLine("Read https://example.com later")
|
|
expect(tagsOf(task)).toEqual({})
|
|
})
|
|
})
|
|
|
|
describe("todotxt segmentBody", () => {
|
|
it("splits text + project + context", () => {
|
|
expect(segmentBody("Call Mom +Family @phone")).toEqual([
|
|
{ kind: "text", value: "Call Mom " },
|
|
{ kind: "project", value: "Family" },
|
|
{ kind: "text", value: " " },
|
|
{ kind: "context", value: "phone" }
|
|
])
|
|
})
|
|
|
|
it("keeps mid-word @ as text", () => {
|
|
expect(segmentBody("Email boss@example.com")).toEqual([
|
|
{ kind: "text", value: "Email boss@example.com" }
|
|
])
|
|
})
|
|
|
|
it("handles a token at the start", () => {
|
|
expect(segmentBody("@phone Call Mom")).toEqual([
|
|
{ kind: "context", value: "phone" },
|
|
{ kind: "text", value: " Call Mom" }
|
|
])
|
|
})
|
|
|
|
it("recognizes due: and rec: as their own segments", () => {
|
|
expect(segmentBody("Pay rent due:2025-01-15 rec:1m")).toEqual([
|
|
{ kind: "text", value: "Pay rent " },
|
|
{ kind: "due", value: "2025-01-15" },
|
|
{ kind: "text", value: " " },
|
|
{ kind: "rec", value: "1m" }
|
|
])
|
|
})
|
|
|
|
it("does not match due: inside a word", () => {
|
|
expect(segmentBody("xdue:foo")).toEqual([
|
|
{ kind: "text", value: "xdue:foo" }
|
|
])
|
|
})
|
|
})
|
|
|
|
describe("todotxt removeTagFromBody", () => {
|
|
it("removes a due tag", () => {
|
|
expect(
|
|
removeTagFromBody("Pay rent due:2025-01-15 rec:1m", "due", "2025-01-15")
|
|
).toBe("Pay rent rec:1m")
|
|
})
|
|
|
|
it("removes a rec tag at the end", () => {
|
|
expect(removeTagFromBody("Pay rent rec:1m", "rec", "1m")).toBe("Pay rent")
|
|
})
|
|
})
|
|
|
|
describe("todotxt removeTokenFromBody", () => {
|
|
it("removes a project token mid-body", () => {
|
|
expect(
|
|
removeTokenFromBody("Plan trip +Travel +Family @home", "+", "Travel")
|
|
).toBe("Plan trip +Family @home")
|
|
})
|
|
|
|
it("removes a context token at the start", () => {
|
|
expect(removeTokenFromBody("@phone Call Mom", "@", "phone")).toBe(
|
|
"Call Mom"
|
|
)
|
|
})
|
|
|
|
it("removes a token at the end", () => {
|
|
expect(removeTokenFromBody("Call Mom @phone", "@", "phone")).toBe(
|
|
"Call Mom"
|
|
)
|
|
})
|
|
|
|
it("leaves untouched a token-prefix substring inside another word", () => {
|
|
expect(removeTokenFromBody("boss@example.com here", "@", "example")).toBe(
|
|
"boss@example.com here"
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("todotxt parseFile / serializeFile", () => {
|
|
it("round-trips a file with trailing newline", () => {
|
|
const input = `(A) 2024-01-15 First task +work
|
|
x 2024-01-20 Second task
|
|
Plain task
|
|
`
|
|
const parsed = parseFile(input)
|
|
expect(parsed).toHaveLength(3)
|
|
expect(serializeFile(parsed)).toBe(input)
|
|
})
|
|
|
|
it("preserves blank lines", () => {
|
|
const input = `Task one
|
|
|
|
Task two
|
|
`
|
|
const parsed = parseFile(input)
|
|
expect(parsed).toHaveLength(3)
|
|
expect(isBlank(parsed[1])).toBe(true)
|
|
expect(serializeFile(parsed)).toBe(input)
|
|
})
|
|
|
|
it("handles file with no trailing newline", () => {
|
|
const parsed = parseFile("only line")
|
|
expect(serializeFile(parsed, { trailingNewline: false })).toBe("only line")
|
|
})
|
|
})
|
|
|
|
describe("todotxt applyRecurrence", () => {
|
|
it("returns null when task has no rec tag", () => {
|
|
expect(applyRecurrence(parseLine("Buy milk"))).toBeNull()
|
|
})
|
|
|
|
it("returns null for an invalid rec value", () => {
|
|
expect(applyRecurrence(parseLine("Task rec:bad"))).toBeNull()
|
|
})
|
|
|
|
it("non-strict: bases next due on today, replacing existing due", () => {
|
|
const now = new Date(2026, 0, 15) // Jan 15
|
|
const task = parseLine("Pay rent due:2025-12-15 rec:1m")
|
|
const next = applyRecurrence(task, now)!
|
|
expect(next.completed).toBe(false)
|
|
expect(next.body).toContain("due:2026-02-15")
|
|
expect(next.body).toContain("rec:1m")
|
|
expect(next.body).not.toContain("due:2025-12-15")
|
|
})
|
|
|
|
it("strict: bases next due on existing due date", () => {
|
|
const now = new Date(2026, 0, 15) // Jan 15
|
|
const task = parseLine("Pay rent due:2025-12-15 rec:+1m")
|
|
const next = applyRecurrence(task, now)!
|
|
expect(next.body).toContain("due:2026-01-15")
|
|
expect(next.body).toContain("rec:+1m")
|
|
})
|
|
|
|
it("strict with no due: falls back to today", () => {
|
|
const now = new Date(2026, 0, 15)
|
|
const task = parseLine("Daily standup rec:+1d")
|
|
const next = applyRecurrence(task, now)!
|
|
expect(next.body).toContain("due:2026-01-16")
|
|
})
|
|
|
|
it("appends due tag when task has none", () => {
|
|
const now = new Date(2026, 0, 15)
|
|
const task = parseLine("Daily standup rec:1d")
|
|
const next = applyRecurrence(task, now)!
|
|
expect(next.body).toContain("due:2026-01-16")
|
|
})
|
|
|
|
it("preserves projects, contexts, and rec tag in new body", () => {
|
|
const now = new Date(2026, 0, 15)
|
|
const task = parseLine("Pay rent +finance @home due:2026-01-01 rec:1m")
|
|
const next = applyRecurrence(task, now)!
|
|
expect(next.body).toContain("+finance")
|
|
expect(next.body).toContain("@home")
|
|
expect(next.body).toContain("rec:1m")
|
|
})
|
|
|
|
it("preserves priority on the new task", () => {
|
|
const now = new Date(2026, 0, 15)
|
|
const task = parseLine("(A) Pay rent rec:1w")
|
|
const next = applyRecurrence(task, now)!
|
|
expect(next.priority).toBe("A")
|
|
expect(next.body).toContain("due:2026-01-22")
|
|
})
|
|
|
|
it("handles week interval", () => {
|
|
const now = new Date(2026, 0, 15)
|
|
expect(applyRecurrence(parseLine("Task rec:2w"), now)!.body).toContain(
|
|
"due:2026-01-29"
|
|
)
|
|
})
|
|
|
|
it("handles year interval", () => {
|
|
const now = new Date(2026, 0, 15)
|
|
expect(applyRecurrence(parseLine("Task rec:1y"), now)!.body).toContain(
|
|
"due:2027-01-15"
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("todotxt toggleCompleted", () => {
|
|
it("marks a task complete with today's date", () => {
|
|
// Construct via local-time fields so the test doesn't depend on the
|
|
// runner's timezone (todayIso uses getFullYear/getMonth/getDate).
|
|
const now = new Date(2024, 2, 15, 12, 0)
|
|
const task = parseLine("(A) 2024-01-15 Call Mom")
|
|
const toggled = toggleCompleted(task, now)
|
|
expect(toggled.completed).toBe(true)
|
|
expect(toggled.completionDate).toBe("2024-03-15")
|
|
expect(serializeTask(toggled)).toBe("x 2024-03-15 (A) 2024-01-15 Call Mom")
|
|
})
|
|
|
|
it("uncompletes a task and drops the completion date", () => {
|
|
const task = parseLine("x 2024-01-20 (A) 2024-01-15 Call Mom")
|
|
const toggled = toggleCompleted(task)
|
|
expect(toggled.completed).toBe(false)
|
|
expect(toggled.completionDate).toBeUndefined()
|
|
expect(serializeTask(toggled)).toBe("(A) 2024-01-15 Call Mom")
|
|
})
|
|
})
|