ajhahn.de
← eeco
Go 353 lines
package workflow

import (
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/ajhahnde/eeco/internal/config"
	"github.com/ajhahnde/eeco/internal/queue"
)

func queueBody(t *testing.T, cfg *config.Config) string {
	t.Helper()
	b, err := os.ReadFile(filepath.Join(cfg.Workspace, "state", queue.Filename))
	if err != nil {
		if os.IsNotExist(err) {
			return ""
		}
		t.Fatalf("queue: %v", err)
	}
	return string(b)
}

func TestEvolve_ManualIsDisabledNoOp(t *testing.T) {
	cfg := newCfg(t)
	cfg.Automation = config.AutomationManual

	res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, true)})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Errorf("code = %d, want clean", res.Code)
	}
	if !strings.Contains(res.Summary, "manual") {
		t.Errorf("summary = %q, want it to mention manual", res.Summary)
	}
	if q := queueBody(t, cfg); q != "" {
		t.Errorf("manual evolve must not queue anything, got:\n%s", q)
	}
}

func TestEvolve_ProposeDefersWithoutConsent(t *testing.T) {
	cfg := newCfg(t)
	cfg.Automation = config.AutomationPropose

	// Consent false: the Gate parks the prompt and queues ai-parked;
	// evolve reports AI-deferred (contract code 3), like bug-sweep.
	res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeAIDeferred {
		t.Fatalf("code = %d, want %d (AI deferred)", res.Code, CodeAIDeferred)
	}
	if !strings.Contains(queueBody(t, cfg), "ai-parked") {
		t.Error("expected the Gate to have queued an ai-parked item")
	}
}

func TestEvolve_ProposeQueuesProposalNoScaffold(t *testing.T) {
	cfg := newCfg(t)
	cfg.Automation = config.AutomationPropose
	p := &stubProvider{text: "Repeated release bumps.\nWORKFLOW: release-bump — automate it\n"}

	res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, p, true)})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("code = %d, want clean", res.Code)
	}
	q := queueBody(t, cfg)
	if !strings.Contains(q, "**evolve**") || !strings.Contains(q, "proposal ready") {
		t.Errorf("queue missing evolve proposal:\n%s", q)
	}
	// propose level must NOT write a workflow.
	if _, err := os.Stat(filepath.Join(cfg.Workspace, "workflows", "release-bump")); !os.IsNotExist(err) {
		t.Errorf("propose level scaffolded a workflow (err=%v)", err)
	}
}

func TestEvolve_ScaffoldWritesInactiveAndQueues(t *testing.T) {
	cfg := newCfg(t)
	cfg.Automation = config.AutomationScaffold
	p := &stubProvider{text: "prose\nWORKFLOW: dep-audit — audit deps\nWORKFLOW: dep-audit — duplicate\nWORKFLOW: BAD NAME — skip\n"}

	res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, p, true)})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("code = %d, want clean", res.Code)
	}
	dir := filepath.Join(cfg.Workspace, "workflows", "dep-audit")
	if _, err := os.Stat(filepath.Join(dir, "run")); err != nil {
		t.Fatalf("scaffolded entry missing: %v", err)
	}
	if _, err := os.Stat(filepath.Join(dir, "README.md")); err != nil {
		t.Fatalf("scaffolded README missing: %v", err)
	}
	q := queueBody(t, cfg)
	if !strings.Contains(q, "ready to activate") || !strings.Contains(q, "dep-audit") {
		t.Errorf("queue missing 'ready to activate' for dep-audit:\n%s", q)
	}
	// The duplicate name must be scaffolded only once (idempotent set).
	if strings.Count(q, "ready to activate") != 1 {
		t.Errorf("dep-audit scaffolded/queued more than once:\n%s", q)
	}
}

