39d83a33e8
PendingQuestion plus ClarifyStore: same shape, locking and expiry as SessionStore. Answer fills only the missing slots and never overwrites a filled one. No wiring yet — TODOs mark the daemon hooks. Reviewer: MaxAttempts is 1 on purpose (Maven asks once, she is not a nag). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
226 lines
6.4 KiB
Go
226 lines
6.4 KiB
Go
package dialogue
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var base = time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
|
|
|
|
func TestPendingQuestionIsExpired(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
ttl time.Duration
|
|
now time.Time
|
|
want bool
|
|
}{
|
|
{"fresh", time.Minute, base.Add(10 * time.Second), false},
|
|
{"exactly at ttl", time.Minute, base.Add(time.Minute), false},
|
|
{"past ttl", time.Minute, base.Add(2 * time.Minute), true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
q := &PendingQuestion{Asked: base, TTL: tc.ttl}
|
|
if got := q.IsExpired(tc.now); got != tc.want {
|
|
t.Fatalf("IsExpired = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClarifyStoreGetPutDelete(t *testing.T) {
|
|
s := NewClarifyStore(time.Minute)
|
|
|
|
if got := s.Get("voice", base); got != nil {
|
|
t.Fatalf("empty store returned %+v", got)
|
|
}
|
|
|
|
q := &PendingQuestion{Intent: IntentReminder, Missing: []Slot{SlotTime}, Asked: base}
|
|
s.Put("voice", q)
|
|
if q.TTL != time.Minute {
|
|
t.Fatalf("Put did not apply the default TTL, got %v", q.TTL)
|
|
}
|
|
if got := s.Get("voice", base.Add(time.Second)); got != q {
|
|
t.Fatalf("Get returned %+v, want the parked question", got)
|
|
}
|
|
|
|
// Expired questions are dropped on read, not returned.
|
|
if got := s.Get("voice", base.Add(2*time.Minute)); got != nil {
|
|
t.Fatalf("expired Get returned %+v", got)
|
|
}
|
|
if got := s.Get("voice", base); got != nil {
|
|
t.Fatalf("expired question was not deleted: %+v", got)
|
|
}
|
|
|
|
s.Put("voice", &PendingQuestion{Asked: base, TTL: time.Hour})
|
|
s.Delete("voice")
|
|
if got := s.Get("voice", base); got != nil {
|
|
t.Fatalf("Delete left %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestNewClarifyStoreDefaultTTL(t *testing.T) {
|
|
s := NewClarifyStore(0)
|
|
q := &PendingQuestion{Asked: base}
|
|
s.Put("voice", q)
|
|
if q.TTL != 90*time.Second {
|
|
t.Fatalf("TTL = %v, want 90s", q.TTL)
|
|
}
|
|
}
|
|
|
|
func TestAnswerFillsOnlyMissingSlots(t *testing.T) {
|
|
answerTime := base.Add(3 * time.Hour)
|
|
other := base.Add(9 * time.Hour)
|
|
|
|
cases := []struct {
|
|
name string
|
|
parked Slots
|
|
missing []Slot
|
|
text string
|
|
answer Slots
|
|
want Slots
|
|
}{
|
|
{
|
|
name: "fills the missing time",
|
|
parked: Slots{Text: "напомни позвонить"},
|
|
missing: []Slot{SlotTime},
|
|
text: "в три",
|
|
answer: Slots{Time: answerTime, HasTime: true},
|
|
want: Slots{Text: "напомни позвонить", Time: answerTime, HasTime: true},
|
|
},
|
|
{
|
|
name: "does not overwrite a filled time",
|
|
parked: Slots{Time: other, HasTime: true},
|
|
missing: []Slot{SlotTime},
|
|
text: "в три",
|
|
answer: Slots{Time: answerTime, HasTime: true},
|
|
want: Slots{Time: other, HasTime: true},
|
|
},
|
|
{
|
|
name: "ignores slots that were not missing",
|
|
parked: Slots{Key: "water", HasKey: true},
|
|
missing: []Slot{SlotValue},
|
|
text: "два литра",
|
|
answer: Slots{Key: "sleep", HasKey: true, Value: "2l"},
|
|
want: Slots{Key: "water", HasKey: true, Value: "2l"},
|
|
},
|
|
{
|
|
name: "fills key when empty",
|
|
parked: Slots{},
|
|
missing: []Slot{SlotKey, SlotValue},
|
|
text: "воды",
|
|
answer: Slots{Key: "water", HasKey: true, Value: `"drank"`},
|
|
want: Slots{Key: "water", HasKey: true, Value: `"drank"`},
|
|
},
|
|
{
|
|
name: "fills fn and its args",
|
|
parked: Slots{},
|
|
missing: []Slot{SlotFn},
|
|
text: "перезапусти nginx",
|
|
answer: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
},
|
|
{
|
|
name: "keeps existing args when fn was already known",
|
|
parked: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
missing: []Slot{SlotFn},
|
|
text: "останови postgres",
|
|
answer: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true},
|
|
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
},
|
|
{
|
|
name: "raw answer becomes the text when nothing was parsed",
|
|
parked: Slots{},
|
|
missing: []Slot{SlotText},
|
|
text: "купить хлеб",
|
|
answer: Slots{},
|
|
want: Slots{Text: "купить хлеб"},
|
|
},
|
|
{
|
|
name: "parsed text wins over the raw answer",
|
|
parked: Slots{},
|
|
missing: []Slot{SlotText},
|
|
text: "запиши купить хлеб",
|
|
answer: Slots{Text: "купить хлеб"},
|
|
want: Slots{Text: "купить хлеб"},
|
|
},
|
|
{
|
|
name: "empty answer leaves the slot missing",
|
|
parked: Slots{Text: "напомни"},
|
|
missing: []Slot{SlotTime},
|
|
text: "не знаю",
|
|
answer: Slots{},
|
|
want: Slots{Text: "напомни"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
q := &PendingQuestion{Slots: tc.parked, Missing: tc.missing, Asked: base}
|
|
got := q.Answer(tc.text, tc.answer)
|
|
if got.Time != tc.want.Time || got.HasTime != tc.want.HasTime ||
|
|
got.Key != tc.want.Key || got.HasKey != tc.want.HasKey ||
|
|
got.Value != tc.want.Value || got.Text != tc.want.Text ||
|
|
got.Fn != tc.want.Fn || got.HasFn != tc.want.HasFn {
|
|
t.Fatalf("Answer = %+v, want %+v", got, tc.want)
|
|
}
|
|
if len(got.Args) != len(tc.want.Args) {
|
|
t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args)
|
|
}
|
|
for i := range got.Args {
|
|
if got.Args[i] != tc.want.Args[i] {
|
|
t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCanAskCapsAtOneQuestion(t *testing.T) {
|
|
if MaxAttempts != 1 {
|
|
t.Fatalf("MaxAttempts = %d, want 1 (Maven asks once, she is not a nag)", MaxAttempts)
|
|
}
|
|
q := &PendingQuestion{Asked: base}
|
|
if !q.CanAsk() {
|
|
t.Fatal("a fresh question should be askable")
|
|
}
|
|
q.Attempts = MaxAttempts
|
|
if q.CanAsk() {
|
|
t.Fatal("the question should not be asked twice")
|
|
}
|
|
}
|
|
|
|
func TestStillMissing(t *testing.T) {
|
|
want := []Slot{SlotTime, SlotKey, SlotValue, SlotFn, SlotText}
|
|
cases := []struct {
|
|
name string
|
|
slots Slots
|
|
want []Slot
|
|
}{
|
|
{"all empty", Slots{}, want},
|
|
{
|
|
name: "all filled",
|
|
slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Value: "1l", Fn: "restart", HasFn: true, Text: "t"},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "only value left",
|
|
slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Fn: "restart", HasFn: true, Text: "t"},
|
|
want: []Slot{SlotValue},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := StillMissing(want, tc.slots)
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("StillMissing = %v, want %v", got, tc.want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != tc.want[i] {
|
|
t.Fatalf("StillMissing = %v, want %v", got, tc.want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|