template-jj/cmd/planctl/emit_test.go
sid e913ce1880
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
merge: integrate planctl v2 task-cmds + context-window token awareness
2026-04-26 09:42:58 -06:00

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())
}
}