Files
kami d0d0021659 mavend: let a fact close the nudge that asked for it
The snooze wire had one end. "готово" and "выпил воды" both left the nudge
pending, so the auto-tuner only ever learned from deferrals and from
silence — never from the rule working.

Two entry points, because the two utterances are different acts. A bare
"готово" carries no content and is intercepted before the router, sharing
pendingNudge and the twenty-minute window with the snooze. "выпил воды" IS
content: it routes normally, writes its fact, and only then closes the
nudge (ackFromFact, after applyAction). Folding the second into a pre-route
intercept would have thrown away the thing he actually said.

ackFromFact is silent. The fact reply stands; "отлично, отметила" on top
would be her congratulating him for obeying, which is the nag she is not.

Which fact answers which rule comes from the rule's own InertWhenNoData, so
a rule added later is covered the day it lands. "да" and "ок" stay out of
the ack vocabulary: the clarify gate upstream has the stronger claim on
them, and a stray "да" must not rewrite the feedback signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 21:43:28 +04:00

110 lines
3.5 KiB
Go

package main
import (
"context"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
func TestClassifyAck(t *testing.T) {
for _, s := range []string{
"готово", "сделал", "сделано", "выполнил", "уже",
"уже сделал", "всё сделал", "done",
} {
if !classifyAck(s) {
t.Errorf("classifyAck(%q) = false, want true", s)
}
}
for _, s := range []string{
// "да" and "ок" belong to the clarify gate, not to the nudge.
"да", "ок", "хорошо",
// A single-word pattern must not eat the sentence it appears in.
"сделал бэкап базы", "готово ли обновление", "уже поздно",
"напомни завтра позвонить маме", "",
} {
if classifyAck(s) {
t.Errorf("classifyAck(%q) = true, want false", s)
}
}
}
func TestResolveAckMarksTheNudgeActed(t *testing.T) {
h, api := snoozeHandler([]ipc.Nudge{pendingNudgeAt(6, 2*time.Minute)})
reply, handled := h.resolveAck(context.Background(), "готово", sourceVoice)
if !handled || reply == "" {
t.Fatalf("got (%q, %v), want a reply", reply, handled)
}
if api.gotID != 6 || api.gotOutcome != store.NudgeActed {
t.Fatalf("resolved (%d, %q), want (6, %q)", api.gotID, api.gotOutcome, store.NudgeActed)
}
}
func TestResolveAckFallsThroughWithNothingPending(t *testing.T) {
h, api := snoozeHandler(nil)
if reply, handled := h.resolveAck(context.Background(), "готово", sourceVoice); 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 TestAckRulesForKey(t *testing.T) {
cases := []struct {
key string
want string // "" means no rule
}{
{"water", "water"},
{"meal", "meal"},
{"break", "break"},
{"desk_active", "break"},
{"weight", ""},
{"", ""},
}
for _, tc := range cases {
got := ackRulesForKey(tc.key)
if tc.want == "" {
if len(got) != 0 {
t.Errorf("ackRulesForKey(%q) = %v, want none", tc.key, got)
}
continue
}
if !got[tc.want] {
t.Errorf("ackRulesForKey(%q) = %v, want %q in it", tc.key, got, tc.want)
}
}
}
func TestAckFromFactClosesTheMatchingNudge(t *testing.T) {
h, api := snoozeHandler([]ipc.Nudge{pendingNudgeAt(11, time.Minute)}) // rule "water"
h.ackFromFact(context.Background(), router.Decision{
Intent: router.IntentFact,
Slots: router.Slots{Key: "water", HasKey: true},
})
if api.gotID != 11 || api.gotOutcome != store.NudgeActed {
t.Fatalf("resolved (%d, %q), want (11, %q)", api.gotID, api.gotOutcome, store.NudgeActed)
}
}
func TestAckFromFactIgnoresAnUnrelatedFact(t *testing.T) {
// The live nudge is "water"; a meal fact does not answer it. Closing it
// anyway would tell the auto-tuner the water rule works when he ignored it.
h, api := snoozeHandler([]ipc.Nudge{pendingNudgeAt(12, time.Minute)})
for _, dec := range []router.Decision{
{Intent: router.IntentFact, Slots: router.Slots{Key: "meal", HasKey: true}},
{Intent: router.IntentFact, Slots: router.Slots{Key: "weight", HasKey: true}},
{Intent: router.IntentFact}, // no key
{Intent: router.IntentQuery, Slots: router.Slots{Key: "water", HasKey: true}},
{Intent: router.IntentFact, Slots: router.Slots{Key: "water", HasKey: true}, Clarify: true},
} {
h.ackFromFact(context.Background(), dec)
}
if api.calls != 0 {
t.Fatalf("resolved %d nudge(s) on unrelated decisions", api.calls)
}
}