package main import ( "context" "testing" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/store" ) // snoozeFakeAPI serves a fixed nudge list and records the resolution. type snoozeFakeAPI struct { ipc.UnimplementedCoreAPI nudges []ipc.Nudge gotID int64 gotOutcome string calls int } func (a *snoozeFakeAPI) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) { return a.nudges, nil } func (a *snoozeFakeAPI) ResolveNudge(_ context.Context, id int64, outcome string, _ time.Time) error { a.gotID, a.gotOutcome, a.calls = id, outcome, a.calls+1 return nil } var snoozeNow = time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) func snoozeHandler(nudges []ipc.Nudge) (*reactiveHandler, *snoozeFakeAPI) { api := &snoozeFakeAPI{nudges: nudges} return &reactiveHandler{api: api, now: func() time.Time { return snoozeNow }}, api } func pendingNudgeAt(id int64, ago time.Duration) ipc.Nudge { return ipc.Nudge{ID: id, Ts: snoozeNow.Add(-ago), Rule: "water", Channel: "voice", Outcome: store.NudgePending} } func TestClassifySnooze(t *testing.T) { yes := []string{ "не сейчас", "потом", "позже", "попозже", "отложи", "погоди", "напомни позже", "напомни потом", "не могу сейчас", "not now", "later", "snooze", } for _, s := range yes { if !classifySnooze(s) { t.Errorf("classifySnooze(%q) = false, want true", s) } } no := []string{ // A single-word pattern must not eat the sentence it appears in. "потом схожу за водой", "позже посмотрю что там с бэкапом", "напомни завтра позвонить маме", "какая погода", "погода на завтра", "я отложил деньги", "", "тихий режим", } for _, s := range no { if classifySnooze(s) { t.Errorf("classifySnooze(%q) = true, want false", s) } } } func TestResolveSnoozeDefersTheNewestPendingNudge(t *testing.T) { h, api := snoozeHandler([]ipc.Nudge{ {ID: 9, Ts: snoozeNow.Add(-time.Minute), Rule: "meal", Outcome: store.NudgeActed}, pendingNudgeAt(8, 3*time.Minute), pendingNudgeAt(7, 10*time.Minute), }) reply, handled := h.resolveSnooze(context.Background(), "не сейчас", sourceVoice) if !handled || reply == "" { t.Fatalf("got (%q, %v), want a reply", reply, handled) } if api.gotID != 8 || api.gotOutcome != store.NudgeSnoozed { t.Fatalf("resolved (%d, %q), want (8, %q)", api.gotID, api.gotOutcome, store.NudgeSnoozed) } } func TestResolveSnoozeFallsThroughWithNothingPending(t *testing.T) { // The whole point of the window: with no live nudge, "потом" is just a // word and must keep routing. for _, name := range []string{"stale", "resolved", "empty"} { var nudges []ipc.Nudge switch name { case "stale": nudges = []ipc.Nudge{pendingNudgeAt(3, snoozeWindow+time.Minute)} case "resolved": nudges = []ipc.Nudge{{ID: 4, Ts: snoozeNow, Rule: "water", Outcome: store.NudgeActed}} } t.Run(name, func(t *testing.T) { h, api := snoozeHandler(nudges) reply, handled := h.resolveSnooze(context.Background(), "потом", sourceVoice) if handled || reply != "" { t.Fatalf("got (%q, %v), want fall-through", reply, handled) } if api.calls != 0 { t.Fatalf("resolved a nudge with nothing pending") } }) } } func TestResolveSnoozeIgnoresAFutureNudge(t *testing.T) { // Clock skew between the tick and the turn must not let a send from the // future be answered before it happened. h, api := snoozeHandler([]ipc.Nudge{pendingNudgeAt(5, -time.Minute)}) if _, handled := h.resolveSnooze(context.Background(), "потом", sourceVoice); handled { t.Fatalf("snoozed a nudge dated in the future") } if api.calls != 0 { t.Fatalf("resolved a future nudge") } }