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 / bench (push) Has been cancelled
940 lines
31 KiB
Go
940 lines
31 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// spec:planctl/R5.2+R5.3+R5.9+R5.10+D§3.7+D§7.1
|
|
// TestEmitText_SinglePlanClean verifies R5.3 clean-summary emission in
|
|
// single-plan mode: just the one-line summary, no header, no aggregate.
|
|
func TestEmitText_SinglePlanClean(t *testing.T) {
|
|
plans := []PlanResult{{
|
|
PlanDir: "26172-planctl",
|
|
TaskCount: 45, ReqCount: 32, DesCount: 9,
|
|
}}
|
|
var buf bytes.Buffer
|
|
exit := emitText(&buf, plans, false, nil)
|
|
if exit != 0 {
|
|
t.Errorf("want exit 0 (clean), got %d", exit)
|
|
}
|
|
wantLine := "26172-planctl: clean (45 tasks, 32 requirements, 9 design sections)\n"
|
|
if buf.String() != wantLine {
|
|
t.Errorf("want %q, got %q", wantLine, buf.String())
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R5.2+R5.4+R5.10+D§3.7+D§7.1
|
|
// TestEmitText_SinglePlanDirty checks the R5.10 single-plan-with-dirt
|
|
// output: per-line diagnostics, no aggregate summary. One error + one
|
|
// warning → exit 1 (R5.4).
|
|
func TestEmitText_SinglePlanDirty(t *testing.T) {
|
|
plans := []PlanResult{{
|
|
PlanDir: "26172-planctl",
|
|
Diagnostics: []Diagnostic{
|
|
{PlanDir: "26172-planctl", Path: "tasks.md", Line: 42, Severity: SevError, Code: CodeOrphanRequirement, Message: "cites R3.7, not declared in prd.md"},
|
|
{PlanDir: "26172-planctl", Path: "prd.md", Line: 128, Severity: SevWarning, Code: CodeEARSViolation, Message: "body does not match EARS keyword"},
|
|
},
|
|
}}
|
|
var buf bytes.Buffer
|
|
exit := emitText(&buf, plans, false, nil)
|
|
if exit != 1 {
|
|
t.Errorf("want exit 1 (error present), got %d", exit)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "tasks.md:42: [error] orphan-requirement:") {
|
|
t.Errorf("missing orphan-requirement line: %q", out)
|
|
}
|
|
if !strings.Contains(out, "prd.md:128: [warning] ears-violation:") {
|
|
t.Errorf("missing ears-violation line: %q", out)
|
|
}
|
|
if strings.Contains(out, "=== ") {
|
|
t.Errorf("single-plan output should have no `=== ` header: %q", out)
|
|
}
|
|
if strings.Contains(out, "plans linted") {
|
|
t.Errorf("single-plan output should have no aggregate summary: %q", out)
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R5.9+D§3.7+D§7.1
|
|
// TestEmitText_MultiPlan checks the R5.9 multi-plan format: per-plan
|
|
// `=== ... ===` header, blank line between plans, aggregate summary.
|
|
// Labels are always plural per R5.9.
|
|
func TestEmitText_MultiPlan(t *testing.T) {
|
|
plans := []PlanResult{
|
|
{
|
|
PlanDir: "26167-group-backend",
|
|
Diagnostics: []Diagnostic{
|
|
{PlanDir: "26167-group-backend", Path: "tasks.md", Line: 90, Severity: SevWarning, Code: CodeEARSViolation, Message: "warning body"},
|
|
},
|
|
},
|
|
{
|
|
PlanDir: "26172-planctl",
|
|
TaskCount: 3, ReqCount: 2, DesCount: 1,
|
|
},
|
|
{
|
|
PlanDir: "26175-other",
|
|
Diagnostics: []Diagnostic{
|
|
{PlanDir: "26175-other", Path: "tasks.md", Line: 42, Severity: SevError, Code: CodeOrphanRequirement, Message: "error body"},
|
|
},
|
|
},
|
|
}
|
|
var buf bytes.Buffer
|
|
exit := emitText(&buf, plans, false, nil)
|
|
if exit != 1 {
|
|
t.Errorf("want exit 1 (error in 3rd plan), got %d", exit)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "=== 26167-group-backend ===") {
|
|
t.Errorf("missing first header: %q", out)
|
|
}
|
|
if !strings.Contains(out, "=== 26172-planctl ===") {
|
|
t.Errorf("missing middle header: %q", out)
|
|
}
|
|
if !strings.Contains(out, "=== 26175-other ===") {
|
|
t.Errorf("missing last header: %q", out)
|
|
}
|
|
if !strings.Contains(out, "26172-planctl: clean (3 tasks, 2 requirements, 1 design sections)") {
|
|
t.Errorf("missing clean summary for middle plan: %q", out)
|
|
}
|
|
if !strings.Contains(out, "3 plans linted, 1 errors, 1 warnings\n") {
|
|
t.Errorf("missing aggregate summary line: %q", out)
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R5.5+D§3.7
|
|
// TestEmitText_StrictPromotesWarnings: --strict flips warnings-only runs
|
|
// from exit 0 to exit 1 per R5.5.
|
|
func TestEmitText_StrictPromotesWarnings(t *testing.T) {
|
|
plans := []PlanResult{{
|
|
PlanDir: "p",
|
|
Diagnostics: []Diagnostic{
|
|
{Path: "prd.md", Line: 1, Severity: SevWarning, Code: CodeEARSViolation, Message: "w"},
|
|
},
|
|
}}
|
|
if got := emitText(new(bytes.Buffer), plans, false, nil); got != 0 {
|
|
t.Errorf("non-strict warning-only: want exit 0, got %d", got)
|
|
}
|
|
if got := emitText(new(bytes.Buffer), plans, true, nil); got != 1 {
|
|
t.Errorf("strict warning-only: want exit 1, got %d", got)
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R5.6+R5.11+D§3.7+D§7.1
|
|
// TestEmitJSON_ValidJSONL checks that every emitted line is a valid JSON
|
|
// object and that the final line is the aggregate summary.
|
|
func TestEmitJSON_ValidJSONL(t *testing.T) {
|
|
plans := []PlanResult{
|
|
{
|
|
PlanDir: "26167-a",
|
|
Diagnostics: []Diagnostic{
|
|
{PlanDir: "26167-a", Path: "tasks.md", Line: 90, Severity: SevWarning, Code: CodeEARSViolation, Message: "warning body"},
|
|
},
|
|
},
|
|
{
|
|
PlanDir: "26175-b",
|
|
Diagnostics: []Diagnostic{
|
|
{PlanDir: "26175-b", Path: "tasks.md", Line: 42, Severity: SevError, Code: CodeOrphanRequirement, Message: "error body"},
|
|
},
|
|
},
|
|
}
|
|
var buf bytes.Buffer
|
|
exit := emitJSON(&buf, plans, false, nil)
|
|
if exit != 1 {
|
|
t.Errorf("want exit 1, got %d", exit)
|
|
}
|
|
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
|
if len(lines) != 3 {
|
|
t.Fatalf("want 3 JSONL lines (2 diags + 1 summary), got %d: %q", len(lines), buf.String())
|
|
}
|
|
// Verify each line parses as a JSON object.
|
|
for i, line := range lines {
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
|
t.Errorf("line %d not valid JSON: %v (line=%q)", i, err, line)
|
|
}
|
|
}
|
|
// First two lines should carry the expected diagnostic fields.
|
|
var diag jsonDiag
|
|
if err := json.Unmarshal([]byte(lines[0]), &diag); err != nil {
|
|
t.Fatalf("line 0 decode: %v", err)
|
|
}
|
|
if diag.PlanDir != "26167-a" || diag.Path != "tasks.md" || diag.Line != 90 || diag.Severity != "warning" || diag.Code != "ears-violation" {
|
|
t.Errorf("line 0 diag mismatch: %+v", diag)
|
|
}
|
|
// Last line is the summary wrapper.
|
|
var sum jsonSummary
|
|
if err := json.Unmarshal([]byte(lines[2]), &sum); err != nil {
|
|
t.Fatalf("line 2 decode: %v", err)
|
|
}
|
|
if sum.Summary.Plans != 2 || sum.Summary.Errors != 1 || sum.Summary.Warnings != 1 {
|
|
t.Errorf("summary mismatch: %+v", sum.Summary)
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R5.6+R5.11+D§3.7
|
|
// TestEmitJSON_CleanPlans covers the all-clean-plans case: no per-diag
|
|
// lines, only the aggregate summary.
|
|
func TestEmitJSON_CleanPlans(t *testing.T) {
|
|
plans := []PlanResult{
|
|
{PlanDir: "a"},
|
|
{PlanDir: "b"},
|
|
}
|
|
var buf bytes.Buffer
|
|
exit := emitJSON(&buf, plans, false, nil)
|
|
if exit != 0 {
|
|
t.Errorf("want exit 0, got %d", exit)
|
|
}
|
|
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
|
if len(lines) != 1 {
|
|
t.Fatalf("want 1 JSONL line (summary only), got %d: %q", len(lines), buf.String())
|
|
}
|
|
var sum jsonSummary
|
|
if err := json.Unmarshal([]byte(lines[0]), &sum); err != nil {
|
|
t.Fatalf("summary decode: %v", err)
|
|
}
|
|
if sum.Summary.Plans != 2 || sum.Summary.Errors != 0 || sum.Summary.Warnings != 0 {
|
|
t.Errorf("summary mismatch: %+v", sum.Summary)
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R5.4+R5.5+D§3.7
|
|
// TestEmit_ExitCodes pins computeExit's behavior via the emitter entry
|
|
// points: clean → 0, any error → 1, warning-only + --strict → 1,
|
|
// warning-only + no-strict → 0.
|
|
func TestEmit_ExitCodes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
plans []PlanResult
|
|
strict bool
|
|
want int
|
|
}{
|
|
{name: "all clean", plans: []PlanResult{{PlanDir: "a"}}, want: 0},
|
|
{
|
|
name: "any error → 1",
|
|
plans: []PlanResult{{Diagnostics: []Diagnostic{
|
|
{Severity: SevError, Code: CodeOrphanRequirement, Message: "x"},
|
|
}}},
|
|
want: 1,
|
|
},
|
|
{
|
|
name: "warning only, no strict → 0",
|
|
plans: []PlanResult{{Diagnostics: []Diagnostic{
|
|
{Severity: SevWarning, Code: CodeEARSViolation, Message: "y"},
|
|
}}},
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "warning only, strict → 1",
|
|
plans: []PlanResult{{Diagnostics: []Diagnostic{
|
|
{Severity: SevWarning, Code: CodeEARSViolation, Message: "y"},
|
|
}}},
|
|
strict: true, want: 1,
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := emitText(new(bytes.Buffer), tc.plans, tc.strict, nil); got != tc.want {
|
|
t.Errorf("emitText: want %d, got %d", tc.want, got)
|
|
}
|
|
if got := emitJSON(new(bytes.Buffer), tc.plans, tc.strict, nil); got != tc.want {
|
|
t.Errorf("emitJSON: want %d, got %d", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTaskParent(t *testing.T) {
|
|
cases := []struct {
|
|
id string
|
|
want string
|
|
}{
|
|
{"T2.1", "T2"},
|
|
{"T2.1.3", "T2.1"},
|
|
{"T10.3.2", "T10.3"},
|
|
{"T1", ""},
|
|
{"(no-id)", ""},
|
|
{"", ""},
|
|
}
|
|
for _, c := range cases {
|
|
got := taskParent(c.id)
|
|
if got != c.want {
|
|
t.Errorf("taskParent(%q) = %q, want %q", c.id, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── v2 emit unit tests ───────────────────────────────────────────────────────
|
|
|
|
// spec:planctl-task-cmds/R1.3+R1.4+D§4+D§7
|
|
// TestEmitNext_Text checks text-format output: found → 3-line block;
|
|
// not-found → "No open tasks." on a single line.
|
|
func TestEmitNext_Text(t *testing.T) {
|
|
rec := &TaskRecord{
|
|
TaskLine: TaskLine{File: "tasks.md", Line: 4, Checked: false},
|
|
Text: "1.1 Second task _Requirements: R1.1_",
|
|
ID: "T1.1",
|
|
ReqTags: []string{"R1.1"},
|
|
}
|
|
|
|
t.Run("found", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if exit := emitNext(&buf, rec, "text"); exit != 0 {
|
|
t.Errorf("want exit 0, got %d", exit)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "T1.1 1.1 Second task") {
|
|
t.Errorf("missing id+text line: %q", out)
|
|
}
|
|
if !strings.Contains(out, "Tags: _Requirements: R1.1_") {
|
|
t.Errorf("missing Tags line: %q", out)
|
|
}
|
|
if !strings.Contains(out, "File: tasks.md:4") {
|
|
t.Errorf("missing File line: %q", out)
|
|
}
|
|
})
|
|
|
|
t.Run("not-found", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if exit := emitNext(&buf, nil, "text"); exit != 0 {
|
|
t.Errorf("want exit 0, got %d", exit)
|
|
}
|
|
if buf.String() != "No open tasks.\n" {
|
|
t.Errorf("want 'No open tasks.\\n', got %q", buf.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R1.3+R1.4+D§4+D§7
|
|
// TestEmitNext_JSON validates JSON output for found and not-found cases,
|
|
// including the parent field (taskParent) and the open:false sentinel.
|
|
func TestEmitNext_JSON(t *testing.T) {
|
|
rec := &TaskRecord{
|
|
TaskLine: TaskLine{File: "tasks.md", Line: 4, Checked: false},
|
|
PlanDir: "test-plan",
|
|
Text: "1.1 Second task",
|
|
ID: "T1.1",
|
|
ReqTags: []string{"R1.1"},
|
|
}
|
|
|
|
t.Run("found", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if exit := emitNext(&buf, rec, "json"); exit != 0 {
|
|
t.Errorf("want exit 0, got %d", exit)
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(strings.TrimRight(buf.String(), "\n")), &m); err != nil {
|
|
t.Fatalf("JSON parse: %v", err)
|
|
}
|
|
if _, hasOpen := m["open"]; hasOpen {
|
|
t.Errorf("found case must not include open field per R1.3; got open=%v", m["open"])
|
|
}
|
|
if m["task_id"] != "T1.1" {
|
|
t.Errorf("want task_id=T1.1, got %v", m["task_id"])
|
|
}
|
|
if m["parent"] != "T1" {
|
|
t.Errorf("want parent=T1, got %v", m["parent"])
|
|
}
|
|
if m["file"] != "test-plan/tasks.md" {
|
|
t.Errorf("want file=test-plan/tasks.md, got %v", m["file"])
|
|
}
|
|
})
|
|
|
|
t.Run("not-found", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if exit := emitNext(&buf, nil, "json"); exit != 0 {
|
|
t.Errorf("want exit 0, got %d", exit)
|
|
}
|
|
if strings.TrimRight(buf.String(), "\n") != `{"open":false}` {
|
|
t.Errorf("want {\"open\":false}, got %q", buf.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R2.4+R2.5+R2.6+D§4+D§7
|
|
// TestEmitList_Text covers single-plan open-only, single-plan --all, and
|
|
// multi-plan (headers + aggregate Total line).
|
|
func TestEmitList_Text(t *testing.T) {
|
|
records := []TaskRecord{
|
|
{TaskLine: TaskLine{File: "tasks.md", Line: 2, Checked: true}, ID: "T1.0", Text: "1.0 First task"},
|
|
{TaskLine: TaskLine{File: "tasks.md", Line: 3, Checked: false}, ID: "T1.1", Text: "1.1 Second task"},
|
|
{TaskLine: TaskLine{File: "tasks.md", Line: 4, Checked: false}, ID: "T1.2", Text: "1.2 Third task"},
|
|
}
|
|
|
|
t.Run("single open-only", func(t *testing.T) {
|
|
pr := []listPlanResult{{PlanDir: "26172-planctl", Records: records}}
|
|
var buf bytes.Buffer
|
|
if exit := emitList(&buf, pr, "text", false); exit != 0 {
|
|
t.Errorf("want exit 0, got %d", exit)
|
|
}
|
|
out := buf.String()
|
|
if strings.Contains(out, "[x]") {
|
|
t.Errorf("open-only should not include checked tasks: %q", out)
|
|
}
|
|
if !strings.Contains(out, "[ ] T1.1 1.1 Second task") {
|
|
t.Errorf("missing T1.1: %q", out)
|
|
}
|
|
if !strings.Contains(out, "[ ] T1.2 1.2 Third task") {
|
|
t.Errorf("missing T1.2: %q", out)
|
|
}
|
|
})
|
|
|
|
t.Run("single all", func(t *testing.T) {
|
|
pr := []listPlanResult{{PlanDir: "26172-planctl", Records: records}}
|
|
var buf bytes.Buffer
|
|
if exit := emitList(&buf, pr, "text", true); exit != 0 {
|
|
t.Errorf("want exit 0, got %d", exit)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "[x] T1.0 1.0 First task") {
|
|
t.Errorf("--all should show checked T1.0: %q", out)
|
|
}
|
|
if !strings.Contains(out, "[ ] T1.1 1.1 Second task") {
|
|
t.Errorf("missing T1.1: %q", out)
|
|
}
|
|
})
|
|
|
|
t.Run("multi-plan headers+summary", func(t *testing.T) {
|
|
prs := []listPlanResult{
|
|
{
|
|
PlanDir: "26167-alpha",
|
|
Records: []TaskRecord{
|
|
{TaskLine: TaskLine{File: "tasks.md", Line: 2, Checked: false}, ID: "T1.0", Text: "1.0 Alpha task"},
|
|
},
|
|
},
|
|
{
|
|
PlanDir: "26172-beta",
|
|
Records: []TaskRecord{
|
|
{TaskLine: TaskLine{File: "tasks.md", Line: 2, Checked: false}, ID: "T1.0", Text: "1.0 Beta task"},
|
|
{TaskLine: TaskLine{File: "tasks.md", Line: 3, Checked: false}, ID: "T1.1", Text: "1.1 Beta task two"},
|
|
},
|
|
},
|
|
}
|
|
var buf bytes.Buffer
|
|
if exit := emitList(&buf, prs, "text", false); exit != 0 {
|
|
t.Errorf("want exit 0, got %d", exit)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "=== 26167-alpha ===") {
|
|
t.Errorf("missing alpha header: %q", out)
|
|
}
|
|
if !strings.Contains(out, "=== 26172-beta ===") {
|
|
t.Errorf("missing beta header: %q", out)
|
|
}
|
|
if !strings.Contains(out, "Total: 3 open across 2 plans") {
|
|
t.Errorf("missing aggregate summary: %q", out)
|
|
}
|
|
})
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R2.5+R2.6+D§4+D§7
|
|
// TestEmitList_JSON validates JSON output for single-plan (flat object) and
|
|
// multi-plan (wrapped in "plans" array with totals).
|
|
func TestEmitList_JSON(t *testing.T) {
|
|
t.Run("single plan", func(t *testing.T) {
|
|
pr := []listPlanResult{{
|
|
PlanDir: "dev/plans/26172-planctl",
|
|
Records: []TaskRecord{
|
|
{TaskLine: TaskLine{File: "tasks.md", Line: 3, Checked: false}, ID: "T1.1", Text: "1.1 Task", ReqTags: []string{"R1.1"}},
|
|
},
|
|
}}
|
|
var buf bytes.Buffer
|
|
_ = emitList(&buf, pr, "json", false)
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(strings.TrimRight(buf.String(), "\n")), &m); err != nil {
|
|
t.Fatalf("JSON parse: %v", err)
|
|
}
|
|
if m["plan"] != "26172-planctl" {
|
|
t.Errorf("want plan=26172-planctl (basename), got %v", m["plan"])
|
|
}
|
|
if int(m["open_count"].(float64)) != 1 {
|
|
t.Errorf("want open_count=1, got %v", m["open_count"])
|
|
}
|
|
})
|
|
|
|
t.Run("multi-plan", func(t *testing.T) {
|
|
prs := []listPlanResult{
|
|
{
|
|
PlanDir: "26167-alpha",
|
|
Records: []TaskRecord{
|
|
{TaskLine: TaskLine{File: "tasks.md", Line: 2, Checked: false}, ID: "T1.0", Text: "1.0 task"},
|
|
},
|
|
},
|
|
{
|
|
PlanDir: "26172-beta",
|
|
Records: []TaskRecord{
|
|
{TaskLine: TaskLine{File: "tasks.md", Line: 2, Checked: false}, ID: "T1.0", Text: "1.0 task"},
|
|
{TaskLine: TaskLine{File: "tasks.md", Line: 3, Checked: true}, ID: "T1.1", Text: "1.1 done"},
|
|
},
|
|
},
|
|
}
|
|
var buf bytes.Buffer
|
|
_ = emitList(&buf, prs, "json", false)
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(strings.TrimRight(buf.String(), "\n")), &m); err != nil {
|
|
t.Fatalf("JSON parse: %v", err)
|
|
}
|
|
plans, ok := m["plans"].([]any)
|
|
if !ok || len(plans) != 2 {
|
|
t.Errorf("want plans array of 2, got %v", m["plans"])
|
|
}
|
|
if int(m["total_open"].(float64)) != 2 {
|
|
t.Errorf("want total_open=2 (checked task excluded from open), got %v", m["total_open"])
|
|
}
|
|
})
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R3.8+R3.9+D§4+D§7
|
|
// TestEmitComplete_Text covers live (no dry-run) clean, live with error
|
|
// diagnostics, and dry-run preview.
|
|
func TestEmitComplete_Text(t *testing.T) {
|
|
old := "- [ ] 1.1 task"
|
|
new := "- [x] 1.1 task"
|
|
|
|
t.Run("live clean", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if exit := emitComplete(&buf, "T1.1", 4, old, new, false, nil, "text"); exit != 0 {
|
|
t.Errorf("want exit 0, got %d", exit)
|
|
}
|
|
want := "Completed T1.1 (line 4). 0 diagnostics.\n"
|
|
if buf.String() != want {
|
|
t.Errorf("want %q, got %q", want, buf.String())
|
|
}
|
|
})
|
|
|
|
t.Run("live with error diag", func(t *testing.T) {
|
|
diags := []Diagnostic{
|
|
{Path: "prd.md", Line: 5, Severity: SevError, Code: CodeOrphanRequirement, Message: "cites missing req"},
|
|
}
|
|
var buf bytes.Buffer
|
|
if exit := emitComplete(&buf, "T1.1", 4, old, new, false, diags, "text"); exit != 1 {
|
|
t.Errorf("want exit 1 (error diag), got %d", exit)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "Completed T1.1 (line 4). 1 diagnostics.") {
|
|
t.Errorf("missing header: %q", out)
|
|
}
|
|
if !strings.Contains(out, "prd.md:5: [error] orphan-requirement: cites missing req") {
|
|
t.Errorf("missing diag line: %q", out)
|
|
}
|
|
})
|
|
|
|
t.Run("dry-run", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if exit := emitComplete(&buf, "T1.1", 4, old, new, true, nil, "text"); exit != 0 {
|
|
t.Errorf("want exit 0 for dry-run, got %d", exit)
|
|
}
|
|
if !strings.Contains(buf.String(), "Would change line 4") {
|
|
t.Errorf("missing dry-run preview: %q", buf.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R3.8+R3.9+D§4+D§7
|
|
// TestEmitComplete_JSON validates JSON output fields for clean and dry-run.
|
|
func TestEmitComplete_JSON(t *testing.T) {
|
|
old := "- [ ] 1.1 task"
|
|
new := "- [x] 1.1 task"
|
|
|
|
t.Run("clean", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if exit := emitComplete(&buf, "T1.1", 4, old, new, false, nil, "json"); exit != 0 {
|
|
t.Errorf("want exit 0, got %d", exit)
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(strings.TrimRight(buf.String(), "\n")), &m); err != nil {
|
|
t.Fatalf("JSON parse: %v", err)
|
|
}
|
|
if m["dry_run"] != false {
|
|
t.Errorf("want dry_run=false, got %v", m["dry_run"])
|
|
}
|
|
if m["task_id"] != "T1.1" {
|
|
t.Errorf("want task_id=T1.1, got %v", m["task_id"])
|
|
}
|
|
diags, ok := m["diagnostics"].([]any)
|
|
if !ok || len(diags) != 0 {
|
|
t.Errorf("want empty diagnostics array, got %v", m["diagnostics"])
|
|
}
|
|
})
|
|
|
|
t.Run("dry-run with diag", func(t *testing.T) {
|
|
diags := []Diagnostic{
|
|
{Path: "tasks.md", Line: 8, Severity: SevWarning, Code: CodeEARSViolation, Message: "not EARS"},
|
|
}
|
|
var buf bytes.Buffer
|
|
_ = emitComplete(&buf, "T1.1", 4, old, new, true, diags, "json")
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(strings.TrimRight(buf.String(), "\n")), &m); err != nil {
|
|
t.Fatalf("JSON parse: %v", err)
|
|
}
|
|
if m["dry_run"] != true {
|
|
t.Errorf("want dry_run=true, got %v", m["dry_run"])
|
|
}
|
|
diagsOut, ok := m["diagnostics"].([]any)
|
|
if !ok || len(diagsOut) != 1 {
|
|
t.Errorf("want 1 diagnostic, got %v", m["diagnostics"])
|
|
}
|
|
})
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R4.3+R4.4+R4.5+R4.6+D§4+D§7
|
|
// TestEmitStatus_Text verifies the 4-line block format and exit codes for
|
|
// all five PlanStatus values.
|
|
func TestEmitStatus_Text(t *testing.T) {
|
|
cases := []struct {
|
|
sr statusPlanResult
|
|
wantExit int
|
|
wantStatus string
|
|
wantLint string
|
|
wantClose string
|
|
}{
|
|
{
|
|
sr: statusPlanResult{
|
|
PlanDir: "plan", TotalTasks: 3, DoneTasks: 0,
|
|
LintErrors: 0, LintWarnings: 0,
|
|
HasCodexLog: false, HasHandoff: false,
|
|
Status: StatusNotStarted,
|
|
},
|
|
wantExit: 1, wantStatus: "NOT STARTED",
|
|
wantLint: "PASS", wantClose: "missing codex-sessions.md, handoff*.md",
|
|
},
|
|
{
|
|
sr: statusPlanResult{
|
|
PlanDir: "plan", TotalTasks: 3, DoneTasks: 1,
|
|
LintErrors: 0, LintWarnings: 0,
|
|
HasCodexLog: false, HasHandoff: false,
|
|
Status: StatusInProgress,
|
|
},
|
|
wantExit: 1, wantStatus: "IN PROGRESS",
|
|
wantLint: "PASS", wantClose: "missing codex-sessions.md, handoff*.md",
|
|
},
|
|
{
|
|
sr: statusPlanResult{
|
|
PlanDir: "plan", TotalTasks: 3, DoneTasks: 0,
|
|
LintErrors: 2, LintWarnings: 0,
|
|
HasCodexLog: false, HasHandoff: false,
|
|
Status: StatusLintError,
|
|
},
|
|
wantExit: 1, wantStatus: "LINT ERROR",
|
|
wantLint: "FAIL", wantClose: "missing codex-sessions.md, handoff*.md",
|
|
},
|
|
{
|
|
sr: statusPlanResult{
|
|
PlanDir: "plan", TotalTasks: 3, DoneTasks: 3,
|
|
LintErrors: 0, LintWarnings: 0,
|
|
HasCodexLog: false, HasHandoff: false,
|
|
Status: StatusNeedsCloseout,
|
|
},
|
|
wantExit: 1, wantStatus: "NEEDS CLOSEOUT",
|
|
wantLint: "PASS", wantClose: "missing codex-sessions.md, handoff*.md",
|
|
},
|
|
{
|
|
sr: statusPlanResult{
|
|
PlanDir: "plan", TotalTasks: 3, DoneTasks: 3,
|
|
LintErrors: 0, LintWarnings: 0,
|
|
HasCodexLog: true, HasHandoff: true,
|
|
Status: StatusDone,
|
|
},
|
|
wantExit: 0, wantStatus: "DONE",
|
|
wantLint: "PASS", wantClose: "Close: OK",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(string(tc.sr.Status), func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
exit := emitStatus(&buf, []statusPlanResult{tc.sr}, "text")
|
|
if exit != tc.wantExit {
|
|
t.Errorf("want exit %d, got %d", tc.wantExit, exit)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "Status: "+tc.wantStatus) {
|
|
t.Errorf("want 'Status: %s' in %q", tc.wantStatus, out)
|
|
}
|
|
if !strings.Contains(out, "Lint: "+tc.wantLint) {
|
|
t.Errorf("want 'Lint: %s' in %q", tc.wantLint, out)
|
|
}
|
|
if !strings.Contains(out, tc.wantClose) {
|
|
t.Errorf("want %q in %q", tc.wantClose, out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R4.4+R4.6+D§4+D§7
|
|
// TestEmitStatus_JSON validates single-plan JSON structure including tasks,
|
|
// lint, closeout, and status fields.
|
|
func TestEmitStatus_JSON(t *testing.T) {
|
|
sr := statusPlanResult{
|
|
PlanDir: "26172-planctl",
|
|
TotalTasks: 5,
|
|
DoneTasks: 3,
|
|
LintErrors: 0,
|
|
LintWarnings: 1,
|
|
HasCodexLog: true,
|
|
HasHandoff: false,
|
|
Status: StatusInProgress,
|
|
}
|
|
var buf bytes.Buffer
|
|
if exit := emitStatus(&buf, []statusPlanResult{sr}, "json"); exit != 1 {
|
|
t.Errorf("want exit 1 for in_progress, got %d", exit)
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(strings.TrimRight(buf.String(), "\n")), &m); err != nil {
|
|
t.Fatalf("JSON parse: %v", err)
|
|
}
|
|
if m["plan"] != "26172-planctl" {
|
|
t.Errorf("want plan=26172-planctl, got %v", m["plan"])
|
|
}
|
|
if m["status"] != "in_progress" {
|
|
t.Errorf("want status=in_progress, got %v", m["status"])
|
|
}
|
|
tasks, ok := m["tasks"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("want tasks object, got %T", m["tasks"])
|
|
}
|
|
if int(tasks["total"].(float64)) != 5 || int(tasks["done"].(float64)) != 3 || int(tasks["open"].(float64)) != 2 {
|
|
t.Errorf("tasks mismatch: %v", tasks)
|
|
}
|
|
lint, ok := m["lint"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("want lint object, got %T", m["lint"])
|
|
}
|
|
if int(lint["warnings"].(float64)) != 1 {
|
|
t.Errorf("want lint.warnings=1, got %v", lint["warnings"])
|
|
}
|
|
closeout, ok := m["closeout"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("want closeout object, got %T", m["closeout"])
|
|
}
|
|
if closeout["ready"] != false {
|
|
t.Errorf("want closeout.ready=false, got %v", closeout["ready"])
|
|
}
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R4.5+R4.6+D§4
|
|
// TestEmitStatus_MultiPlan verifies exit code (any non-done → 1) and the
|
|
// aggregate Summary line appended after the per-plan blocks.
|
|
func TestEmitStatus_MultiPlan(t *testing.T) {
|
|
results := []statusPlanResult{
|
|
{
|
|
PlanDir: "a", TotalTasks: 2, DoneTasks: 2,
|
|
HasCodexLog: true, HasHandoff: true,
|
|
Status: StatusDone,
|
|
},
|
|
{
|
|
PlanDir: "b", TotalTasks: 3, DoneTasks: 1,
|
|
Status: StatusInProgress,
|
|
},
|
|
}
|
|
var buf bytes.Buffer
|
|
if exit := emitStatus(&buf, results, "text"); exit != 1 {
|
|
t.Errorf("want exit 1 when any plan is not done, got %d", exit)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "Plan: a") {
|
|
t.Errorf("missing Plan: a block: %q", out)
|
|
}
|
|
if !strings.Contains(out, "Plan: b") {
|
|
t.Errorf("missing Plan: b block: %q", out)
|
|
}
|
|
wantSummary := "Summary: 2 plans; tasks: 5 total, 2 open, 3 done; statuses: 0 lint_error, 0 needs_closeout, 1 done, 0 not_started, 1 in_progress"
|
|
if !strings.Contains(out, wantSummary) {
|
|
t.Errorf("missing aggregate Summary line: %q", out)
|
|
}
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R4.6+D§4
|
|
// TestEmitStatus_MultiPlan_JSON verifies that multi-plan JSON output includes
|
|
// a top-level "summary" object with total_plans, tasks, and status_counts.
|
|
func TestEmitStatus_MultiPlan_JSON(t *testing.T) {
|
|
results := []statusPlanResult{
|
|
{PlanDir: "a", TotalTasks: 1, DoneTasks: 1, HasCodexLog: true, HasHandoff: true, Status: StatusDone},
|
|
{PlanDir: "b", TotalTasks: 2, DoneTasks: 1, Status: StatusInProgress},
|
|
}
|
|
var buf bytes.Buffer
|
|
_ = emitStatus(&buf, results, "json")
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(strings.TrimRight(buf.String(), "\n")), &m); err != nil {
|
|
t.Fatalf("JSON parse: %v", err)
|
|
}
|
|
plans, ok := m["plans"].([]any)
|
|
if !ok || len(plans) != 2 {
|
|
t.Errorf("want plans array of 2, got %v", m["plans"])
|
|
}
|
|
summary, ok := m["summary"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("want summary object, got %T: %v", m["summary"], m["summary"])
|
|
}
|
|
if int(summary["total_plans"].(float64)) != 2 {
|
|
t.Errorf("want total_plans=2, got %v", summary["total_plans"])
|
|
}
|
|
sc, ok := summary["status_counts"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("want status_counts object, got %T", summary["status_counts"])
|
|
}
|
|
if int(sc["done"].(float64)) != 1 || int(sc["in_progress"].(float64)) != 1 {
|
|
t.Errorf("want done=1, in_progress=1; got %v", sc)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R3.4+R3.5+R4.1+D§4.3
|
|
func TestEmitText_ContextInfo(t *testing.T) {
|
|
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
|
ctx := &TokenCtx{Used: 142000, Limit: 200000} // 71% → info
|
|
var buf bytes.Buffer
|
|
emitText(&buf, plans, false, ctx)
|
|
want := "context: 142 k / 200 k tokens (71%) — plan to wrap up this session soon.\n"
|
|
if !strings.Contains(buf.String(), want) {
|
|
t.Errorf("missing context line, got %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R4.1+D§4.3
|
|
func TestEmitText_ContextWarn(t *testing.T) {
|
|
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
|
ctx := &TokenCtx{Used: 174000, Limit: 200000} // 87%
|
|
var buf bytes.Buffer
|
|
emitText(&buf, plans, false, ctx)
|
|
want := "context: 174 k / 200 k tokens (87%) — commit current work and start a new session after this task.\n"
|
|
if !strings.Contains(buf.String(), want) {
|
|
t.Errorf("missing warn context line, got %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R4.1+D§4.3
|
|
func TestEmitText_ContextError(t *testing.T) {
|
|
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
|
ctx := &TokenCtx{Used: 192000, Limit: 200000} // 96%
|
|
var buf bytes.Buffer
|
|
emitText(&buf, plans, false, ctx)
|
|
want := "context: 192 k / 200 k tokens (96%) — stop new work; commit and close out immediately.\n"
|
|
if !strings.Contains(buf.String(), want) {
|
|
t.Errorf("missing error context line, got %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R3.5+D§4.3
|
|
func TestEmitText_ContextNormalSilent(t *testing.T) {
|
|
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
|
ctx := &TokenCtx{Used: 50000, Limit: 200000} // 25% → Normal band, silent
|
|
var buf bytes.Buffer
|
|
emitText(&buf, plans, false, ctx)
|
|
if strings.Contains(buf.String(), "context:") {
|
|
t.Errorf("Normal band should emit no context line, got %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R3.3+R4.2+D§4.3
|
|
func TestEmitText_ContextUnknownLimit(t *testing.T) {
|
|
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
|
ctx := &TokenCtx{Used: 143000, Limit: 0}
|
|
var buf bytes.Buffer
|
|
emitText(&buf, plans, false, ctx)
|
|
want := "context: 143 k tokens (limit unknown)\n"
|
|
if !strings.Contains(buf.String(), want) {
|
|
t.Errorf("missing unknown-limit context line, got %q", buf.String())
|
|
}
|
|
if strings.Contains(buf.String(), "—") {
|
|
t.Errorf("unknown-limit should emit no recommendation suffix, got %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R4.3+D§4.3
|
|
func TestEmitText_ContextMultiPlanOnce(t *testing.T) {
|
|
plans := []PlanResult{
|
|
{PlanDir: "a", TaskCount: 1},
|
|
{PlanDir: "b", TaskCount: 1},
|
|
}
|
|
ctx := &TokenCtx{Used: 142000, Limit: 200000}
|
|
var buf bytes.Buffer
|
|
emitText(&buf, plans, false, ctx)
|
|
out := buf.String()
|
|
n := strings.Count(out, "context:")
|
|
if n != 1 {
|
|
t.Errorf("multi-plan should emit context line exactly once, got %d\n%s", n, out)
|
|
}
|
|
// Must come AFTER the aggregate summary line ("2 plans linted, ...").
|
|
agg := strings.Index(out, "2 plans linted")
|
|
ctxIdx := strings.Index(out, "context:")
|
|
if agg < 0 || ctxIdx < 0 || ctxIdx < agg {
|
|
t.Errorf("context line must follow aggregate summary; agg=%d ctx=%d\n%s", agg, ctxIdx, out)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R5.1+R5.4+D§4.4
|
|
func TestEmitJSON_ContextWindowKnown(t *testing.T) {
|
|
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
|
ctx := &TokenCtx{Used: 142000, Limit: 200000}
|
|
var buf bytes.Buffer
|
|
emitJSON(&buf, plans, false, ctx)
|
|
// Last line is the summary object.
|
|
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
|
last := lines[len(lines)-1]
|
|
var obj struct {
|
|
Summary map[string]int `json:"summary"`
|
|
ContextWindow struct {
|
|
TokensUsed int64 `json:"tokens_used"`
|
|
TokensLimit int64 `json:"tokens_limit"`
|
|
Pct int `json:"pct"`
|
|
Severity string `json:"severity"`
|
|
Recommendation string `json:"recommendation"`
|
|
} `json:"context_window"`
|
|
}
|
|
if err := json.Unmarshal([]byte(last), &obj); err != nil {
|
|
t.Fatalf("unmarshal: %v\n%s", err, last)
|
|
}
|
|
if obj.Summary["errors"] != 0 || obj.Summary["warnings"] != 0 {
|
|
t.Errorf("context should not affect error/warning counts: %+v", obj.Summary)
|
|
}
|
|
if obj.ContextWindow.TokensUsed != 142000 || obj.ContextWindow.TokensLimit != 200000 {
|
|
t.Errorf("tokens = %+v", obj.ContextWindow)
|
|
}
|
|
if obj.ContextWindow.Pct != 71 || obj.ContextWindow.Severity != "info" {
|
|
t.Errorf("pct/severity wrong: %+v", obj.ContextWindow)
|
|
}
|
|
if obj.ContextWindow.Recommendation == "" {
|
|
t.Error("recommendation empty")
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R5.2+D§4.4
|
|
func TestEmitJSON_ContextWindowUnknownLimit(t *testing.T) {
|
|
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
|
ctx := &TokenCtx{Used: 143000, Limit: 0}
|
|
var buf bytes.Buffer
|
|
emitJSON(&buf, plans, false, ctx)
|
|
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
|
last := lines[len(lines)-1]
|
|
// Should contain tokens_used but NOT tokens_limit/pct/severity/recommendation.
|
|
if !strings.Contains(last, `"tokens_used":143000`) {
|
|
t.Errorf("missing tokens_used: %s", last)
|
|
}
|
|
for _, omitted := range []string{"tokens_limit", "pct", "severity", "recommendation"} {
|
|
if strings.Contains(last, omitted) {
|
|
t.Errorf("unknown-limit should omit %q: %s", omitted, last)
|
|
}
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R5.3+D§4.4
|
|
func TestEmitJSON_ContextWindowNormalOmitted(t *testing.T) {
|
|
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
|
ctx := &TokenCtx{Used: 50000, Limit: 200000} // Normal
|
|
var buf bytes.Buffer
|
|
emitJSON(&buf, plans, false, ctx)
|
|
if strings.Contains(buf.String(), "context_window") {
|
|
t.Errorf("Normal band should omit context_window: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R5.3+D§4.4
|
|
func TestEmitJSON_ContextWindowNilOmitted(t *testing.T) {
|
|
plans := []PlanResult{{PlanDir: "p", TaskCount: 1}}
|
|
var buf bytes.Buffer
|
|
emitJSON(&buf, plans, false, nil)
|
|
if strings.Contains(buf.String(), "context_window") {
|
|
t.Errorf("nil ctx should omit context_window: %s", buf.String())
|
|
}
|
|
}
|