Go 256 lines
package workflow
import (
"os/exec"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/memory"
)
// ymd parses a YYYY-MM-DD date into UTC midnight, panicking on a bad
// literal (test inputs are constants).
func ymd(s string) time.Time {
t, err := time.Parse("2006-01-02", s)
if err != nil {
panic(err)
}
return t
}
// seedFact writes one reference fact carrying ref and created into the
// workspace memory store.
func seedFact(t *testing.T, cfg *config.Config, name, ref string, created time.Time) {
t.Helper()
store, err := memory.Open(cfg)
if err != nil {
t.Fatal(err)
}
f := &memory.Fact{
Name: name,
Description: "desc " + name,
Type: memory.TypeReference,
Created: created,
LastUsed: created,
Ref: ref,
Body: "body",
}
if err := store.Save(f); err != nil {
t.Fatalf("seed fact %s: %v", name, err)
}
}
// stubCommitDates overrides memoryDriftCommitDate for the test: a path
// present in m resolves to its date (ok=true); a path absent from m
// resolves to ok=false (no commit history).
func stubCommitDates(t *testing.T, m map[string]time.Time) {
t.Helper()
old := memoryDriftCommitDate
memoryDriftCommitDate = func(_ string, path string) (time.Time, bool, error) {
d, ok := m[path]
return d, ok, nil
}
t.Cleanup(func() { memoryDriftCommitDate = old })
}
func TestMemoryDrift_NoFacts(t *testing.T) {
cfg := newCfg(t)
stubCommitDates(t, nil)
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("Code = %d, want %d", res.Code, CodeClean)
}
if res.Summary != "no memory facts carry a ref to check" {
t.Errorf("Summary = %q", res.Summary)
}
}
func TestMemoryDrift_RefCurrent(t *testing.T) {
cfg := newCfg(t)
seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-20"))
writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
// Same calendar day as the fact's created date — not drift.
stubCommitDates(t, map[string]time.Time{"internal/a.go": ymd("2026-05-20")})
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
}
if res.Summary != "1 memory fact(s) with a ref are current" {
t.Errorf("Summary = %q", res.Summary)
}
if q := queueBody(t, cfg); q != "" {
t.Errorf("queue should be empty, got:\n%s", q)
}
}
func TestMemoryDrift_RefChangedAfter(t *testing.T) {
cfg := newCfg(t)
seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-10"))
writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
stubCommitDates(t, map[string]time.Time{"internal/a.go": ymd("2026-05-20")})
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
}
if len(res.Findings) != 1 {
t.Fatalf("Findings = %d, want 1", len(res.Findings))
}
if res.Findings[0].Path != "internal/a.go" {
t.Errorf("Finding.Path = %q", res.Findings[0].Path)
}
q := queueBody(t, cfg)
if !strings.Contains(q, "**memory-drift**") || !strings.Contains(q, `"alpha"`) {
t.Errorf("queue missing memory-drift item for alpha:\n%s", q)
}
}
func TestMemoryDrift_RepeatedRunDedupsQueue(t *testing.T) {
cfg := newCfg(t)
seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-10"))
writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
stubCommitDates(t, map[string]time.Time{"internal/a.go": ymd("2026-05-20")})
for i := range 2 {
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
// The finding is real on every run — only the queue write dedups.
if res.Code != CodeFinding {
t.Fatalf("run %d: Code = %d, want %d (%q)", i, res.Code, CodeFinding, res.Summary)
}
}
if got := queueBody(t, cfg); strings.Count(got, "**memory-drift**") != 1 {
t.Errorf("repeated runs should queue exactly one open item:\n%s", got)
}
}
func TestMemoryDrift_RefMissingOnDisk(t *testing.T) {
cfg := newCfg(t)
// The ref file is never written — a missing ref is eeco gc's job.
seedFact(t, cfg, "alpha", "internal/gone.go", ymd("2026-05-10"))
stubCommitDates(t, map[string]time.Time{"internal/gone.go": ymd("2026-05-20")})
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
}
if res.Summary != "no memory facts carry a ref to check" {
t.Errorf("Summary = %q", res.Summary)
}
}
func TestMemoryDrift_RefUntracked(t *testing.T) {
cfg := newCfg(t)
seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-10"))
writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
// No entry in the map → ok=false → no commit history to compare.
stubCommitDates(t, map[string]time.Time{})
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
}
if res.Summary != "no memory facts carry a ref to check" {
t.Errorf("Summary = %q", res.Summary)
}
}
func TestMemoryDrift_FactWithoutRef(t *testing.T) {
cfg := newCfg(t)
seedFact(t, cfg, "alpha", "", ymd("2026-05-10"))
stubCommitDates(t, nil)
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean || res.Summary != "no memory facts carry a ref to check" {
t.Errorf("Code = %d, Summary = %q", res.Code, res.Summary)
}
}
func TestMemoryDrift_MixedFacts(t *testing.T) {
cfg := newCfg(t)
seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-10")) // stale
seedFact(t, cfg, "beta", "internal/b.go", ymd("2026-05-20")) // current
seedFact(t, cfg, "gamma", "", ymd("2026-05-01")) // no ref
writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
writeRepoFile(t, cfg.RepoRoot, "internal/b.go", "package b\n")
stubCommitDates(t, map[string]time.Time{
"internal/a.go": ymd("2026-05-20"),
"internal/b.go": ymd("2026-05-20"),
})
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
}
if len(res.Findings) != 1 {
t.Fatalf("Findings = %d, want 1: %+v", len(res.Findings), res.Findings)
}
if res.Findings[0].Path != "internal/a.go" {
t.Errorf("stale fact = %q, want internal/a.go", res.Findings[0].Path)
}
if got := queueBody(t, cfg); strings.Count(got, "**memory-drift**") != 1 {
t.Errorf("want exactly one queued item:\n%s", got)
}
}
// TestMemoryDrift_RealGit exercises the real gitx.LastCommitDate wiring
// (no stub) against an actual commit, so the integration is covered end
// to end alongside the table-driven stub cases above.
func TestMemoryDrift_RealGit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
gitInit(t, cfg.RepoRoot)
runGit(t, cfg.RepoRoot, "commit", "-q", "-m", "add a.go")
// The commit lands now; the fact claims to predate it by years.
seedFact(t, cfg, "alpha", "internal/a.go", ymd("2020-01-01"))
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
}
if len(res.Findings) != 1 || res.Findings[0].Path != "internal/a.go" {
t.Errorf("Findings = %+v", res.Findings)
}
}
func runGit(t *testing.T, root string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = root
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}