template-jj/cmd/planctl/main_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

719 lines
24 KiB
Go

package main
import (
"bytes"
"crypto/sha256"
"flag"
"io"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
)
// spec:planctl/D§7.2
// updateGoldens is toggled by `go test -update`; when true, TestGoldenFixtures
// regenerates expected.golden + expected.exit sidecars instead of comparing.
var updateGoldens = flag.Bool("update", false, "regenerate testdata/**/expected.golden and expected.exit sidecars")
// spec:planctl-task-cmds/R5.3+R5.5+D§1
func TestRun_Help(t *testing.T) {
for _, arg := range []string{"--help", "-h"} {
t.Run(arg, func(t *testing.T) {
var stdout, stderr bytes.Buffer
exit := run([]string{arg}, &stdout, &stderr)
if exit != 0 {
t.Fatalf("exit = %d, want 0", exit)
}
out := stdout.String()
// Must list all five subcommands.
for _, sub := range []string{"lint", "next", "list", "complete", "status"} {
if !strings.Contains(out, sub) {
t.Errorf("--help output missing subcommand %q", sub)
}
}
// Must not contain the old v2-reserved stub wording.
for _, bad := range []string{"v2-reserved", "new-plan"} {
if strings.Contains(out, bad) {
t.Errorf("--help output contains stale text %q", bad)
}
}
// Must mention flags.
for _, needle := range []string{"--format", "--strict", "--no-ears", "--color", "--help", "--version"} {
if !strings.Contains(out, needle) {
t.Errorf("--help output missing flag %q", needle)
}
}
// Must list all classification codes (stable public contract per design §4).
for _, code := range []string{
"tag-syntax", "tag-unclosed", "orphan-requirement", "orphan-design",
"uncovered-requirement", "uncovered-design", "missing-prd",
"missing-closeout-file", "ears-violation",
} {
if !strings.Contains(out, code) {
t.Errorf("--help output missing classification code %q", code)
}
}
if stderr.Len() != 0 {
t.Errorf("--help wrote to stderr: %q", stderr.String())
}
})
}
}
// spec:planctl-task-cmds/R5.3+D§1
func TestSubcmd_NextHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
exit := run([]string{"next", "--help"}, &stdout, &stderr)
if exit != 0 {
t.Fatalf("exit = %d, want 0", exit)
}
out := stdout.String()
for _, needle := range []string{"next", "Usage:", "--format", "Exit codes:"} {
if !strings.Contains(out, needle) {
t.Errorf("next --help missing %q; got:\n%s", needle, out)
}
}
if stderr.Len() != 0 {
t.Errorf("next --help wrote to stderr: %q", stderr.String())
}
}
// spec:planctl-task-cmds/R5.3+D§1
func TestSubcmd_ListHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
exit := run([]string{"list", "--help"}, &stdout, &stderr)
if exit != 0 {
t.Fatalf("exit = %d, want 0", exit)
}
out := stdout.String()
for _, needle := range []string{"list", "--all", "Exit codes:"} {
if !strings.Contains(out, needle) {
t.Errorf("list --help missing %q", needle)
}
}
}
// spec:planctl-task-cmds/R5.3+D§1
func TestSubcmd_CompleteHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
exit := run([]string{"complete", "--help"}, &stdout, &stderr)
if exit != 0 {
t.Fatalf("exit = %d, want 0", exit)
}
out := stdout.String()
for _, needle := range []string{"complete", "--dry-run", "<task-ref>", "Exit codes:", "snapshotting"} {
if !strings.Contains(out, needle) {
t.Errorf("complete --help missing %q", needle)
}
}
}
// spec:planctl-task-cmds/R5.3+D§1
func TestSubcmd_StatusHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
exit := run([]string{"status", "--help"}, &stdout, &stderr)
if exit != 0 {
t.Fatalf("exit = %d, want 0", exit)
}
out := stdout.String()
for _, needle := range []string{"status", "--strict", "Exit codes:"} {
if !strings.Contains(out, needle) {
t.Errorf("status --help missing %q", needle)
}
}
}
// spec:planctl/R5.7
func TestRun_Version(t *testing.T) {
var stdout, stderr bytes.Buffer
exit := run([]string{"--version"}, &stdout, &stderr)
if exit != 0 {
t.Fatalf("exit = %d, want 0", exit)
}
if !strings.HasPrefix(stdout.String(), "planctl ") {
t.Errorf("--version output %q does not start with %q", stdout.String(), "planctl ")
}
if stderr.Len() != 0 {
t.Errorf("--version wrote to stderr: %q", stderr.String())
}
}
// spec:planctl-task-cmds/R5.5
// TestRun_V2ReservedSubcommands is intentionally deleted — all v2 stubs have
// been replaced by real implementations. new-plan now hits the default
// unknown-subcommand path, covered by TestRun_UnknownSubcommand.
// spec:planctl/R5.8
func TestRun_UnknownSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
exit := run([]string{"frobnicate"}, &stdout, &stderr)
if exit != 2 {
t.Fatalf("exit = %d, want 2", exit)
}
if !strings.Contains(stderr.String(), "frobnicate") {
t.Errorf("stderr %q does not name the unknown subcommand", stderr.String())
}
// Usage must be included on error per R5.8.
if !strings.Contains(stderr.String(), "Usage:") {
t.Errorf("stderr missing usage: %q", stderr.String())
}
}
// spec:planctl/R5.8
func TestRun_UnknownTopLevelFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
exit := run([]string{"--bogus"}, &stdout, &stderr)
if exit != 2 {
t.Fatalf("exit = %d, want 2", exit)
}
if !strings.Contains(stderr.String(), "--bogus") {
t.Errorf("stderr %q does not name the unknown flag", stderr.String())
}
}
// spec:planctl/R5.8
func TestRun_NoArgs(t *testing.T) {
var stdout, stderr bytes.Buffer
exit := run(nil, &stdout, &stderr)
if exit != 2 {
t.Fatalf("exit = %d, want 2 (no args should require usage)", exit)
}
if !strings.Contains(stderr.String(), "Usage:") {
t.Errorf("stderr missing usage on no-args invocation: %q", stderr.String())
}
}
// spec:planctl/R3.1+R3.2+R5.3+R5.4+D§1
// TestRun_LintMissingPRD verifies the lint subcommand dispatches end-to-
// end and produces the missing-prd fatal path when prd.md is absent.
// Uses a temp dir so the test is isolated from the actual working-
// directory contents (otherwise Case C would discover this repo's own
// plan dirs). Parent 5.5 adds the full resolvePlans coverage.
func TestRun_LintMissingPRD(t *testing.T) {
dir := t.TempDir()
var stdout, stderr bytes.Buffer
exit := run([]string{"lint", dir}, &stdout, &stderr)
if exit != 1 {
t.Fatalf("exit = %d, want 1 (missing-prd); stdout=%q stderr=%q", exit, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), "missing-prd") {
t.Errorf("stdout lacks 'missing-prd': %q", stdout.String())
}
}
// spec:planctl/R5.1+D§3.5
// TestResolvePlans exercises the four R5.1 cases A/B/C/D using
// t.TempDir()-staged directory shapes. `/tmp` isn't used directly for
// Case D because the tempdir's ancestors are the actual test-subject
// for "no dev/plans/ ever" — any stray `dev/plans/` under /tmp would
// break the test, so we explicitly verify by filepath.
func TestResolvePlans(t *testing.T) {
t.Run("Case A: explicit valid path", func(t *testing.T) {
dir := t.TempDir()
got, err := resolvePlans(dir, "/irrelevant")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 {
t.Fatalf("want 1 result, got %d: %v", len(got), got)
}
abs, _ := filepath.Abs(dir)
if got[0] != abs {
t.Errorf("got %q, want %q", got[0], abs)
}
})
t.Run("Case A: explicit invalid path", func(t *testing.T) {
_, err := resolvePlans("/nonexistent/nope/never/1234567", "/irrelevant")
if err == nil {
t.Fatalf("want error for missing path, got nil")
}
})
t.Run("Case B: deeply-nested cwd inside plan dir", func(t *testing.T) {
root := t.TempDir()
planDir := filepath.Join(root, "dev", "plans", "26172-planctl")
deep := filepath.Join(planDir, "sub", "deeper", "nested")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
got, err := resolvePlans("", deep)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 {
t.Fatalf("want 1 result, got %d: %v", len(got), got)
}
absPlan, _ := filepath.Abs(planDir)
if got[0] != absPlan {
t.Errorf("got %q, want %q", got[0], absPlan)
}
})
t.Run("Case C: repo root with plans + archive filter + lex sort", func(t *testing.T) {
root := t.TempDir()
// Three plans + an archive and an unrelated dir; only the two
// pattern-matching non-archive dirs should be returned, sorted.
for _, d := range []string{
"dev/plans/26170-alpha",
"dev/plans/26172-beta",
"dev/plans/archive/26100-retired",
"dev/plans/not-a-plan",
} {
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", d, err)
}
}
got, err := resolvePlans("", root)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
absRoot, _ := filepath.Abs(root)
want := []string{
filepath.Join(absRoot, "dev", "plans", "26170-alpha"),
filepath.Join(absRoot, "dev", "plans", "26172-beta"),
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
})
t.Run("Case D: no plans ancestor returns error", func(t *testing.T) {
// Walk up from a tempdir whose ancestry we've verified has no
// dev/plans/ layer. If /tmp happens to contain dev/plans/ this
// test would be fragile; sanity-check by scanning ancestors.
root := t.TempDir()
for d := root; ; {
if _, err := os.Stat(filepath.Join(d, "dev", "plans")); err == nil {
t.Skipf("skipping: dev/plans/ exists at ancestor %s of tempdir", d)
}
parent := filepath.Dir(d)
if parent == d {
break
}
d = parent
}
_, err := resolvePlans("", root)
if err == nil {
t.Fatalf("want error for Case D, got nil")
}
if !strings.Contains(err.Error(), "no plan directory found") {
t.Errorf("error message should mention 'no plan directory found': %v", err)
}
})
}
// spec:planctl/R5.6+R5.8+D§3.6
// TestParseLintFlags covers the accepted flag surface (R5.6 / D§3.6)
// plus the unknown-flag rejection path (R5.8). --color values are
// validated but not acted on — v1 keeps the flag a no-op per D-1.
func TestParseLintFlags(t *testing.T) {
t.Run("defaults", func(t *testing.T) {
f, err := parseLintFlags(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if f.format != "text" || f.color != "auto" || f.strict || f.noEars || f.planDir != "" {
t.Errorf("unexpected default flags: %+v", f)
}
})
t.Run("--format=json", func(t *testing.T) {
f, err := parseLintFlags([]string{"--format=json"})
if err != nil || f.format != "json" {
t.Errorf("want format=json, got %+v err=%v", f, err)
}
})
t.Run("--format json (space form)", func(t *testing.T) {
f, err := parseLintFlags([]string{"--format", "json"})
if err != nil || f.format != "json" {
t.Errorf("want format=json, got %+v err=%v", f, err)
}
})
t.Run("--strict", func(t *testing.T) {
f, _ := parseLintFlags([]string{"--strict"})
if !f.strict {
t.Errorf("strict not set")
}
})
t.Run("--no-ears", func(t *testing.T) {
f, _ := parseLintFlags([]string{"--no-ears"})
if !f.noEars {
t.Errorf("noEars not set")
}
})
t.Run("--color=never", func(t *testing.T) {
f, err := parseLintFlags([]string{"--color=never"})
if err != nil || f.color != "never" {
t.Errorf("want color=never, got %+v err=%v", f, err)
}
})
t.Run("unknown flag is an error", func(t *testing.T) {
_, err := parseLintFlags([]string{"--bogus"})
if err == nil {
t.Errorf("unknown flag should error")
}
})
t.Run("invalid --format value is an error", func(t *testing.T) {
_, err := parseLintFlags([]string{"--format=xml"})
if err == nil {
t.Errorf("invalid format should error")
}
})
t.Run("positional plan-dir", func(t *testing.T) {
f, err := parseLintFlags([]string{"--strict", "my-plan"})
if err != nil || f.planDir != "my-plan" || !f.strict {
t.Errorf("want planDir=my-plan strict=true, got %+v err=%v", f, err)
}
})
t.Run("last-value-wins on repeated flag", func(t *testing.T) {
f, _ := parseLintFlags([]string{"--format=text", "--format=json"})
if f.format != "json" {
t.Errorf("last value should win: got %q", f.format)
}
})
}
// spec:planctl/R1.1+R1.2+R1.3+R1.4+R1.5+R1.6+R2.4+R2.5+R2.6+R2.7+R3.5+R3.6+R3.7+R4.2+R5.2+R5.3+R5.4+R5.5+R5.9+R5.10+D§7.2
// TestGoldenFixtures sweeps testdata/{clean,dirty,inline-code-exclusion,
// multi}/** and checks each fixture against its expected.golden +
// expected.exit sidecars. An optional args.txt overrides the default
// invocation (defaults to `lint <fixtureDir>`); an optional cwd-rel.txt
// overrides the test's working directory relative to the fixture.
//
// `go test -update` regenerates the sidecars. Goldens are reviewed by
// hand before committing — the harness merely codifies the diff.
func TestGoldenFixtures(t *testing.T) {
classes := []string{"clean", "dirty", "inline-code-exclusion", "multi", "v2"}
for _, class := range classes {
classDir := filepath.Join("testdata", class)
if _, err := os.Stat(classDir); err != nil {
continue
}
// If the class directory itself looks like a plan (has prd.md)
// OR like a multi-plan root (has dev/plans/), treat it as a
// single fixture. This covers the flat-layout case
// (testdata/inline-code-exclusion/{prd.md,tasks.md}).
if isFixtureRoot(classDir) {
t.Run(class, func(t *testing.T) {
runGoldenFixture(t, classDir)
})
continue
}
entries, err := os.ReadDir(classDir)
if err != nil {
t.Fatalf("readdir %s: %v", classDir, err)
}
for _, e := range entries {
if !e.IsDir() {
continue
}
fixture := filepath.Join(classDir, e.Name())
t.Run(class+"/"+e.Name(), func(t *testing.T) {
runGoldenFixture(t, fixture)
})
}
}
}
// isFixtureRoot reports whether dir is itself a runnable fixture —
// either a single plan dir (has prd.md) or a multi-plan root
// (has dev/plans/). Used to allow both flat and nested fixture layouts.
func isFixtureRoot(dir string) bool {
if _, err := os.Stat(filepath.Join(dir, "prd.md")); err == nil {
return true
}
if info, err := os.Stat(filepath.Join(dir, "dev", "plans")); err == nil && info.IsDir() {
return true
}
return false
}
// spec:planctl/D§7.2+D§7.4
// runGoldenFixture is the single-fixture test body. It:
// - loads the optional args.txt / cwd-rel.txt override files,
// - Chdir()s into the computed cwd so the fixture sees the right CWD,
// - invokes run() with the computed args,
// - normalises stdout (CRLF → LF) per design §7.4's Windows-CI guard,
// - compares against sidecar expectations, or regenerates them if
// -update is set.
func runGoldenFixture(t *testing.T, fixture string) {
t.Helper()
absFixture, err := filepath.Abs(fixture)
if err != nil {
t.Fatalf("abs %s: %v", fixture, err)
}
args, cwd := loadFixtureInvocation(t, fixture)
// spec:26174-planctl-context-tokens/R3.4+R4.1+D§7.2
setupFixtureContextEnv(t, fixture)
var stdout, stderr bytes.Buffer
restore := chdir(t, cwd)
defer restore()
exit := run(args, &stdout, &stderr)
got := strings.ReplaceAll(stdout.String(), "\r\n", "\n")
goldenPath := filepath.Join(absFixture, "expected.golden")
exitPath := filepath.Join(absFixture, "expected.exit")
if *updateGoldens {
if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
if err := os.WriteFile(exitPath, []byte(strconv.Itoa(exit)+"\n"), 0o644); err != nil {
t.Fatalf("write exit: %v", err)
}
return
}
wantGolden, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read expected.golden: %v (run `go test -update` to seed)", err)
}
want := strings.ReplaceAll(string(wantGolden), "\r\n", "\n")
if got != want {
t.Errorf("stdout mismatch:\n--- want ---\n%s--- got ---\n%s--- stderr ---\n%s",
want, got, stderr.String())
}
wantExitRaw, err := os.ReadFile(exitPath)
if err != nil {
t.Fatalf("read expected.exit: %v", err)
}
wantExit, err := strconv.Atoi(strings.TrimSpace(string(wantExitRaw)))
if err != nil {
t.Fatalf("parse expected.exit: %v", err)
}
if exit != wantExit {
t.Errorf("exit = %d, want %d; stdout=%q stderr=%q", exit, wantExit, got, stderr.String())
}
}
// spec:26174-planctl-context-tokens/R3.4+R4.1+D§7.2
// setupFixtureContextEnv wires per-fixture environment for the context-window
// feature:
//
// - env.txt — one KEY=VALUE line per env var; trimmed, `#` comments skipped.
// Each key is pinned via t.Setenv (auto-restored on test end).
// - transcript.jsonl — staged under a temp HOME at
// ~/.claude/projects/<slug>/<uuid>.jsonl where slug is derived from
// CLAUDE_PROJECT_DIR (or fixture dir) with `/` → `-` and uuid is
// CLAUDE_SESSION_ID. When transcript.jsonl is present but the required
// env keys aren't, the staging step is skipped silently so non-context
// fixtures remain unaffected.
//
// Fixtures without env.txt or transcript.jsonl are untouched — baseline fixtures
// stay byte-identical.
func setupFixtureContextEnv(t *testing.T, fixture string) {
t.Helper()
envPath := filepath.Join(fixture, "env.txt")
if b, err := os.ReadFile(envPath); err == nil {
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
t.Setenv(strings.TrimSpace(k), strings.TrimSpace(v))
}
}
tp := filepath.Join(fixture, "transcript.jsonl")
tb, err := os.ReadFile(tp)
if err != nil {
return
}
// Need a uuid + a slug to derive the transcript path. If either is missing,
// skip staging — the fixture under test probably doesn't need it to pass.
uuid := os.Getenv("CLAUDE_SESSION_ID")
projectDir := os.Getenv("CLAUDE_PROJECT_DIR")
if uuid == "" || projectDir == "" {
return
}
// Stage a temp HOME and drop the transcript at the exact path planctl will look up.
home := t.TempDir()
t.Setenv("HOME", home)
slug := strings.ReplaceAll(projectDir, "/", "-")
dir := filepath.Join(home, ".claude", "projects", slug)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir transcript dir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), tb, 0o600); err != nil {
t.Fatalf("stage transcript: %v", err)
}
}
// loadFixtureInvocation reads args.txt and cwd-rel.txt if present. The
// args.txt tokens may reference the literal `FIXTURE` substring, which
// is substituted with the absolute path of the fixture directory so
// goldens written once stay portable across dev machines.
//
// Defaults:
// - args = ["lint", <fixtureAbs>]
// - cwd = fixtureAbs (needed so Case C fixtures discover their own
// dev/plans/ before resolvePlans walks into the real repo root)
func loadFixtureInvocation(t *testing.T, fixture string) (args []string, cwd string) {
t.Helper()
absFixture, err := filepath.Abs(fixture)
if err != nil {
t.Fatalf("abs %s: %v", fixture, err)
}
args = []string{"lint", absFixture}
cwd = absFixture
if b, err := os.ReadFile(filepath.Join(fixture, "args.txt")); err == nil {
args = strings.Fields(strings.TrimSpace(string(b)))
for i, tok := range args {
args[i] = strings.ReplaceAll(tok, "FIXTURE", absFixture)
}
}
if b, err := os.ReadFile(filepath.Join(fixture, "cwd-rel.txt")); err == nil {
rel := strings.TrimSpace(string(b))
cwd = filepath.Join(absFixture, rel)
}
return args, cwd
}
// chdir changes the test's working directory for the duration of the
// caller's scope. The returned func restores the previous cwd; the test
// must defer it.
func chdir(t *testing.T, dir string) func() {
t.Helper()
prev, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir %s: %v", dir, err)
}
return func() { _ = os.Chdir(prev) }
}
// copyFixture copies all files from src into dst (non-recursively for the top
// level, but recursively for subdirs). Used by mutation tests to avoid
// dirtying testdata/.
func copyFixture(t *testing.T, src, dst string) {
t.Helper()
entries, err := os.ReadDir(src)
if err != nil {
t.Fatalf("readdir %s: %v", src, err)
}
for _, e := range entries {
srcPath := filepath.Join(src, e.Name())
dstPath := filepath.Join(dst, e.Name())
if e.IsDir() {
if err := os.MkdirAll(dstPath, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dstPath, err)
}
copyFixture(t, srcPath, dstPath)
continue
}
data, err := os.ReadFile(srcPath)
if err != nil {
t.Fatalf("read %s: %v", srcPath, err)
}
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
t.Fatalf("write %s: %v", dstPath, err)
}
}
}
// fileHash returns the SHA-256 hash of the file at path.
func fileHash(t *testing.T, path string) [32]byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return sha256.Sum256(data)
}
// spec:planctl-task-cmds/R3.2+R3.6+T4.4
// TestCompleteSuccess verifies that `complete T1.1 <dir>` marks the task
// checked, the file is written, and post-mutation lint is clean.
func TestCompleteSuccess(t *testing.T) {
src := filepath.Join("testdata", "v2", "complete-success")
tmp := t.TempDir()
copyFixture(t, src, tmp)
var stdout, stderr bytes.Buffer
exit := run([]string{"complete", "T1.1", tmp}, &stdout, &stderr)
if exit != 0 {
t.Fatalf("exit = %d, want 0; stdout=%q stderr=%q", exit, stdout.String(), stderr.String())
}
tasksPath := filepath.Join(tmp, "tasks.md")
data, err := os.ReadFile(tasksPath)
if err != nil {
t.Fatalf("read tasks.md: %v", err)
}
if !strings.Contains(string(data), "- [x] 1.1") {
t.Errorf("tasks.md should contain '- [x] 1.1' after complete; got:\n%s", data)
}
if !strings.Contains(stdout.String(), "T1.1") {
t.Errorf("stdout should name the completed task; got: %q", stdout.String())
}
}
// spec:planctl-task-cmds/R3.3+T4.5
// TestCompleteAlreadyDone verifies that completing an already-checked task
// exits 0 and does not modify the file.
func TestCompleteAlreadyDone(t *testing.T) {
src := filepath.Join("testdata", "v2", "complete-already-done")
tmp := t.TempDir()
copyFixture(t, src, tmp)
tasksPath := filepath.Join(tmp, "tasks.md")
before := fileHash(t, tasksPath)
var stdout, stderr bytes.Buffer
exit := run([]string{"complete", "T1.0", tmp}, &stdout, &stderr)
if exit != 0 {
t.Fatalf("exit = %d, want 0; stdout=%q stderr=%q", exit, stdout.String(), stderr.String())
}
after := fileHash(t, tasksPath)
if before != after {
t.Errorf("tasks.md was modified despite task already being done")
}
if !strings.Contains(stdout.String(), "already complete") {
t.Errorf("stdout should say 'already complete'; got: %q", stdout.String())
}
}
// spec:planctl-task-cmds/R3.6+T4.9
// TestCompleteTriggersLint verifies that completing the last task in a plan
// with no closeout files exits 1 and reports missing-closeout-file.
func TestCompleteTriggersLint(t *testing.T) {
src := filepath.Join("testdata", "v2", "complete-triggers-lint")
tmp := t.TempDir()
copyFixture(t, src, tmp)
var stdout, stderr bytes.Buffer
exit := run([]string{"complete", "T1.1", tmp}, &stdout, &stderr)
if exit != 1 {
t.Fatalf("exit = %d, want 1 (lint error after completion); stdout=%q stderr=%q", exit, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), "missing-closeout-file") {
t.Errorf("stdout should mention missing-closeout-file; got: %q", stdout.String())
}
}
// spec:planctl-task-cmds/R3.8+M6+T4.10
// TestCompleteDryRun verifies that --dry-run exits 0, prints "Would change",
// and does NOT modify tasks.md (SHA-256 invariant M6).
func TestCompleteDryRun(t *testing.T) {
src := filepath.Join("testdata", "v2", "complete-dry-run")
tmp := t.TempDir()
copyFixture(t, src, tmp)
tasksPath := filepath.Join(tmp, "tasks.md")
before := fileHash(t, tasksPath)
var stdout, stderr bytes.Buffer
exit := run([]string{"complete", "--dry-run", "T1.1", tmp}, &stdout, &stderr)
if exit != 0 {
t.Fatalf("exit = %d, want 0; stdout=%q stderr=%q", exit, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), "Would change") {
t.Errorf("stdout should contain 'Would change'; got: %q", stdout.String())
}
after := fileHash(t, tasksPath)
if before != after {
t.Errorf("tasks.md was modified during --dry-run (M6 violation)")
}
}
// Ensure sha256 and io are used (linter guard).
var _ = sha256.Sum256
var _ io.Writer