62d320f93a
MaxAttempts was 1, justified as "not a nag". Wrong reading: "not a nag" is about interrupting unprompted, and a clarifying question is part of a conversation he started. Now three, configurable via voice.clarify_max_attempts (default 3). Three, because after that the likely problem is she misheard the whole request, not one slot. Answer used to keep the parked value, so "в три" then "нет, в пять" threw the five away. Now a value the answer carries wins for the slot she asked about. Only for the clarify answer — a correction in a fresh turn is followUpMerge. The eight-field chained assertion in the Answer test is one DeepEqual now, so a new field in Slots is covered without touching the test. 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 (
|
|
"reflect"
|
|
"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},
|
|
},
|
|
{
|
|
// He restated it: «нет, в пять». The new value wins.
|
|
name: "a restated time overwrites the parked one",
|
|
parked: Slots{Time: other, HasTime: true},
|
|
missing: []Slot{SlotTime},
|
|
text: "нет, в три",
|
|
answer: Slots{Time: answerTime, HasTime: true},
|
|
want: Slots{Time: answerTime, 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: "a restated fn replaces the fn and its args",
|
|
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: "stop", Args: []string{"postgres"}, 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}
|
|
// Whole-struct compare: a new field in Slots is covered for free.
|
|
if got := q.Answer(tc.text, tc.answer); !reflect.DeepEqual(got, tc.want) {
|
|
t.Fatalf("Answer = %+v, want %+v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCanAskAllowsThreeQuestionsByDefault(t *testing.T) {
|
|
if DefaultMaxAttempts != 3 {
|
|
t.Fatalf("DefaultMaxAttempts = %d, want 3", DefaultMaxAttempts)
|
|
}
|
|
q := &PendingQuestion{Asked: base} // MaxAttempts unset ⇒ the default
|
|
for i := 0; i < 3; i++ {
|
|
if !q.CanAsk() {
|
|
t.Fatalf("question %d should be allowed", i+1)
|
|
}
|
|
q.Attempts++
|
|
}
|
|
if q.CanAsk() {
|
|
t.Fatal("a fourth question must not be allowed")
|
|
}
|
|
}
|
|
|
|
func TestCanAskHonoursConfiguredMax(t *testing.T) {
|
|
q := &PendingQuestion{Asked: base, MaxAttempts: 1, Attempts: 1}
|
|
if q.CanAsk() {
|
|
t.Fatal("MaxAttempts 1 means one question only")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|