247 lines
7.2 KiB
Go
247 lines
7.2 KiB
Go
// 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)
|
|
}
|
|
}
|