import { describe, it, expect } from "vitest"; import path from "node:path"; import os from "node:os"; import fs from "node:fs"; import { buildState, parseFrontmatter, countTaskCheckboxes, computeReadyBlocked, } from "./state-builder.mjs"; function makeWorkTree({ epics }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-state-")); for (const [epicId, epicData] of Object.entries(epics)) { const epicDir = path.join(root, epicId); fs.mkdirSync(epicDir, { recursive: true }); fs.writeFileSync( path.join(epicDir, "_epic.md"), `--- id: ${epicId} title: ${epicData.title ?? epicId} status: ${epicData.status ?? "todo"} --- `, ); for (const [storyId, storyData] of Object.entries(epicData.stories ?? {})) { const storyDir = path.join(epicDir, storyId); fs.mkdirSync(storyDir, { recursive: true }); const tasksBlock = (storyData.tasks ?? []) .map((t) => `- [${t.done ? "x" : " "}] ${t.label}`) .join("\n"); fs.writeFileSync( path.join(storyDir, "_story.md"), `--- id: ${storyId} title: ${storyData.title ?? storyId} status: ${storyData.status ?? "todo"} --- ## Tasks ${tasksBlock} `, ); } } return root; } describe("buildState", () => { it("returns an empty epics map for a non-existent workRoot", () => { const state = buildState("/nonexistent/path"); expect(state.epics).toEqual({}); expect(typeof state.updated_at).toBe("string"); }); it("collects epics + stories with status + task counts", () => { const root = makeWorkTree({ epics: { epic1: { status: "in-progress", stories: { story1: { status: "done", tasks: [ { label: "a", done: true }, { label: "b", done: true }, ], }, story2: { status: "todo", tasks: [ { label: "c", done: false }, { label: "d", done: false }, { label: "e", done: false }, ], }, }, }, }, }); const state = buildState(root); expect(state.epics).toEqual({ epic1: { status: "in-progress", title: "epic1", stories: { story1: { status: "done", title: "story1", ac_total: 2, ac_completed: 2, depends_on: [], blocks: [], }, story2: { status: "todo", title: "story2", ac_total: 3, ac_completed: 0, depends_on: [], blocks: [], }, }, }, }); }); it("skips _templates and prds folders", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip-")); fs.mkdirSync(path.join(root, "_templates"), { recursive: true }); fs.writeFileSync( path.join(root, "_templates", "_epic.md"), "---\nid: x\n---", ); fs.mkdirSync(path.join(root, "prds"), { recursive: true }); fs.writeFileSync(path.join(root, "prds", "_epic.md"), "---\nid: y\n---"); expect(buildState(root).epics).toEqual({}); }); it("skips directories without _epic.md", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip2-")); fs.mkdirSync(path.join(root, "incomplete"), { recursive: true }); expect(buildState(root).epics).toEqual({}); }); it("parses depends-on and blocks frontmatter arrays", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-dep-")); const epicDir = path.join(root, "epic1"); fs.mkdirSync(epicDir, { recursive: true }); fs.writeFileSync( path.join(epicDir, "_epic.md"), `--- id: epic1 title: epic1 status: todo --- `, ); const story1Dir = path.join(epicDir, "story1"); fs.mkdirSync(story1Dir, { recursive: true }); fs.writeFileSync( path.join(story1Dir, "_story.md"), `--- id: story1 title: story1 status: todo depends-on: [] blocks: [story2, other-epic/x] --- `, ); const story2Dir = path.join(epicDir, "story2"); fs.mkdirSync(story2Dir, { recursive: true }); fs.writeFileSync( path.join(story2Dir, "_story.md"), `--- id: story2 title: story2 status: todo depends-on: [story1] blocks: [] --- `, ); const state = buildState(root); expect(state.epics.epic1.stories.story1.depends_on).toEqual([]); expect(state.epics.epic1.stories.story1.blocks).toEqual([ "story2", "other-epic/x", ]); expect(state.epics.epic1.stories.story2.depends_on).toEqual(["story1"]); expect(state.epics.epic1.stories.story2.blocks).toEqual([]); }); }); describe("countTaskCheckboxes", () => { it("counts both unchecked and checked", () => { const content = `## Tasks - [x] done one - [ ] open one - [X] capital X also counts `; expect(countTaskCheckboxes(content)).toEqual({ total: 3, completed: 2 }); }); it("only counts inside the ## Tasks section", () => { const content = `## Tasks - [x] in tasks ## Notes - [x] outside, should not count `; expect(countTaskCheckboxes(content)).toEqual({ total: 1, completed: 1 }); }); it("returns zeros when no Tasks section exists", () => { expect(countTaskCheckboxes(`## Goal\n\nFoo\n`)).toEqual({ total: 0, completed: 0, }); }); }); describe("parseFrontmatter", () => { it("extracts simple string keys", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-")); const fp = path.join(dir, "f.md"); fs.writeFileSync( fp, `--- id: x title: A title status: done --- Body`, ); expect(parseFrontmatter(fp)).toEqual({ id: "x", title: "A title", status: "done", }); }); it("returns {} when no frontmatter present", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm2-")); const fp = path.join(dir, "f.md"); fs.writeFileSync(fp, "# Just a heading\n"); expect(parseFrontmatter(fp)).toEqual({}); }); it("parses array frontmatter values", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-arr-")); const fp = path.join(dir, "f.md"); fs.writeFileSync( fp, `--- id: x items: [a, b, "c"] empty: [] --- `, ); expect(parseFrontmatter(fp)).toEqual({ id: "x", items: ["a", "b", "c"], empty: [], }); }); }); describe("computeReadyBlocked", () => { it("returns ready for stories whose depends_on are all done", () => { const state = { updated_at: "x", epics: { e: { status: "in-progress", title: "e", stories: { a: { status: "done", title: "a", ac_total: 0, ac_completed: 0, depends_on: [], blocks: [], }, b: { status: "todo", title: "b", ac_total: 0, ac_completed: 0, depends_on: ["a"], blocks: [], }, }, }, }, }; const { ready, blocked } = computeReadyBlocked(state); expect(ready).toEqual([{ epic: "e", story: "b", title: "b" }]); expect(blocked).toEqual([]); }); it("returns blocked for stories whose depends_on are not done", () => { const state = { updated_at: "x", epics: { e: { status: "in-progress", title: "e", stories: { a: { status: "todo", title: "a", ac_total: 0, ac_completed: 0, depends_on: [], blocks: [], }, b: { status: "todo", title: "b", ac_total: 0, ac_completed: 0, depends_on: ["a"], blocks: [], }, }, }, }, }; const { ready, blocked } = computeReadyBlocked(state); expect(ready).toEqual([{ epic: "e", story: "a", title: "a" }]); expect(blocked).toEqual([ { epic: "e", story: "b", title: "b", waiting_on: ["e/a"] }, ]); }); it("resolves cross-epic refs", () => { const state = { updated_at: "x", epics: { e1: { status: "done", title: "e1", stories: { a: { status: "done", title: "a", ac_total: 0, ac_completed: 0, depends_on: [], blocks: [], }, }, }, e2: { status: "in-progress", title: "e2", stories: { b: { status: "todo", title: "b", ac_total: 0, ac_completed: 0, depends_on: ["e1/a"], blocks: [], }, }, }, }, }; const { ready, blocked } = computeReadyBlocked(state); expect(ready).toEqual([{ epic: "e2", story: "b", title: "b" }]); expect(blocked).toEqual([]); }); it("skips done stories", () => { const state = { updated_at: "x", epics: { e: { status: "done", title: "e", stories: { a: { status: "done", title: "a", ac_total: 1, ac_completed: 1, depends_on: [], blocks: [], }, }, }, }, }; const { ready, blocked } = computeReadyBlocked(state); expect(ready).toEqual([]); expect(blocked).toEqual([]); }); });