package session import ( "path/filepath" "testing" "time" "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) } }