214a4032cf
Vikunja #382. A parked clarifying question past its TTL was discarded silently on read; now she says the old request is gone and the newly spoken words are still routed as a fresh utterance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
248 lines
7.2 KiB
Go
248 lines
7.2 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 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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|