mirror of
https://github.com/savar-g/taskline.git
synced 2026-07-22 08:33:55 +00:00
129 lines
5.6 KiB
TypeScript
129 lines
5.6 KiB
TypeScript
|
|
import { describe, expect, it } from "vitest";
|
||
|
|
import { serializeTask } from "../src/format";
|
||
|
|
import { parseProposalLine, parseTaskLine, parseTrackerFile } from "../src/parser";
|
||
|
|
import { WORKSPACE } from "./fixtures";
|
||
|
|
|
||
|
|
const ctx = (over: Partial<{ sourceId: string; filePath: string; lineNo: number; heading: string }> = {}) => ({
|
||
|
|
sourceId: "tasks",
|
||
|
|
filePath: "Tasks.md",
|
||
|
|
lineNo: 1,
|
||
|
|
heading: "Writing",
|
||
|
|
...over,
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("parseTaskLine", () => {
|
||
|
|
it("parses tags, provenance, and non-provenance parentheses", () => {
|
||
|
|
const line = "- [ ] Test device flow (phase 2) #writing (from [[Build Log]])";
|
||
|
|
const task = parseTaskLine(line, ctx());
|
||
|
|
expect(task).toMatchObject({ title: "Test device flow (phase 2)", tags: ["writing"], sourceId: "tasks" });
|
||
|
|
expect(task?.provenance).toEqual({ kind: "link", source: "[[Build Log]]" });
|
||
|
|
expect(serializeTask(task!)).toBe(line);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("round-trips generic slash/hyphen tags and hyphenated owners", () => {
|
||
|
|
const line = "- [ ] Review #client/work-stream #follow-up — @alex-smith";
|
||
|
|
const task = parseTaskLine(line, ctx())!;
|
||
|
|
expect(task.tags).toEqual(["client/work-stream", "follow-up"]);
|
||
|
|
expect(task.owner).toBe("alex-smith");
|
||
|
|
expect(serializeTask(task)).toBe(line);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("parses recurrence, priorities, dates, owner, and stale state", () => {
|
||
|
|
const line = "- [/] Publish update #writing (from inbox 2026-06-26) — @Alex 🟡 stale 5d 🔼 🔁 every week ⏳ 2026-07-01 📅 2026-07-02";
|
||
|
|
const task = parseTaskLine(line, ctx());
|
||
|
|
expect(task).toMatchObject({
|
||
|
|
status: "in-progress",
|
||
|
|
priority: "p3",
|
||
|
|
scheduled: "2026-07-01",
|
||
|
|
due: "2026-07-02",
|
||
|
|
recurrence: "every week",
|
||
|
|
owner: "Alex",
|
||
|
|
stale: { level: "warn", days: 5 },
|
||
|
|
});
|
||
|
|
expect(serializeTask(task!)).toBe(line);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("parses done and cancelled states plus their dates", () => {
|
||
|
|
expect(parseTaskLine("- [x] Finished ✅ 2026-07-01", ctx())).toMatchObject({ status: "done", doneDate: "2026-07-01" });
|
||
|
|
expect(parseTaskLine("- [-] Dropped ❌ 2026-07-01", ctx())).toMatchObject({ status: "cancelled", cancelledDate: "2026-07-01" });
|
||
|
|
});
|
||
|
|
|
||
|
|
it("normalizes legacy timed signifiers to Taskline's date-only model", () => {
|
||
|
|
expect(parseTaskLine("- [ ] Legacy ⏳ 2026-07-01T09:30 📅 2026-07-02T14:00", ctx()))
|
||
|
|
.toMatchObject({ scheduled: "2026-07-01", due: "2026-07-02" });
|
||
|
|
});
|
||
|
|
|
||
|
|
it("round-trips an alert stale marker and preserves an em dash in provenance", () => {
|
||
|
|
const line = "- [ ] Escalate review (from [[May 1 — Review]]) — @Alex 🔴 stale 46d (escalate)";
|
||
|
|
const task = parseTaskLine(line, ctx())!;
|
||
|
|
expect(task.stale).toEqual({ level: "alert", days: 46 });
|
||
|
|
expect(task.provenance?.source).toBe("[[May 1 — Review]]");
|
||
|
|
expect(serializeTask(task)).toBe(line);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("handles every open status char", () => {
|
||
|
|
expect(parseTaskLine("- [ ] Todo", ctx())?.status).toBe("todo");
|
||
|
|
expect(parseTaskLine("- [!] Blocked", ctx())?.status).toBe("blocked");
|
||
|
|
expect(parseTaskLine("- [?] Planning", ctx())?.status).toBe("planning");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("tolerates malformed and non-task input", () => {
|
||
|
|
for (const input of ["", "random", "## Heading", null as unknown as string]) {
|
||
|
|
expect(() => parseTaskLine(input, ctx())).not.toThrow();
|
||
|
|
}
|
||
|
|
expect(parseTaskLine("random", ctx())).toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("proposals and tracker files", () => {
|
||
|
|
it("parses proposal evidence and source", () => {
|
||
|
|
const proposal = parseProposalLine('- (propose done) Review draft - evidence: "approved" [[Meeting]]', ctx());
|
||
|
|
expect(proposal).toMatchObject({ sourceId: "tasks", action: "complete", text: "Review draft", evidence: "approved", source: "Meeting" });
|
||
|
|
});
|
||
|
|
|
||
|
|
it("parses proposals without evidence and rejects ordinary bullets", () => {
|
||
|
|
expect(parseProposalLine("- (propose cancel) Drop stale idea", ctx())).toMatchObject({ action: "cancel", text: "Drop stale idea" });
|
||
|
|
expect(parseProposalLine("- [ ] Ordinary task", ctx())).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it.each([
|
||
|
|
["done", "complete"],
|
||
|
|
["complete", "complete"],
|
||
|
|
["cancel", "cancel"],
|
||
|
|
] as const)("normalizes proposal action %s", (input, action) => {
|
||
|
|
expect(parseProposalLine(`- (propose ${input}) Task`, ctx())?.action).toBe(action);
|
||
|
|
});
|
||
|
|
|
||
|
|
it.each(["finish", "done later", "", "delete"])("rejects unsupported proposal action %j", (action) => {
|
||
|
|
expect(parseProposalLine(`- (propose ${action}) Task`, ctx())).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("tracks headings, subnotes, source identity, and enabled proposals", () => {
|
||
|
|
const content = [
|
||
|
|
"## Writing",
|
||
|
|
"- [ ] Draft article #writing",
|
||
|
|
" - 📝 Waiting for review",
|
||
|
|
"## Learning",
|
||
|
|
"- [x] Finish course #learning ✅ 2026-07-01",
|
||
|
|
"## Proposed",
|
||
|
|
"- (propose done) Archive notes",
|
||
|
|
].join("\n");
|
||
|
|
const source = WORKSPACE.sourceById.get("tasks")!;
|
||
|
|
const result = parseTrackerFile(content, source, WORKSPACE);
|
||
|
|
expect(result.tasks.map((task) => task.heading)).toEqual(["Writing", "Learning"]);
|
||
|
|
expect(result.tasks[0].subNotes).toEqual(["Waiting for review"]);
|
||
|
|
expect(result.proposals).toHaveLength(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("ignores proposals for a source that disables them", () => {
|
||
|
|
const source = WORKSPACE.sourceById.get("team")!;
|
||
|
|
expect(parseTrackerFile("- (propose done) Archive notes", source, WORKSPACE).proposals).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not throw on malformed tracker content", () => {
|
||
|
|
const source = WORKSPACE.sourceById.get("tasks")!;
|
||
|
|
expect(() => parseTrackerFile("\0 garbage\n- [", source, WORKSPACE)).not.toThrow();
|
||
|
|
expect(() => parseTrackerFile(null as unknown as string, source, WORKSPACE)).not.toThrow();
|
||
|
|
});
|
||
|
|
});
|