Files
Maven/internal/dialogue/clarify_test.go
claude 9ed259660d the suspend contract, and the row that was waiting for it (V-561)
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>
2026-08-06 01:23:37 +04:00

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