savar-g_taskline/tests/parser.test.ts

129 lines
5.6 KiB
TypeScript
Raw Permalink Normal View History

2026-07-18 21:16:25 +00:00
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();
});
});