M1: controller evidence stats, accumulation, replay, audit-at-End
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"antidrift/internal/domain"
|
||||
"antidrift/internal/evidence"
|
||||
"antidrift/internal/store"
|
||||
)
|
||||
|
||||
func newTestController(t *testing.T) (*Controller, string) {
|
||||
@@ -105,3 +107,131 @@ func TestRestoredCommitmentKeepsDeadline(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user