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
- 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
304 lines
8.4 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|