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) } } // TestClarifyStoreTakeExpired — TakeExpired reports (and drops) only a question // whose TTL ran out. func TestClarifyStoreTakeExpired(t *testing.T) { s := NewClarifyStore(time.Minute) if s.TakeExpired("voice", base) { t.Fatal("nothing parked ⇒ nothing expired") } s.Put("voice", &PendingQuestion{Missing: []Slot{SlotTime}, Asked: base, TTL: time.Minute}) if s.TakeExpired("voice", base.Add(30*time.Second)) { t.Fatal("a live question must not report as expired") } if s.Get("voice", base.Add(30*time.Second)) == nil { t.Fatal("a live question must survive TakeExpired") } if !s.TakeExpired("voice", base.Add(2*time.Minute)) { t.Fatal("a stale question must report as expired") } if s.TakeExpired("voice", base.Add(2*time.Minute)) { t.Fatal("TakeExpired must drop the question, so the second call is false") } } 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) } } }) } }