9145b83100
The router can already say "I am not sure" (Decision.Clarify) but the daemon had nowhere to keep the request while it asked. PendingQuestion holds the original slots, ClarifyStore parks one per dialogue id with a 90s TTL, and Answer fills only the slots that were missing so an answer can never rewrite what she already understood. Logic that uses this comes next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
94 lines
2.6 KiB
Go
94 lines
2.6 KiB
Go
package dialogue
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var clarifyNow = time.Date(2026, 7, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
func TestPendingQuestionAnswerFillsOnlyMissing(t *testing.T) {
|
|
fireAt := clarifyNow.Add(time.Hour)
|
|
q := &PendingQuestion{
|
|
Intent: IntentReminder,
|
|
Slots: Slots{Key: "mom", HasKey: true, Text: "напомни позвонить маме"},
|
|
Missing: []Slot{SlotTime},
|
|
}
|
|
got := q.Answer("в 11", Slots{Time: fireAt, HasTime: true, Key: "other", HasKey: true})
|
|
if !got.HasTime || !got.Time.Equal(fireAt) {
|
|
t.Fatalf("missing time slot not filled: %+v", got)
|
|
}
|
|
if got.Key != "mom" {
|
|
t.Fatalf("answer overwrote a filled slot: key=%q", got.Key)
|
|
}
|
|
if got.Text != "напомни позвонить маме" {
|
|
t.Fatalf("answer overwrote the original text: %q", got.Text)
|
|
}
|
|
}
|
|
|
|
func TestPendingQuestionAnswerKeepsGapWhenAnswerIsEmpty(t *testing.T) {
|
|
q := &PendingQuestion{Intent: IntentReminder, Missing: []Slot{SlotTime}}
|
|
got := q.Answer("не знаю", Slots{})
|
|
if got.HasTime {
|
|
t.Fatal("empty answer must not fill the slot")
|
|
}
|
|
if len(StillMissing(q.Missing, got)) != 1 {
|
|
t.Fatal("StillMissing should report the unfilled slot")
|
|
}
|
|
}
|
|
|
|
func TestStillMissing(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
want []Slot
|
|
slots Slots
|
|
left int
|
|
}{
|
|
{"all filled", []Slot{SlotTime, SlotKey}, Slots{HasTime: true, HasKey: true}, 0},
|
|
{"time gap", []Slot{SlotTime}, Slots{HasKey: true}, 1},
|
|
{"fn gap", []Slot{SlotFn}, Slots{}, 1},
|
|
{"text filled", []Slot{SlotText}, Slots{Text: "hi"}, 0},
|
|
{"nothing wanted", nil, Slots{}, 0},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := StillMissing(tc.want, tc.slots); len(got) != tc.left {
|
|
t.Errorf("%s: got %v, want %d left", tc.name, got, tc.left)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClarifyStoreExpiry(t *testing.T) {
|
|
s := NewClarifyStore(90 * time.Second)
|
|
s.Put("voice", &PendingQuestion{Asked: clarifyNow, Missing: []Slot{SlotTime}})
|
|
|
|
if s.Get("voice", clarifyNow.Add(30*time.Second)) == nil {
|
|
t.Fatal("question inside the TTL should be live")
|
|
}
|
|
if s.Get("voice", clarifyNow.Add(2*time.Minute)) != nil {
|
|
t.Fatal("question past the TTL should be dropped")
|
|
}
|
|
if s.Get("voice", clarifyNow) != nil {
|
|
t.Fatal("an expired question must be deleted on read, not linger")
|
|
}
|
|
}
|
|
|
|
func TestClarifyStoreDefaultTTL(t *testing.T) {
|
|
s := NewClarifyStore(0)
|
|
q := &PendingQuestion{Asked: clarifyNow}
|
|
s.Put("voice", q)
|
|
if q.TTL != 90*time.Second {
|
|
t.Fatalf("default TTL not applied: %v", q.TTL)
|
|
}
|
|
}
|
|
|
|
func TestCanAskOnce(t *testing.T) {
|
|
q := &PendingQuestion{}
|
|
if !q.CanAsk() {
|
|
t.Fatal("a fresh question should be askable")
|
|
}
|
|
q.Attempts = MaxAttempts
|
|
if q.CanAsk() {
|
|
t.Fatal("she must not ask twice")
|
|
}
|
|
}
|