388 lines
9.4 KiB
JavaScript
388 lines
9.4 KiB
JavaScript
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([]);
|
|
});
|
|
});
|