Skip to content

Commit 8d4757d

Browse files
committed
test: add unit tests for core modules
Add comprehensive unit tests using Bun's test runner (55 tests total): - tests/config.test.ts: Config parsing, env vars, CLI args merging - tests/state.test.ts: State persistence, phase transitions - tests/plan.test.ts: Plan parsing, validation, task management - tests/ideas.test.ts: Ideas loading, formatting, selection - tests/evaluator.test.ts: Evaluation response parsing Signed-off-by: leocavalcante <[email protected]>
1 parent 4c8efc2 commit 8d4757d

File tree

5 files changed

+585
-0
lines changed

5 files changed

+585
-0
lines changed

tests/config.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Tests for config module
3+
*/
4+
5+
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
6+
import { parseModel } from "../src/config.ts"
7+
8+
describe("parseModel", () => {
9+
test("parses simple provider/model format", () => {
10+
const result = parseModel("anthropic/claude-sonnet-4")
11+
expect(result.providerID).toBe("anthropic")
12+
expect(result.modelID).toBe("claude-sonnet-4")
13+
})
14+
15+
test("parses model with multiple slashes", () => {
16+
const result = parseModel("openai/gpt-4/turbo")
17+
expect(result.providerID).toBe("openai")
18+
expect(result.modelID).toBe("gpt-4/turbo")
19+
})
20+
21+
test("handles empty string", () => {
22+
const result = parseModel("")
23+
expect(result.providerID).toBe("")
24+
expect(result.modelID).toBe("")
25+
})
26+
27+
test("handles string without slash", () => {
28+
const result = parseModel("claude")
29+
expect(result.providerID).toBe("claude")
30+
expect(result.modelID).toBe("")
31+
})
32+
33+
test("handles provider-only with trailing slash", () => {
34+
const result = parseModel("anthropic/")
35+
expect(result.providerID).toBe("anthropic")
36+
expect(result.modelID).toBe("")
37+
})
38+
})

tests/evaluator.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Tests for evaluator module
3+
*/
4+
5+
import { describe, expect, test } from "bun:test"
6+
import { parseEvaluation, isComplete, extractEvaluationReason } from "../src/evaluator.ts"
7+
8+
describe("evaluator", () => {
9+
describe("parseEvaluation", () => {
10+
test("parses COMPLETE response", () => {
11+
const response = "COMPLETE\nReason: All tasks done"
12+
const result = parseEvaluation(response)
13+
14+
expect(result).toBe("COMPLETE")
15+
})
16+
17+
test("parses NEEDS_WORK response", () => {
18+
const response = "NEEDS_WORK\nReason: Tests failing"
19+
const result = parseEvaluation(response)
20+
21+
expect(result).toBe("NEEDS_WORK")
22+
})
23+
24+
test("handles extra whitespace", () => {
25+
const response = " COMPLETE \n\nReason: Done"
26+
const result = parseEvaluation(response)
27+
28+
expect(result).toBe("COMPLETE")
29+
})
30+
31+
test("defaults to NEEDS_WORK for ambiguous response", () => {
32+
const response = "Maybe we should continue"
33+
const result = parseEvaluation(response)
34+
35+
expect(result).toBe("NEEDS_WORK")
36+
})
37+
38+
test("handles case insensitive matching", () => {
39+
const response = "complete\nreason: all good"
40+
const result = parseEvaluation(response)
41+
42+
expect(result).toBe("COMPLETE")
43+
})
44+
45+
test("handles response with code block", () => {
46+
const response = `Here's my evaluation:
47+
\`\`\`
48+
COMPLETE
49+
Reason: Everything passed
50+
\`\`\``
51+
const result = parseEvaluation(response)
52+
53+
expect(result).toBe("COMPLETE")
54+
})
55+
})
56+
57+
describe("isComplete", () => {
58+
test("returns true for COMPLETE", () => {
59+
expect(isComplete("COMPLETE")).toBe(true)
60+
})
61+
62+
test("returns false for NEEDS_WORK", () => {
63+
expect(isComplete("NEEDS_WORK")).toBe(false)
64+
})
65+
})
66+
67+
describe("extractEvaluationReason", () => {
68+
test("extracts reason from response", () => {
69+
const response = "COMPLETE\nReason: All tests passing and code looks good"
70+
const reason = extractEvaluationReason(response)
71+
72+
expect(reason).toBe("All tests passing and code looks good")
73+
})
74+
75+
test("handles multi-word reason", () => {
76+
const response = "NEEDS_WORK\nReason: Three tests still failing in the auth module"
77+
const reason = extractEvaluationReason(response)
78+
79+
expect(reason).toBe("Three tests still failing in the auth module")
80+
})
81+
82+
test("returns null when no reason found", () => {
83+
const response = "COMPLETE"
84+
const reason = extractEvaluationReason(response)
85+
86+
expect(reason).toBeNull()
87+
})
88+
89+
test("handles lowercase reason prefix", () => {
90+
const response = "COMPLETE\nreason: it works"
91+
const reason = extractEvaluationReason(response)
92+
93+
expect(reason).toBe("it works")
94+
})
95+
})
96+
})