func TestEvolve_ScaffoldCollisionIsNotFatal(t *testing.T) {
	cfg := newCfg(t)
	cfg.Automation = config.AutomationScaffold
	// Pre-existing workflow of the same name: Scaffold refuses to
	// overwrite; evolve must record the collision and keep going.
	clash := filepath.Join(cfg.Workspace, "workflows", "taken")
	if err := os.MkdirAll(clash, 0o755); err != nil {
		t.Fatal(err)
	}
	p := &stubProvider{text: "WORKFLOW: taken — collides\nWORKFLOW: fresh-one — ok\n"}

	res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, p, true)})
	if err != nil {
		t.Fatalf("collision must not be fatal: %v", err)
	}
	if res.Code != CodeClean {
		t.Fatalf("code = %d, want clean", res.Code)
	}
	if _, err := os.Stat(filepath.Join(cfg.Workspace, "workflows", "fresh-one", "run")); err != nil {
		t.Errorf("the non-colliding candidate was not scaffolded: %v", err)
	}
	q := queueBody(t, cfg)
	if !strings.Contains(q, "could not be scaffolded: taken") {
		t.Errorf("collision not surfaced in queue:\n%s", q)
	}
}

func TestEvolve_NilGateDefers(t *testing.T) {
	cfg := newCfg(t)
	cfg.Automation = config.AutomationPropose
	res, err := evolve{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeAIDeferred {
		t.Errorf("code = %d, want %d (AI deferred) with nil gate", res.Code, CodeAIDeferred)
	}
}

func TestEvolve_NoConsent_DetCandidates_ReturnsClean(t *testing.T) {
	cfg := newCfg(t)
	cfg.Automation = config.AutomationPropose
	root := cfg.RepoRoot

	writeRepoFile(t, root, "a.txt", "1")
	gitInit(t, root)
	runGit(t, root, "commit", "-q", "-m", "feat: one")
	runGit(t, root, "commit", "-q", "--allow-empty", "-m", "feat: two")
	runGit(t, root, "commit", "-q", "--allow-empty", "-m", "feat: three")

	res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("code = %d, want %d (clean) — no-consent + det≥1 must exit 0", res.Code, CodeClean)
	}
	if !strings.Contains(res.Summary, "deterministic candidate") {
		t.Errorf("summary = %q, want it to mention deterministic candidate(s)", res.Summary)
	}
	q := queueBody(t, cfg)
	if !strings.Contains(q, "Workflow candidate: feat-workflow") {
		t.Errorf("queue missing feat-workflow candidate:\n%s", q)
	}
	if !strings.Contains(q, "ai-parked") {
		t.Errorf("queue missing ai-parked item (gate did not park on no-consent):\n%s", q)
	}
}

func TestEvolve_NoConsent_NoDetCandidates_DefersExit3(t *testing.T) {
	cfg := newCfg(t)
	cfg.Automation = config.AutomationPropose
	root := cfg.RepoRoot

	writeRepoFile(t, root, "a.txt", "1")
	gitInit(t, root)
	runGit(t, root, "commit", "-q", "-m", "plain message one")
	runGit(t, root, "commit", "-q", "--allow-empty", "-m", "plain message two")
	runGit(t, root, "commit", "-q", "--allow-empty", "-m", "plain message three")

	res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeAIDeferred {
		t.Fatalf("code = %d, want %d (AI deferred) — no-consent + det=0 must preserve exit 3", res.Code, CodeAIDeferred)
	}
}

func TestEvolve_LedgerWrittenOnFirstRun(t *testing.T) {
	cfg := newCfg(t)
	cfg.Automation = config.AutomationPropose
	root := cfg.RepoRoot

	writeRepoFile(t, root, "a.txt", "1")
	gitInit(t, root)
	runGit(t, root, "commit", "-q", "-m", "fix: one")
	runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: two")
	runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: three")

	env := Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}
	if _, err := (evolve{}).Run(env); err != nil {
		t.Fatal(err)
	}

	stateDir := filepath.Join(cfg.Workspace, "state")
	h, err := LoadHistory(stateDir)
	if err != nil {
		t.Fatal(err)
	}
	if len(h.Records) != 1 {
		t.Fatalf("ledger records: got %d, want 1", len(h.Records))
	}
	r := h.Records[0]
	if r.SignalKind != SignalCommitType || r.SignalKey != "fix" {
		t.Errorf("ledger signal: got %s/%s, want %s/fix", r.SignalKind, r.SignalKey, SignalCommitType)
	}
	if r.CountAtProposal != 3 {
		t.Errorf("CountAtProposal: got %d, want 3", r.CountAtProposal)
	}
	if r.QueueTitle != "Workflow candidate: fix-workflow" {
		t.Errorf("QueueTitle: got %q", r.QueueTitle)
	}
	if r.Resolved {
		t.Errorf("fresh record must not be resolved")
	}
}

