79c3b994cf
A nudge could only be deferred from Telegram or the web UI. The voice path had no route to store.ResolveNudge at all, so the channel she nudges on hardest was the one he could not answer out loud. resolveSnooze runs pre-route, right after the quiet toggle, and writes the same `snoozed` outcome the buttons write — which also drops the row out of RepeatUnacked, so a deferred sev4 stops re-sending every five minutes. The window is what makes this safe to run before the router. "потом" is an ordinary word; it only counts as a deferral when a pending nudge was sent in the last twenty minutes, and otherwise the turn routes normally. Single word patterns still match single-word utterances only, so "потом схожу за водой" reports a plan instead of silencing the rule that prompted it. Channel is not filtered: a nudge that went to Telegram is still what he is answering when he says "потом" at the microphone. QA-PLAN gains the two new manual checks and drops the 319 warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
116 lines
3.8 KiB
Go
116 lines
3.8 KiB
Go
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")
|
|
}
|
|
}
|