template-jj/cmd/planctl/tasks_test.go
sid aff5a30b27
Some checks failed
planctl / build-test (1.22, macos-latest) (push) Has been cancelled
planctl / build-test (1.22, ubuntu-latest) (push) Has been cancelled
planctl / build-test (1.22, windows-latest) (push) Has been cancelled
planctl / build-test (stable, macos-latest) (push) Has been cancelled
planctl / build-test (stable, ubuntu-latest) (push) Has been cancelled
planctl / build-test (stable, windows-latest) (push) Has been cancelled
planctl / build-test (1.22, macos-latest) (pull_request) Has been cancelled
planctl / build-test (1.22, ubuntu-latest) (pull_request) Has been cancelled
planctl / build-test (1.22, windows-latest) (pull_request) Has been cancelled
planctl / build-test (stable, macos-latest) (pull_request) Has been cancelled
planctl / build-test (stable, ubuntu-latest) (pull_request) Has been cancelled
planctl / build-test (stable, windows-latest) (pull_request) Has been cancelled
planctl / bench (push) Has been cancelled
planctl / bench (pull_request) Has been cancelled
feat(planctl): v2 task-state commands (next, list, complete, status)
- Add tasks.go: TaskRecord, buildTaskRecords, findTaskByID, atomicRewriteTaskLine, evalPlanStatus
- Add runNext, runList, runComplete, runStatus subcommands in main.go
- Add emitNext, emitList, emitComplete, emitStatus formatters in emit.go
- Rewrite CLI dispatch; update printUsage to list all five subcommands
- Add 28 golden-file fixture dirs under testdata/v2/
- Add mutation tests for complete (success, already-done, triggers-lint, dry-run)
- Add per-subcommand --help tests and unit tests for all emit functions
- Fix: warnings-only status shows PASS not FAIL per R4.2
- Fix: case-insensitive T prefix for complete <task-ref> per R3.1
- Fix: dry-run JSON computes hypothetical lint diagnostics per R3.8
- Fix: dry-run JSON always exits 0 per R3.8
- Add multi-plan status aggregate: Summary line (text) + summary object (JSON) per R4.6

Tasks 2.0-6.0 from dev/plans/26174-planctl-task-cmds/prd.md
2026-04-26 09:12:36 -06:00

304 lines
8.4 KiB
Go