func TestEvolve_LedgerSuppressesRepeatRun(t *testing.T) {
	cfg := newCfg(t)
	cfg.Automation = config.AutomationPropose
	root := cfg.RepoRoot

	writeRepoFile(t, root, "a.txt", "1")
	gitInit(t, root)
	runGit(t, root, "commit", "-q", "-m", "fix: one")
	runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: two")
	runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: three")

	// First run files the candidate + ledger record.
	env := Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}
	if _, err := (evolve{}).Run(env); err != nil {
		t.Fatal(err)
	}
	q1 := queueBody(t, cfg)
	count1 := strings.Count(q1, "Workflow candidate: fix-workflow")
	if count1 != 1 {
		t.Fatalf("first run candidate count: got %d, want 1", count1)
	}

	// Second run with identical git log: ledger must suppress the
	// candidate; the queue row count stays at 1 (no duplicate).
	env = Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}
	if _, err := (evolve{}).Run(env); err != nil {
		t.Fatal(err)
	}
	q2 := queueBody(t, cfg)
	count2 := strings.Count(q2, "Workflow candidate: fix-workflow")
	if count2 != 1 {
		t.Errorf("ledger suppression failed: candidate appeared %d times, want 1", count2)
	}

	// Ledger must still hold exactly one record (no append on repeat).
	h, err := LoadHistory(filepath.Join(cfg.Workspace, "state"))
	if err != nil {
		t.Fatal(err)
	}
	if len(h.Records) != 1 {
		t.Errorf("ledger records after repeat run: got %d, want 1", len(h.Records))
	}
}

func TestEvolve_LedgerResolvedRecordStillSuppresses(t *testing.T) {
	cfg := newCfg(t)
	cfg.Automation = config.AutomationPropose
	root := cfg.RepoRoot
	stateDir := filepath.Join(cfg.Workspace, "state")

	writeRepoFile(t, root, "a.txt", "1")
	gitInit(t, root)
	runGit(t, root, "commit", "-q", "-m", "fix: one")
	runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: two")
	runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: three")

	// First run.
	env := Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}
	if _, err := (evolve{}).Run(env); err != nil {
		t.Fatal(err)
	}

	// Operator resolves the queue item by ticking the checkbox.
	body, err := os.ReadFile(filepath.Join(stateDir, queue.Filename))
	if err != nil {
		t.Fatal(err)
	}
	rewritten := strings.Replace(string(body), "- [ ] **evolve**", "- [x] **evolve**", 1)
	if err := os.WriteFile(filepath.Join(stateDir, queue.Filename), []byte(rewritten), 0o644); err != nil {
		t.Fatal(err)
	}

	// Second run: reconciliation flips Resolved; suppression still holds
	// (resolved records suppress in v2.2.0 — re-propose-on-recurrence is
	// a follow-on slice).
	env = Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}
	if _, err := (evolve{}).Run(env); err != nil {
		t.Fatal(err)
	}
	h, err := LoadHistory(stateDir)
	if err != nil {
		t.Fatal(err)
	}
	if len(h.Records) != 1 {
		t.Fatalf("records after run-resolve-run: got %d, want 1", len(h.Records))
	}
	if !h.Records[0].Resolved {
		t.Errorf("reconciliation must flip Resolved → true")
	}
}

func TestParseCandidates(t *testing.T) {
	in := strings.Join([]string{
		"some prose first",
		"WORKFLOW: good-name — does a thing",
		"WORKFLOW:   spaced-name\twith tab",
		"WORKFLOW: Bad_Name — rejected",
		"WORKFLOW: good-name — duplicate dropped",
		"not a candidate line",
	}, "\n")
	got := parseCandidates(in)
	want := []string{"good-name", "spaced-name"}
	if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
		t.Errorf("parseCandidates = %v, want %v", got, want)
	}
}

func TestEvolve_RegisteredInDefaultRegistry(t *testing.T) {
	if _, ok := DefaultRegistry().Get("evolve"); !ok {
		t.Fatal("evolve not registered in DefaultRegistry")
	}
}