// internal/ambient/sentinel_test.go package ambient import ( "context" "testing" "time" "keel/internal/evidence" "keel/internal/memory" ) func snap(title, class string) evidence.WindowSnapshot { return evidence.WindowSnapshot{Title: title, Class: class, Health: evidence.EvidenceHealth{Available: true}} } func TestNewLineEmpty(t *testing.T) { s := New(Deps{}, 0, "notify") if s.Line() != "" { t.Fatalf("fresh sentinel line = %q, want empty", s.Line()) } } func TestOnWindowRingDedupesAndCaps(t *testing.T) { s := New(Deps{}, 0, "notify") s.OnWindow(snap("a", "x")) s.OnWindow(snap("a", "x")) // consecutive dup ignored s.OnWindow(snap("b", "x")) if got := s.recentForTest(); len(got) != 2 || got[0] != "a" || got[1] != "b" { t.Fatalf("ring = %v, want [a b]", got) } for i := 0; i < recentTitlesMax+5; i++ { s.OnWindow(snap(string(rune('A'+i)), "x")) } if got := s.recentForTest(); len(got) != recentTitlesMax { t.Fatalf("ring len = %d, want %d", len(got), recentTitlesMax) } } func TestOnWindowSkipsEmptyTitle(t *testing.T) { s := New(Deps{}, 0, "notify") s.OnWindow(snap("", "x")) if got := s.recentForTest(); len(got) != 0 { t.Fatalf("empty title must not enter ring, got %v", got) } } var _ = context.Background // --- helpers --- type fakeJudge struct { out []string // queued returns, consumed front-to-back err error calls int } func (f *fakeJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) { f.calls++ if f.err != nil { return "", f.err } if len(f.out) == 0 { return "", nil } r := f.out[0] f.out = f.out[1:] return r, nil } type fakeNotifier struct { calls int body string } func (f *fakeNotifier) Notify(_ context.Context, _, body string) error { f.calls++ f.body = body return nil } func newTestSentinel(j *fakeJudge, n *fakeNotifier, mem *memoryFake, mode string, active *string) *Sentinel { return New(Deps{ AI: j, Notifier: n, Memory: mem, Clock: func() time.Time { return time.Unix(1_700_000_000, 0) }, ActiveMode: func() string { return *active }, }, time.Minute, mode) } // memoryFake is a minimal memory.Store recorder. type memoryFake struct { events []memory.Event } func (m *memoryFake) Record(_ context.Context, e memory.Event) error { m.events = append(m.events, e) return nil } func (m *memoryFake) Recent(context.Context, string, int) ([]memory.Event, error) { return nil, nil } // --- behavioural matrix --- func TestEvaluateOnTrackIsSilent(t *testing.T) { j := &fakeJudge{out: []string{""}} n := &fakeNotifier{} mem := &memoryFake{} active := "" s := newTestSentinel(j, n, mem, modeNotify, &active) s.OnWindow(snap("main.go - code", "code")) s.evaluate(context.Background()) if s.Line() != "" || n.calls != 0 || len(mem.events) != 0 { t.Fatalf("on-track must be silent: line=%q toasts=%d mem=%d", s.Line(), n.calls, len(mem.events)) } } func TestEvaluateSingleDriftShowsLineNoToast(t *testing.T) { j := &fakeJudge{out: []string{"deep in YouTube"}} n := &fakeNotifier{} mem := &memoryFake{} active := "" s := newTestSentinel(j, n, mem, modeNotify, &active) s.OnWindow(snap("YouTube - Brave", "Brave")) s.evaluate(context.Background()) if s.Line() == "" { t.Fatal("first drift must set the status line") } if n.calls != 0 { t.Fatalf("first drift must NOT toast, got %d", n.calls) } if len(mem.events) != 0 { t.Fatalf("first drift must not record memory yet, got %d", len(mem.events)) } } func TestEvaluateSustainedDriftTostsOnceAndRecords(t *testing.T) { j := &fakeJudge{out: []string{"deep in YouTube", "still in YouTube", "yet more YouTube"}} n := &fakeNotifier{} mem := &memoryFake{} active := "" s := newTestSentinel(j, n, mem, modeNotify, &active) s.OnWindow(snap("YouTube - Brave", "Brave")) s.evaluate(context.Background()) // first: line, no toast s.evaluate(context.Background()) // second: sustained -> one toast + one memory s.evaluate(context.Background()) // third: still drift -> NO second toast if n.calls != 1 { t.Fatalf("sustained drift must toast exactly once, got %d", n.calls) } if len(mem.events) != 1 { t.Fatalf("sustained drift must record exactly one ambient_nudge, got %d", len(mem.events)) } if mem.events[0].Kind != kindAmbientNudge { t.Fatalf("memory kind = %q, want %q", mem.events[0].Kind, kindAmbientNudge) } } func TestEvaluateReturnsToOnTrackClearsLine(t *testing.T) { j := &fakeJudge{out: []string{"drift", "drift", ""}} n := &fakeNotifier{} active := "" s := newTestSentinel(j, n, &memoryFake{}, modeNotify, &active) s.OnWindow(snap("YouTube - Brave", "Brave")) s.evaluate(context.Background()) s.evaluate(context.Background()) s.evaluate(context.Background()) // on-track now if s.Line() != "" { t.Fatalf("return to on-track must clear the line, got %q", s.Line()) } } func TestEvaluateSilentWhenModeActive(t *testing.T) { j := &fakeJudge{out: []string{"drift"}} active := "focus" s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active) s.OnWindow(snap("YouTube - Brave", "Brave")) s.evaluate(context.Background()) if j.calls != 0 { t.Fatalf("an active mode must suppress the brain call, got %d", j.calls) } if s.Line() != "" { t.Fatalf("an active mode must keep the line empty, got %q", s.Line()) } } func TestEvaluateStatusModeNeverToasts(t *testing.T) { j := &fakeJudge{out: []string{"drift", "drift"}} n := &fakeNotifier{} mem := &memoryFake{} active := "" s := newTestSentinel(j, n, mem, modeStatus, &active) s.OnWindow(snap("YouTube - Brave", "Brave")) s.evaluate(context.Background()) s.evaluate(context.Background()) if n.calls != 0 { t.Fatalf("status mode must never toast, got %d", n.calls) } if s.Line() == "" { t.Fatal("status mode must still show the line") } if len(mem.events) != 1 { t.Fatalf("status mode must still record the sustained drift, got %d", len(mem.events)) } } func TestEvaluateOffModeDoesNothing(t *testing.T) { j := &fakeJudge{out: []string{"drift"}} active := "" s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeOff, &active) s.OnWindow(snap("YouTube - Brave", "Brave")) s.evaluate(context.Background()) if j.calls != 0 { t.Fatalf("off mode must not call the brain, got %d", j.calls) } } func TestEvaluateCheapGateSkipsUnchangedOnTrack(t *testing.T) { j := &fakeJudge{out: []string{""}} // one on-track answer available active := "" s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active) s.OnWindow(snap("main.go - code", "code")) s.evaluate(context.Background()) // calls=1, on-track s.evaluate(context.Background()) // unchanged ring + on-track -> skip if j.calls != 1 { t.Fatalf("cheap gate must skip the second call, got %d", j.calls) } } func TestSnoozeMutesAndClears(t *testing.T) { j := &fakeJudge{out: []string{"drift", "drift"}} active := "" s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active) s.OnWindow(snap("YouTube - Brave", "Brave")) s.evaluate(context.Background()) // line set s.Snooze(time.Hour) if s.Line() != "" { t.Fatalf("snooze must clear the line, got %q", s.Line()) } callsBefore := j.calls s.evaluate(context.Background()) // snoozed -> no call if j.calls != callsBefore { t.Fatalf("snooze must suppress evaluation, calls %d -> %d", callsBefore, j.calls) } }