tests/ideas.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* Tests for ideas module
3+
*/
4+
5+
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
6+
import { mkdirSync, rmSync, existsSync } from "node:fs"
7+
import { join } from "node:path"
8+
import {
9+
loadAllIdeas,
10+
formatIdeasForSelection,
11+
getIdeaSummary,
12+
removeIdea,
13+
parseIdeaSelection,
14+
countIdeas,
15+
} from "../src/ideas.ts"
16+
17+
const TEST_DIR = "/tmp/opencoder-test-ideas"
18+
19+
describe("ideas", () => {
20+
beforeEach(() => {
21+
if (existsSync(TEST_DIR)) {
22+
rmSync(TEST_DIR, { recursive: true })
23+
}
24+
mkdirSync(TEST_DIR, { recursive: true })
25+
})
26+
27+
afterEach(() => {
28+
if (existsSync(TEST_DIR)) {
29+
rmSync(TEST_DIR, { recursive: true })
30+
}
31+
})
32+
33+
describe("loadAllIdeas", () => {
34+
test("returns empty array for non-existent directory", async () => {
35+
const ideas = await loadAllIdeas("/nonexistent/path")
36+
expect(ideas.length).toBe(0)
37+
})
38+
39+
test("loads markdown files from directory", async () => {
40+
await Bun.write(join(TEST_DIR, "idea1.md"), "# First Idea\nDescription")
41+
await Bun.write(join(TEST_DIR, "idea2.md"), "# Second Idea\nContent")
42+
await Bun.write(join(TEST_DIR, "not-an-idea.txt"), "Ignored file")
43+
44+
const ideas = await loadAllIdeas(TEST_DIR)
45+
46+
expect(ideas.length).toBe(2)
47+
expect(ideas.some((i) => i.filename === "idea1.md")).toBe(true)
48+
expect(ideas.some((i) => i.filename === "idea2.md")).toBe(true)
49+
})
50+
51+
test("skips empty files", async () => {
52+
await Bun.write(join(TEST_DIR, "empty.md"), "")
53+
await Bun.write(join(TEST_DIR, "whitespace.md"), " \n\t ")
54+
await Bun.write(join(TEST_DIR, "valid.md"), "Valid content")
55+
56+
const ideas = await loadAllIdeas(TEST_DIR)
57+
58+
expect(ideas.length).toBe(1)
59+
expect(ideas[0]?.filename).toBe("valid.md")
60+
})
61+
})
62+
63+
describe("getIdeaSummary", () => {
64+
test("extracts first line as summary", () => {
65+
const summary = getIdeaSummary("First line\nSecond line")
66+
expect(summary).toBe("First line")
67+
})
68+
69+
test("removes markdown headers", () => {
70+
const summary = getIdeaSummary("# Header Title\nContent")
71+
expect(summary).toBe("Header Title")
72+
})
73+
74+
test("truncates long summaries", () => {
75+
const longContent = "A".repeat(150)
76+
const summary = getIdeaSummary(longContent)
77+
78+
expect(summary.length).toBeLessThanOrEqual(103) // 100 + "..."
79+
expect(summary.endsWith("...")).toBe(true)
80+
})
81+
82+
test("skips empty lines to find summary", () => {
83+
const summary = getIdeaSummary("\n\n# Title\nContent")
84+
expect(summary).toBe("Title")
85+
})
86+
})
87+
88+
describe("formatIdeasForSelection", () => {
89+
test("formats ideas with numbers and content", () => {
90+
const ideas = [
91+
{ path: "/a.md", filename: "a.md", content: "# Idea A\nContent A" },
92+
{ path: "/b.md", filename: "b.md", content: "# Idea B\nContent B" },
93+
]
94+
95+
const formatted = formatIdeasForSelection(ideas)
96+
97+
expect(formatted).toContain("## Idea 1: a.md")
98+
expect(formatted).toContain("## Idea 2: b.md")
99+
expect(formatted).toContain("Content A")
100+
expect(formatted).toContain("Content B")
101+
})
102+
})
103+
104+
describe("parseIdeaSelection", () => {
105+
test("parses valid selection response", () => {
106+
const response = "SELECTED_IDEA: 2\nREASON: It's simpler"
107+
const index = parseIdeaSelection(response)
108+
109+
expect(index).toBe(1) // 0-indexed
110+
})
111+
112+
test("handles case insensitive matching", () => {
113+
const response = "selected_idea: 3\nreason: Quick win"
114+
const index = parseIdeaSelection(response)
115+
116+
expect(index).toBe(2)
117+
})
118+
119+
test("returns null for invalid response", () => {
120+
const response = "I think we should do idea 2"
121+
const index = parseIdeaSelection(response)
122+
123+
expect(index).toBeNull()
124+
})
125+
126+
test("returns null for zero selection", () => {
127+
const response = "SELECTED_IDEA: 0\nREASON: Invalid"
128+
const index = parseIdeaSelection(response)
129+
130+
expect(index).toBeNull()
131+
})
132+
})
133+
134+
describe("countIdeas", () => {
135+
test("returns 0 for non-existent directory", async () => {
136+
const count = await countIdeas("/nonexistent")
137+
expect(count).toBe(0)
138+
})
139+
140+
test("counts markdown files", async () => {
141+
await Bun.write(join(TEST_DIR, "a.md"), "idea")
142+
await Bun.write(join(TEST_DIR, "b.md"), "idea")
143+
await Bun.write(join(TEST_DIR, "c.txt"), "not counted")
144+
145+
const count = await countIdeas(TEST_DIR)
146+
expect(count).toBe(2)
147+
})
148+
})
149+
150+
describe("removeIdea", () => {
151+
test("removes existing file", async () => {
152+
const filePath = join(TEST_DIR, "to-remove.md")
153+
await Bun.write(filePath, "content")
154+
155+
const result = removeIdea(filePath)
156+
157+
expect(result).toBe(true)
158+
expect(existsSync(filePath)).toBe(false)
159+
})
160+
161+
test("returns false for non-existent file", () => {
162+
const result = removeIdea("/nonexistent/file.md")
163+
expect(result).toBe(false)
164+
})
165+
})
166+
})

0 commit comments

Comments
 (0)