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) } } }) } }