package main
import (
"os"
"path/filepath"
"testing"
)
// spec:planctl-task-cmds/R1.2+D§3
func TestStripCheckboxPrefix(t *testing.T) {
cases := []struct {
in string
want string
}{
{"- [ ] Unchecked task", "Unchecked task"},
{"- [x] Checked task", "Checked task"},
{" - [ ] Indented task", "Indented task"},
{" - [x] Deep indent", "Deep indent"},
{"- [ ] 2.1 With ID", "2.1 With ID"},
{"\t- [ ] Tab indented", "Tab indented"},
{"\t\t- [x] Double tab indent", "Double tab indent"},
{"no checkbox here", "no checkbox here"},
{"- [?] Invalid marker", "- [?] Invalid marker"},
}
for _, c := range cases {
got := stripCheckboxPrefix(c.in)
if got != c.want {
t.Errorf("stripCheckboxPrefix(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// spec:planctl-task-cmds/R1.3+R3.1+D§3+D§6
func TestParseTaskID(t *testing.T) {
cases := []struct {
in string
want string
}{
{"2.0 Create feature branch", "T2.0"},
{"10.3 Write end-to-end tests", "T10.3"},
{"1.1 Do something", "T1.1"},
{"2 Create (bare integer, no dot)", ""},
{"No numeric prefix at all", ""},
{"Section 2.1 description", ""}, // doesn't start with digits
{"2.1.3 Multi-level ID", "T2.1.3"}, // multi-level is valid
}
for _, c := range cases {
got := parseTaskID(c.in)
if got != c.want {
t.Errorf("parseTaskID(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// spec:planctl-task-cmds/R3.1+R3.4+D§3
func TestFindTaskByID(t *testing.T) {
records := []TaskRecord{
{TaskLine: TaskLine{Line: 1, Checked: false}, ID: "T1.0", Text: "1.0 First"},
{TaskLine: TaskLine{Line: 2, Checked: true}, ID: "T1.1", Text: "1.1 Second"},
{TaskLine: TaskLine{Line: 3, Checked: false}, ID: "T2.0", Text: "2.0 Third"},
{TaskLine: TaskLine{Line: 4, Checked: false}, ID: "", Text: "No ID task"},
}
t.Run("found", func(t *testing.T) {
r, result, avail := findTaskByID(records, "T1.1")
if result != findOK {
t.Fatalf("result = %d, want findOK", result)
}
if r == nil || r.ID != "T1.1" {
t.Errorf("got record %v, want T1.1", r)
}
if avail != nil {
t.Errorf("avail should be nil on findOK, got %v", avail)
}
})
t.Run("not found lists available IDs", func(t *testing.T) {
r, result, avail := findTaskByID(records, "T9.9")
if result != findNotFound {
t.Fatalf("result = %d, want findNotFound", result)
}
if r != nil {
t.Errorf("record should be nil on findNotFound")
}
// Only non-empty IDs are listed; sorted
want := []string{"T1.0", "T1.1", "T2.0"}
if len(avail) != len(want) {
t.Fatalf("avail = %v, want %v", avail, want)
}
for i, v := range want {
if avail[i] != v {
t.Errorf("avail[%d] = %q, want %q", i, avail[i], v)
}
}
})
t.Run("bad format — no T prefix", func(t *testing.T) {
_, result, _ := findTaskByID(records, "1.0")
if result != findBadFormat {
t.Errorf("result = %d, want findBadFormat", result)
}
})
t.Run("bad format — no dot in suffix", func(t *testing.T) {
_, result, _ := findTaskByID(records, "T10")
if result != findBadFormat {
t.Errorf("result = %d, want findBadFormat", result)
}
})
t.Run("bad format — empty", func(t *testing.T) {
_, result, _ := findTaskByID(records, "")
if result != findBadFormat {
t.Errorf("result = %d, want findBadFormat", result)
}
})
t.Run("bad format — trailing junk after valid suffix", func(t *testing.T) {
_, result, _ := findTaskByID(records, "T1.2 extra")
if result != findBadFormat {
t.Errorf("result = %d, want findBadFormat (got %d)", result, result)
}
})
}
// spec:planctl-task-cmds/R4.5+D§3
func TestEvalPlanStatus(t *testing.T) {
makeTask := func(checked bool) TaskRecord {
return TaskRecord{TaskLine: TaskLine{Checked: checked}}
}
fullPlan := &Plan{CodexLog: true, HandoffFound: true}
noPlan := &Plan{}
t.Run("lint_error", func(t *testing.T) {
diags := []Diagnostic{{Severity: SevError}}
tasks := []TaskRecord{makeTask(true)}
got := evalPlanStatus(diags, tasks, fullPlan)
if got != StatusLintError {
t.Errorf("got %q, want lint_error", got)
}
})
t.Run("needs_closeout — missing codex-sessions.md", func(t *testing.T) {
tasks := []TaskRecord{makeTask(true), makeTask(true)}
got := evalPlanStatus(nil, tasks, noPlan)
if got != StatusNeedsCloseout {
t.Errorf("got %q, want needs_closeout", got)
}
})
t.Run("done", func(t *testing.T) {
tasks := []TaskRecord{makeTask(true), makeTask(true)}
got := evalPlanStatus(nil, tasks, fullPlan)
if got != StatusDone {
t.Errorf("got %q, want done", got)
}
})
t.Run("not_started", func(t *testing.T) {
tasks := []TaskRecord{makeTask(false), makeTask(false)}
got := evalPlanStatus(nil, tasks, fullPlan)
if got != StatusNotStarted {
t.Errorf("got %q, want not_started", got)
}
})
t.Run("not_started — zero tasks", func(t *testing.T) {
got := evalPlanStatus(nil, nil, fullPlan)
if got != StatusNotStarted {
t.Errorf("got %q, want not_started", got)
}
})
t.Run("in_progress", func(t *testing.T) {
tasks := []TaskRecord{makeTask(true), makeTask(false)}
got := evalPlanStatus(nil, tasks, fullPlan)
if got != StatusInProgress {
t.Errorf("got %q, want in_progress", got)
}
})
}
// spec:planctl-task-cmds/R1.2+R2.4+R2.5+D§2+D§3
func TestBuildTaskRecords(t *testing.T) {
content := "- [ ] 1.0 First task _Requirements: R1.1_\n- [x] 1.1 Second task\n- [ ] No ID task\n"
scan := scanBytes("tasks.md", []byte(content))
scan.Path = "tasks.md"
p := &Plan{Tasks: &scan}
idx := BuildIndex(p)
records := buildTaskRecords(idx, &scan, "test-plan")
if len(records) != 3 {
t.Fatalf("got %d records, want 3", len(records))
}
t.Run("first task has ID and req tag", func(t *testing.T) {
r := records[0]
if r.ID != "T1.0" {
t.Errorf("ID = %q, want T1.0", r.ID)
}
if r.Text != "1.0 First task _Requirements: R1.1_" {
t.Errorf("Text = %q unexpected", r.Text)
}
if r.Checked {
t.Errorf("Checked should be false")
}
if len(r.ReqTags) != 1 || r.ReqTags[0] != "R1.1" {
t.Errorf("ReqTags = %v, want [R1.1]", r.ReqTags)
}
})
t.Run("checked task", func(t *testing.T) {
r := records[1]
if !r.Checked {
t.Errorf("Checked should be true")
}
if r.ID != "T1.1" {
t.Errorf("ID = %q, want T1.1", r.ID)
}
})
t.Run("task without ID", func(t *testing.T) {
r := records[2]
if r.ID != "" {
t.Errorf("ID = %q, want empty", r.ID)
}
if r.Text != "No ID task" {
t.Errorf("Text = %q unexpected", r.Text)
}
})
}
// spec:planctl-task-cmds/R3.2+R3.7+D§3+D§4
func TestAtomicRewriteTaskLine(t *testing.T) {
t.Run("rewrites unchecked to checked", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.md")
content := "- [ ] 1.0 First task _Requirements: R1.1_\n- [ ] 1.1 Second task\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
old, newText, err := atomicRewriteTaskLine(path, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if old != "- [ ] 1.0 First task _Requirements: R1.1_" {
t.Errorf("old = %q unexpected", old)
}
if newText != "- [x] 1.0 First task _Requirements: R1.1_" {
t.Errorf("new = %q unexpected", newText)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := "- [x] 1.0 First task _Requirements: R1.1_\n- [ ] 1.1 Second task\n"
if string(got) != want {
t.Errorf("file content = %q, want %q", string(got), want)
}
})
t.Run("preserves file mode", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.md")
if err := os.WriteFile(path, []byte("- [ ] 1.0 task\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := atomicRewriteTaskLine(path, 1); err != nil {
t.Fatalf("unexpected error: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode() != 0o600 {
t.Errorf("mode = %o, want 0o600", info.Mode())
}
})
t.Run("error on already-checked line", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.md")
if err := os.WriteFile(path, []byte("- [x] 1.0 task\n"), 0o644); err != nil {
t.Fatal(err)
}
_, _, err := atomicRewriteTaskLine(path, 1)
if err == nil {
t.Errorf("expected error for already-checked line")
}
})
t.Run("error on out-of-range line", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.md")
if err := os.WriteFile(path, []byte("- [ ] 1.0 task\n"), 0o644); err != nil {
t.Fatal(err)
}
_, _, err := atomicRewriteTaskLine(path, 99)
if err == nil {
t.Errorf("expected error for out-of-range line")
}
})
}