package session import ( "context" "errors" "path/filepath" "testing" "time" "antidrift/internal/ai" "antidrift/internal/domain" "antidrift/internal/evidence" "antidrift/internal/store" ) func newTestController(t *testing.T) (*Controller, string) { t.Helper() path := filepath.Join(t.TempDir(), "state.json") c, err := New(path) if err != nil { t.Fatalf("new controller: %v", err) } return c, path } func TestHappyPathDrivesStates(t *testing.T) { c, _ := newTestController(t) if c.State().RuntimeState != domain.RuntimeLocked { t.Fatalf("should start Locked") } if err := c.EnterPlanning(); err != nil { t.Fatalf("enter planning: %v", err) } if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute); err != nil { t.Fatalf("start commitment: %v", err) } st := c.State() if st.RuntimeState != domain.RuntimeActive { t.Fatalf("should be Active, got %s", st.RuntimeState) } if st.Commitment == nil || st.Commitment.NextAction != "Port session" { t.Fatalf("active commitment missing: %+v", st.Commitment) } if st.Commitment.DeadlineUnixSecs <= time.Now().Unix() { t.Fatalf("deadline should be in the future") } if err := c.Complete(); err != nil { t.Fatalf("complete: %v", err) } if c.State().RuntimeState != domain.RuntimeReview { t.Fatalf("should be Review after complete") } if err := c.End(); err != nil { t.Fatalf("end: %v", err) } if c.State().RuntimeState != domain.RuntimeLocked { t.Fatalf("should be Locked after end") } } func TestStartCommitmentRejectsInvalidInput(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning() if err := c.StartManualCommitment("", "x", 25*time.Minute); err == nil { t.Fatalf("empty next action should error") } if c.State().RuntimeState != domain.RuntimePlanning { t.Fatalf("failed start must not change state, got %s", c.State().RuntimeState) } } func TestStateRestoresFromSnapshot(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") first, err := New(path) if err != nil { t.Fatalf("new: %v", err) } _ = first.EnterPlanning() _ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute) second, err := New(path) if err != nil { t.Fatalf("reopen: %v", err) } st := second.State() if st.RuntimeState != domain.RuntimeActive { t.Fatalf("restored state should be Active, got %s", st.RuntimeState) } if st.Commitment == nil || st.Commitment.NextAction != "Persisted action" { t.Fatalf("restored commitment missing: %+v", st.Commitment) } } func TestRestoredCommitmentKeepsDeadline(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") first, err := New(path) if err != nil { t.Fatalf("new: %v", err) } _ = first.EnterPlanning() _ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute) want := first.State().Commitment.DeadlineUnixSecs second, err := New(path) if err != nil { t.Fatalf("reopen: %v", err) } got := second.State().Commitment if got == nil || got.DeadlineUnixSecs != want { t.Fatalf("restored deadline: got %+v want %d", got, want) } } // fakeClock returns successive instants on demand. type fakeClock struct{ now time.Time } func (f *fakeClock) advance(d time.Duration) { f.now = f.now.Add(d) } func (f *fakeClock) fn() func() time.Time { return func() time.Time { return f.now } } func snap(class, title string) evidence.WindowSnapshot { return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}} } func bucketSeconds(t *testing.T, st State, class, title string) int64 { t.Helper() if st.Evidence == nil { t.Fatalf("expected evidence projection") } for _, b := range st.Evidence.Buckets { if b.Class == class && b.Title == title { return b.Seconds } } return -1 } func TestAccumulatesBucketsAndSwitches(t *testing.T) { c, _ := newTestController(t) clk := &fakeClock{now: time.Unix(1000, 0)} c.SetClock(clk.fn()) c.RecordWindow(snap("code", "antidrift")) // latest window before start _ = c.EnterPlanning() _ = c.StartManualCommitment("work", "done", 25*time.Minute) // seeds at t=1000 with code/antidrift clk.advance(10 * time.Second) c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox clk.advance(20 * time.Second) c.RecordWindow(snap("code", "antidrift")) // credits 20s to firefox/docs, switch back st := c.State() if got := bucketSeconds(t, st, "code", "antidrift"); got != 10 { t.Errorf("code bucket = %d, want 10", got) } if got := bucketSeconds(t, st, "firefox", "docs"); got != 20 { t.Errorf("firefox bucket = %d, want 20", got) } if st.Evidence.SwitchCount != 2 { t.Errorf("switch count = %d, want 2", st.Evidence.SwitchCount) } } func TestNoAccountingOutsideActive(t *testing.T) { c, _ := newTestController(t) clk := &fakeClock{now: time.Unix(1000, 0)} c.SetClock(clk.fn()) // Locked: RecordWindow must not create stats. c.RecordWindow(snap("code", "x")) if c.State().Evidence != nil { t.Fatalf("no session: evidence should be nil") } } func TestUnavailableAccruesToReservedBucket(t *testing.T) { c, _ := newTestController(t) clk := &fakeClock{now: time.Unix(1000, 0)} c.SetClock(clk.fn()) _ = c.EnterPlanning() _ = c.StartManualCommitment("work", "done", 25*time.Minute) // seed: empty unavailable latestWindow // latestWindow was zero-value (unavailable) at seed time. clk.advance(5 * time.Second) c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket st := c.State() if got := bucketSeconds(t, st, "", "(evidence unavailable)"); got != 5 { t.Errorf("unavailable bucket = %d, want 5", got) } } func TestCrashReplayRebuildsStats(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") first, _ := New(path) clk := &fakeClock{now: time.Unix(1000, 0)} first.SetClock(clk.fn()) first.RecordWindow(snap("code", "antidrift")) _ = first.EnterPlanning() _ = first.StartManualCommitment("work", "done", 25*time.Minute) clk.advance(15 * time.Second) first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch // Simulate crash + restart: New replays the raw log. second, err := New(path) if err != nil { t.Fatalf("reopen: %v", err) } st := second.State() if st.RuntimeState != domain.RuntimeActive { t.Fatalf("restored state should be Active, got %s", st.RuntimeState) } if got := bucketSeconds(t, st, "code", "antidrift"); got != 15 { t.Errorf("replayed code bucket = %d, want 15", got) } if st.Evidence.SwitchCount != 1 { t.Errorf("replayed switch count = %d, want 1", st.Evidence.SwitchCount) } } func TestEndWritesAuditSummary(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "state.json") c, _ := New(path) clk := &fakeClock{now: time.Unix(1000, 0)} c.SetClock(clk.fn()) c.RecordWindow(snap("code", "antidrift")) _ = c.EnterPlanning() _ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute) clk.advance(30 * time.Second) c.RecordWindow(snap("firefox", "docs")) clk.advance(10 * time.Second) if err := c.Complete(); err != nil { // flush: +10s to firefox/docs t.Fatalf("complete: %v", err) } if err := c.End(); err != nil { t.Fatalf("end: %v", err) } auditPath := filepath.Join(dir, "audit.jsonl") if err := store.VerifyChain(auditPath); err != nil { t.Fatalf("chain should verify: %v", err) } } type fakeCoach struct { prop ai.Proposal err error gate chan struct{} // if non-nil, Coach blocks until it receives } func (f *fakeCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) { if f.gate != nil { <-f.gate } return f.prop, f.err } // waitCoachStatus polls until the coach view reaches want, or fails after 2s. func waitCoachStatus(t *testing.T, c *Controller, want string) State { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { st := c.State() if st.Coach != nil && st.Coach.Status == want { return st } time.Sleep(5 * time.Millisecond) } t.Fatalf("coach status never reached %q (last: %+v)", want, c.State().Coach) return State{} } func TestRequestCoachReady(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning() c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "X done", TimeboxSecs: 1500}}) if err := c.RequestCoach("do x"); err != nil { t.Fatalf("request coach: %v", err) } st := waitCoachStatus(t, c, "ready") if st.Coach.Proposal == nil || st.Coach.Proposal.NextAction != "Do X" || st.Coach.Proposal.TimeboxSecs != 1500 { t.Fatalf("ready proposal wrong: %+v", st.Coach) } } func TestRequestCoachError(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning() c.SetCoach(&fakeCoach{err: errors.New("backend down")}) if err := c.RequestCoach("x"); err != nil { t.Fatalf("request: %v", err) } st := waitCoachStatus(t, c, "error") if st.Coach.Error == "" { t.Fatal("error status should carry a message") } } func TestRequestCoachUnavailable(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning() if err := c.RequestCoach("x"); err != nil { t.Fatalf("nil coach must degrade, not error: %v", err) } st := c.State() if st.Coach == nil || st.Coach.Status != "error" { t.Fatalf("want error status for nil coach, got %+v", st.Coach) } } func TestRequestCoachWrongState(t *testing.T) { c, _ := newTestController(t) c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}}) if err := c.RequestCoach("x"); err == nil { t.Fatal("coaching from Locked should error") } if c.State().Coach != nil { t.Fatal("no coach view outside planning") } } func TestRequestCoachStaleResultDiscarded(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning() slow := &fakeCoach{prop: ai.Proposal{NextAction: "OLD", SuccessCondition: "old", TimeboxSecs: 60}, gate: make(chan struct{})} c.SetCoach(slow) _ = c.RequestCoach("first") // gen1 pending, goroutine blocks on gate fast := &fakeCoach{prop: ai.Proposal{NextAction: "NEW", SuccessCondition: "new", TimeboxSecs: 120}} c.SetCoach(fast) _ = c.RequestCoach("second") // gen2 -> ready NEW st := waitCoachStatus(t, c, "ready") if st.Coach.Proposal.NextAction != "NEW" { t.Fatalf("expected NEW, got %+v", st.Coach.Proposal) } close(slow.gate) // release gen1; it must be discarded time.Sleep(50 * time.Millisecond) if got := c.State().Coach.Proposal.NextAction; got != "NEW" { t.Fatalf("stale gen1 overwrote state: %q", got) } } func TestLeavingPlanningClearsCoach(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning() c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}}) _ = c.RequestCoach("x") waitCoachStatus(t, c, "ready") if err := c.StartManualCommitment("a", "b", 25*time.Minute); err != nil { t.Fatalf("start: %v", err) } if c.State().Coach != nil { t.Fatal("coach view must be gone once Active") } }