package harness import ( "context" "encoding/json" "sync" "testing" "time" "keel/internal/evidence" "keel/internal/mode" ) // fakeMode implements Mode and (optionally) the capability interfaces. type fakeMode struct { mu sync.Mutex kind string active bool cmds []string windows int deadline time.Time expired bool evidence bool // implements EvidenceConsumer when true expirer bool // implements Expirer when true } func (f *fakeMode) Kind() string { return f.kind } func (f *fakeMode) View() any { return map[string]bool{"active": f.active} } func (f *fakeMode) Active() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.active } func (f *fakeMode) Command(_ context.Context, name string, _ json.RawMessage) error { f.mu.Lock() defer f.mu.Unlock() f.cmds = append(f.cmds, name) if name == "finish" { f.active = false } return nil } type evidenceMode struct{ *fakeMode } func (e evidenceMode) OnWindow(evidence.WindowSnapshot) { e.mu.Lock(); e.windows++; e.mu.Unlock() } type expirerMode struct{ *fakeMode } func (e expirerMode) Deadline() time.Time { return e.deadline } func (e expirerMode) Expire() error { e.mu.Lock() e.expired = true e.active = false e.mu.Unlock() return nil } func newHarness(m mode.Mode) *Harness { return New(Services{Clock: time.Now}, map[string]Factory{ m.Kind(): func(Services) mode.Mode { return m }, }) } func TestStartSingleActiveInvariant(t *testing.T) { h := New(Services{}, map[string]Factory{ "a": func(Services) mode.Mode { return &fakeMode{kind: "a", active: true} }, "b": func(Services) mode.Mode { return &fakeMode{kind: "b", active: true} }, }) if err := h.Start("a"); err != nil { t.Fatalf("Start a: %v", err) } if err := h.Start("a"); err != nil { t.Fatalf("re-Start a should be idempotent: %v", err) } if err := h.Start("b"); err != ErrBusy { t.Fatalf("Start b while a active = %v, want ErrBusy", err) } if got := h.State().ActiveMode; got != "a" { t.Fatalf("ActiveMode = %q, want a", got) } } func TestCommandReleasesWhenInactive(t *testing.T) { m := &fakeMode{kind: "a", active: true} h := newHarness(m) _ = h.Start("a") if err := h.Command(context.Background(), "finish", nil); err != nil { t.Fatalf("Command: %v", err) } if got := h.State().ActiveMode; got != "" { t.Fatalf("after finish ActiveMode = %q, want idle", got) } } func TestRecordWindowOnlyToEvidenceConsumers(t *testing.T) { plain := &fakeMode{kind: "plain", active: true} h := newHarness(plain) _ = h.Start("plain") h.RecordWindow(evidence.WindowSnapshot{}) // must be a no-op, no panic if plain.windows != 0 { t.Fatalf("plain mode received %d windows, want 0", plain.windows) } ev := evidenceMode{&fakeMode{kind: "ev", active: true}} h2 := newHarness(ev) _ = h2.Start("ev") h2.RecordWindow(evidence.WindowSnapshot{}) if ev.windows != 1 { t.Fatalf("evidence mode received %d windows, want 1", ev.windows) } } func TestExpiryOnlyForExpirers(t *testing.T) { plain := &fakeMode{kind: "plain", active: true} h := newHarness(plain) _ = h.Start("plain") if err := h.Expire(); err != ErrIdle { t.Fatalf("Expire on non-expirer = %v, want ErrIdle", err) } if !h.Deadline().IsZero() { t.Fatal("non-expirer Deadline should be zero") } ex := expirerMode{&fakeMode{kind: "ex", active: true, deadline: time.Unix(100, 0)}} h2 := newHarness(ex) _ = h2.Start("ex") if got := h2.Deadline(); !got.Equal(time.Unix(100, 0)) { t.Fatalf("Deadline = %v, want unix 100", got) } if err := h2.Expire(); err != nil { t.Fatalf("Expire: %v", err) } if got := h2.State().ActiveMode; got != "" { t.Fatalf("after Expire ActiveMode = %q, want idle", got) } } func TestCommandWhileIdle(t *testing.T) { h := New(Services{}, map[string]Factory{}) if err := h.Command(context.Background(), "x", nil); err != ErrIdle { t.Fatalf("Command while idle = %v, want ErrIdle", err) } } func TestServicesNotifyReachesOnChangeListeners(t *testing.T) { h := New(Services{}, map[string]Factory{}) fired := make(chan struct{}, 1) h.AddOnChange(func() { fired <- struct{}{} }) svc := h.Services() if svc.Notify == nil { t.Fatal("Services().Notify is nil; restore path would not propagate updates") } svc.Notify() // simulate a restored mode's async completion firing notify select { case <-fired: case <-time.After(time.Second): t.Fatal("Services().Notify did not reach the registered onChange listener") } }