9ed259660d
The parseable twin of the owner's transcript goes green and loses its skip: Rome is answered, the question survives the side query on the same attempt, and the answer after it completes the reminder he actually asked for. His transcript verbatim stays skipped, and V-561 was never going to unskip it. What is left there is the parser — StubDateTimeParser reads neither "на 9" nor "на завтра", so the third turn lands as an answer that filled nothing. The skip reason now names V-543 and V-562 instead of this task. Two V-560 tests asserted the drop notice and now assert the suspend: nothing says a request was let go, the reply ends with the resumed question, and the parked question is still there on attempt 1 with what it was about intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
7.3 KiB
Go
248 lines
7.3 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)
|
|
}
|
|
}
|
|
|
|
// TestClarifyStoreTakeExpired — TakeExpired reports (and drops) only a question
|
|
// whose TTL ran out.
|
|
func TestClarifyStoreTakeExpired(t *testing.T) {
|
|
s := NewClarifyStore(time.Minute)
|
|
if n := s.TakeExpired("voice", base); n != 0 {
|
|
t.Fatalf("nothing parked ⇒ nothing expired, got %d", n)
|
|
}
|
|
s.Put("voice", &PendingQuestion{Missing: []Slot{SlotTime}, Asked: base, TTL: time.Minute})
|
|
if n := s.TakeExpired("voice", base.Add(30*time.Second)); n != 0 {
|
|
t.Fatalf("a live question must not report as expired, got %d", n)
|
|
}
|
|
if s.Get("voice", base.Add(30*time.Second)) == nil {
|
|
t.Fatal("a live question must survive TakeExpired")
|
|
}
|
|
if n := s.TakeExpired("voice", base.Add(2*time.Minute)); n != 1 {
|
|
t.Fatalf("a stale question must report as one expired, got %d", n)
|
|
}
|
|
if n := s.TakeExpired("voice", base.Add(2*time.Minute)); n != 0 {
|
|
t.Fatalf("TakeExpired must drop the question, so the second call is 0, got %d", n)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|