Skip to content

Commit e027b80

Browse files
committed
test: add tests for groupTasksByFileOverlap, recentFailures, appendFailureJSONL, clearSessionPlan, and Files parsing
17 new tests covering all new code added in the parallel tasks and failure memory features. All at 0% coverage before this commit.
1 parent 3bfd81b commit e027b80

1 file changed

Lines changed: 315 additions & 0 deletions

File tree

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
package evolution
2+
3+
import (
4+
"encoding/json"
5+
"log/slog"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// ---------------------------------------------------------------------------
13+
// groupTasksByFileOverlap
14+
// ---------------------------------------------------------------------------
15+
16+
func TestGroupTasksByFileOverlap_NoTasks(t *testing.T) {
17+
waves := groupTasksByFileOverlap(nil)
18+
if len(waves) != 0 {
19+
t.Errorf("expected 0 waves, got %d", len(waves))
20+
}
21+
}
22+
23+
func TestGroupTasksByFileOverlap_SingleTask(t *testing.T) {
24+
tasks := []planTask{
25+
{Number: 1, Title: "T1", Files: []string{"foo.go"}},
26+
}
27+
waves := groupTasksByFileOverlap(tasks)
28+
if len(waves) != 1 || len(waves[0]) != 1 {
29+
t.Errorf("expected 1 wave with 1 task, got %v", waves)
30+
}
31+
}
32+
33+
func TestGroupTasksByFileOverlap_NonOverlappingInSameWave(t *testing.T) {
34+
tasks := []planTask{
35+
{Number: 1, Files: []string{"a.go"}},
36+
{Number: 2, Files: []string{"b.go"}},
37+
{Number: 3, Files: []string{"c.go"}},
38+
}
39+
waves := groupTasksByFileOverlap(tasks)
40+
if len(waves) != 1 {
41+
t.Errorf("expected 1 wave (no overlaps), got %d", len(waves))
42+
}
43+
if len(waves[0]) != 3 {
44+
t.Errorf("expected 3 tasks in wave, got %d", len(waves[0]))
45+
}
46+
}
47+
48+
func TestGroupTasksByFileOverlap_OverlapForcesNewWave(t *testing.T) {
49+
tasks := []planTask{
50+
{Number: 1, Files: []string{"shared.go"}},
51+
{Number: 2, Files: []string{"other.go"}},
52+
{Number: 3, Files: []string{"shared.go"}}, // conflicts with task 1
53+
}
54+
waves := groupTasksByFileOverlap(tasks)
55+
if len(waves) != 2 {
56+
t.Errorf("expected 2 waves (task 3 conflicts with task 1), got %d", len(waves))
57+
}
58+
// Wave 1 should have tasks 1 and 2; wave 2 should have task 3.
59+
if len(waves[0]) != 2 {
60+
t.Errorf("wave 1: expected 2 tasks, got %d", len(waves[0]))
61+
}
62+
if len(waves[1]) != 1 || waves[1][0].Number != 3 {
63+
t.Errorf("wave 2: expected task 3, got %v", waves[1])
64+
}
65+
}
66+
67+
func TestGroupTasksByFileOverlap_NoFilesForcesOwnWave(t *testing.T) {
68+
tasks := []planTask{
69+
{Number: 1, Files: []string{"a.go"}},
70+
{Number: 2, Files: nil}, // no files → own wave
71+
{Number: 3, Files: []string{"b.go"}},
72+
}
73+
waves := groupTasksByFileOverlap(tasks)
74+
// Task 1 in wave 1, task 2 alone in wave 2, task 3 starts wave 3.
75+
if len(waves) != 3 {
76+
t.Errorf("expected 3 waves, got %d", len(waves))
77+
}
78+
if waves[1][0].Number != 2 {
79+
t.Errorf("expected task 2 alone in wave 2, got task %d", waves[1][0].Number)
80+
}
81+
}
82+
83+
func TestGroupTasksByFileOverlap_MultipleFileOverlap(t *testing.T) {
84+
tasks := []planTask{
85+
{Number: 1, Files: []string{"a.go", "b.go"}},
86+
{Number: 2, Files: []string{"c.go", "b.go"}}, // b.go overlaps
87+
}
88+
waves := groupTasksByFileOverlap(tasks)
89+
if len(waves) != 2 {
90+
t.Errorf("expected 2 waves, got %d", len(waves))
91+
}
92+
}
93+
94+
func TestGroupTasksByFileOverlap_OrderPreserved(t *testing.T) {
95+
tasks := []planTask{
96+
{Number: 1, Files: []string{"x.go"}},
97+
{Number: 2, Files: []string{"y.go"}},
98+
{Number: 3, Files: []string{"x.go"}},
99+
{Number: 4, Files: []string{"z.go"}},
100+
}
101+
waves := groupTasksByFileOverlap(tasks)
102+
// Wave 1: tasks 1, 2 (no overlap). Wave 2: tasks 3, 4 (3 conflicts with 1; 4 is fine with 3).
103+
if len(waves) != 2 {
104+
t.Errorf("expected 2 waves, got %d", len(waves))
105+
}
106+
if waves[0][0].Number != 1 || waves[0][1].Number != 2 {
107+
t.Errorf("wave 1 order wrong: %v", waves[0])
108+
}
109+
if waves[1][0].Number != 3 || waves[1][1].Number != 4 {
110+
t.Errorf("wave 2 order wrong: %v", waves[1])
111+
}
112+
}
113+
114+
// ---------------------------------------------------------------------------
115+
// planTask Files parsing (via parseSessionPlanTasks)
116+
// ---------------------------------------------------------------------------
117+
118+
func TestParseSessionPlanTasks_FilesField(t *testing.T) {
119+
plan := `## Session Plan
120+
121+
Session Title: Fix things
122+
123+
### Task 1: Fix parser
124+
Files: internal/parser/parser.go, internal/parser/lexer.go
125+
Description: Fix off-by-one error.
126+
127+
### Task 2: Add auth
128+
Files: internal/auth/middleware.go
129+
Description: Add JWT middleware.
130+
131+
### Issue Responses
132+
`
133+
tasks := parseSessionPlanTasks(plan)
134+
if len(tasks) != 2 {
135+
t.Fatalf("expected 2 tasks, got %d", len(tasks))
136+
}
137+
if len(tasks[0].Files) != 2 {
138+
t.Errorf("task 1: expected 2 files, got %v", tasks[0].Files)
139+
}
140+
if tasks[0].Files[0] != "internal/parser/parser.go" {
141+
t.Errorf("task 1 file 0: got %q", tasks[0].Files[0])
142+
}
143+
if len(tasks[1].Files) != 1 || tasks[1].Files[0] != "internal/auth/middleware.go" {
144+
t.Errorf("task 2 files: got %v", tasks[1].Files)
145+
}
146+
}
147+
148+
func TestParseSessionPlanTasks_NoFilesField(t *testing.T) {
149+
plan := `## Session Plan
150+
151+
### Task 1: Improve something
152+
Description: Do the thing.
153+
`
154+
tasks := parseSessionPlanTasks(plan)
155+
if len(tasks) != 1 {
156+
t.Fatalf("expected 1 task, got %d", len(tasks))
157+
}
158+
if len(tasks[0].Files) != 0 {
159+
t.Errorf("expected no files, got %v", tasks[0].Files)
160+
}
161+
}
162+
163+
// ---------------------------------------------------------------------------
164+
// recentFailures
165+
// ---------------------------------------------------------------------------
166+
167+
func TestRecentFailures_EmptyDir(t *testing.T) {
168+
dir := t.TempDir()
169+
result := recentFailures(dir, 10)
170+
if result != "" {
171+
t.Errorf("expected empty string for missing failures.jsonl, got %q", result)
172+
}
173+
}
174+
175+
func TestRecentFailures_ReturnsFormatted(t *testing.T) {
176+
dir := t.TempDir()
177+
memDir := filepath.Join(dir, "memory")
178+
_ = os.MkdirAll(memDir, 0o755)
179+
180+
entries := []map[string]interface{}{
181+
{"type": "failure", "day": 3, "task": "Fix parser", "reason": "build failed"},
182+
{"type": "failure", "day": 4, "task": "Add auth", "reason": "test failed"},
183+
}
184+
var lines []string
185+
for _, e := range entries {
186+
b, _ := json.Marshal(e)
187+
lines = append(lines, string(b))
188+
}
189+
_ = os.WriteFile(filepath.Join(memDir, "failures.jsonl"), []byte(strings.Join(lines, "\n")+"\n"), 0o644)
190+
191+
result := recentFailures(dir, 10)
192+
if !strings.Contains(result, "Fix parser") {
193+
t.Errorf("expected 'Fix parser' in output, got %q", result)
194+
}
195+
if !strings.Contains(result, "Add auth") {
196+
t.Errorf("expected 'Add auth' in output, got %q", result)
197+
}
198+
if !strings.Contains(result, "Day 3") {
199+
t.Errorf("expected 'Day 3' in output, got %q", result)
200+
}
201+
}
202+
203+
func TestRecentFailures_RespectsLimit(t *testing.T) {
204+
dir := t.TempDir()
205+
memDir := filepath.Join(dir, "memory")
206+
_ = os.MkdirAll(memDir, 0o755)
207+
208+
var lines []string
209+
for i := 1; i <= 15; i++ {
210+
e := map[string]interface{}{"type": "failure", "day": i, "task": "Task", "reason": "fail"}
211+
b, _ := json.Marshal(e)
212+
lines = append(lines, string(b))
213+
}
214+
_ = os.WriteFile(filepath.Join(memDir, "failures.jsonl"), []byte(strings.Join(lines, "\n")+"\n"), 0o644)
215+
216+
result := recentFailures(dir, 5)
217+
// Should contain days 11-15 (last 5), not day 1.
218+
if strings.Contains(result, "Day 1\n") || strings.Contains(result, "Day 1 —") {
219+
t.Errorf("expected day 1 to be trimmed by limit, got %q", result)
220+
}
221+
if !strings.Contains(result, "Day 15") {
222+
t.Errorf("expected day 15 in output, got %q", result)
223+
}
224+
}
225+
226+
func TestRecentFailures_CorruptLinesSkipped(t *testing.T) {
227+
dir := t.TempDir()
228+
memDir := filepath.Join(dir, "memory")
229+
_ = os.MkdirAll(memDir, 0o755)
230+
231+
content := `{"type":"failure","day":1,"task":"Good","reason":"ok"}
232+
not valid json
233+
{"type":"failure","day":2,"task":"Also good","reason":"ok"}
234+
`
235+
_ = os.WriteFile(filepath.Join(memDir, "failures.jsonl"), []byte(content), 0o644)
236+
237+
result := recentFailures(dir, 10)
238+
if !strings.Contains(result, "Good") {
239+
t.Errorf("expected 'Good' in output, got %q", result)
240+
}
241+
if !strings.Contains(result, "Also good") {
242+
t.Errorf("expected 'Also good' in output, got %q", result)
243+
}
244+
}
245+
246+
// ---------------------------------------------------------------------------
247+
// appendFailureJSONL
248+
// ---------------------------------------------------------------------------
249+
250+
func TestAppendFailureJSONL_WritesEntry(t *testing.T) {
251+
dir := t.TempDir()
252+
_ = os.WriteFile(filepath.Join(dir, "DAY_COUNT"), []byte("7"), 0o644)
253+
254+
e := New(dir, slog.Default())
255+
if err := e.appendFailureJSONL("My task", "build error"); err != nil {
256+
t.Fatalf("unexpected error: %v", err)
257+
}
258+
259+
data, err := os.ReadFile(filepath.Join(dir, "memory", "failures.jsonl"))
260+
if err != nil {
261+
t.Fatalf("failures.jsonl not created: %v", err)
262+
}
263+
264+
var entry map[string]interface{}
265+
if err := json.Unmarshal([]byte(strings.TrimSpace(string(data))), &entry); err != nil {
266+
t.Fatalf("invalid JSON written: %v", err)
267+
}
268+
if entry["task"] != "My task" {
269+
t.Errorf("task mismatch: got %v", entry["task"])
270+
}
271+
if entry["reason"] != "build error" {
272+
t.Errorf("reason mismatch: got %v", entry["reason"])
273+
}
274+
if int(entry["day"].(float64)) != 7 {
275+
t.Errorf("day mismatch: got %v", entry["day"])
276+
}
277+
}
278+
279+
func TestAppendFailureJSONL_AppendsMultiple(t *testing.T) {
280+
dir := t.TempDir()
281+
e := New(dir, slog.Default())
282+
283+
_ = e.appendFailureJSONL("Task A", "reason A")
284+
_ = e.appendFailureJSONL("Task B", "reason B")
285+
286+
data, _ := os.ReadFile(filepath.Join(dir, "memory", "failures.jsonl"))
287+
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
288+
if len(lines) != 2 {
289+
t.Errorf("expected 2 lines, got %d: %q", len(lines), string(data))
290+
}
291+
}
292+
293+
// ---------------------------------------------------------------------------
294+
// clearSessionPlan
295+
// ---------------------------------------------------------------------------
296+
297+
func TestClearSessionPlan_DeletesFile(t *testing.T) {
298+
dir := t.TempDir()
299+
planPath := filepath.Join(dir, "SESSION_PLAN.md")
300+
_ = os.WriteFile(planPath, []byte("## plan"), 0o644)
301+
302+
e := New(dir, slog.Default())
303+
e.clearSessionPlan()
304+
305+
if _, err := os.Stat(planPath); !os.IsNotExist(err) {
306+
t.Error("expected SESSION_PLAN.md to be deleted")
307+
}
308+
}
309+
310+
func TestClearSessionPlan_NoErrorIfMissing(t *testing.T) {
311+
dir := t.TempDir()
312+
e := New(dir, slog.Default())
313+
// Should not panic or error when file doesn't exist.
314+
e.clearSessionPlan()
315+
}

0 commit comments

Comments
 (0)