Files
Maven/cmd/mavend/reactive_notes_test.go
T
claude ed491e23fc mavend: group the recall fields into one wiring struct (V-433)
The review comment asked for basic DI. The answer is the idiom voice.go
already had for capabilities — a cohesive *Wiring struct — applied to a
group that is not a capability toggle, plus the decision written down so
it is a rule and not a habit.

recallWiring holds the embedder, the vector store, the personal boundary
and the two numbers that gate an answer. They sat in three places on
reactiveHandler, with the gate numbers a hundred lines from the store
they gate. Its zero value means no recall, so it is a value, not a
pointer like the optional-capability groups.

dataStore stays out of it. patterns.go, ecosystem_acts.go and confirm.go
use it, so it is not part of this cluster.

docs/handler-wiring.md records the choice, rejects a container or a
wire-style generator outright, defers narrow per-handler interfaces to
the package split that would justify them, and states the constraint the
task named: a wiring change does not ride a feature PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 05:57:24 +04:00

123 lines
3.7 KiB
Go

package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/voice"
)
func TestReactiveNotesReminders(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
now := time.Now()
emb := router.NewHashEmbedder(1024)
matcher := tool.NewMatcher(api)
rtr := buildRouter(emb, matcher, 0.55, nil)
h := &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: rtr,
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
}
t.Run("reminder capture", func(t *testing.T) {
fireAt := now.Add(24 * time.Hour).Truncate(time.Millisecond)
dec := router.Decision{
Intent: router.IntentReminder,
Utterance: "напомни завтра позвонить маме",
Slots: router.Slots{
Text: "завтра позвонить маме",
Time: fireAt,
HasTime: true,
},
}
reply := h.applyAction(ctx, dec)
if reply != "" {
t.Errorf("expected empty reply from applyAction, got %q", reply)
}
reminders, err := st.ListReminders(ctx, 10)
if err != nil {
t.Fatalf("ListReminders: %v", err)
}
if len(reminders) == 0 {
t.Fatal("expected at least one reminder, got none")
}
last := reminders[0]
if last.FireTs.UnixMilli() != fireAt.UnixMilli() {
t.Errorf("reminder FireTs = %v, want %v", last.FireTs, fireAt)
}
if last.Status != "pending" {
t.Errorf("reminder Status = %q, want pending", last.Status)
}
})
t.Run("note capture", func(t *testing.T) {
dec := router.Decision{
Intent: router.IntentNote,
Utterance: "запомни что кофе закончился",
}
reply := h.applyAction(ctx, dec)
if reply != "" {
t.Errorf("expected empty reply from applyAction, got %q", reply)
}
notes, err := st.RecentNotes(ctx, 10)
if err != nil {
t.Fatalf("RecentNotes: %v", err)
}
if len(notes) == 0 {
t.Fatal("expected at least one note, got none")
}
last := notes[0]
if last.Text != "запомни что кофе закончился" {
t.Errorf("note text = %q, want %q", last.Text, "запомни что кофе закончился")
}
})
}
// TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the
// task table. It went dead when the router started claiming the marker as an
// act: capture rides the note intent, so nothing below actionNote was ever
// reached and every capture answered "Что сделать?" (Vikunja #467).
func TestSpokenTaskCaptureFilesATask(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
now := time.Now()
emb := router.NewHashEmbedder(1024)
matcher := tool.NewMatcher(api)
h := &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, matcher, 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
}
reply := h.handleText(ctx, "web", "добавь в задачи купить молоко")
if !strings.Contains(reply, "купить молоко") {
t.Fatalf("capture did not claim the turn: %q", reply)
}
open, err := st.ListTasks(ctx, store.TaskOpen)
if err != nil || len(open) != 1 {
t.Fatalf("task was not filed: tasks=%v err=%v", open, err)
}
// The words he said, not the model's rewrite of them.
if open[0].Text != "купить молоко" {
t.Fatalf("task text was rewritten: %q", open[0].Text)
}
}