Files
Maven/internal/dialogue/stack_test.go
T
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

158 lines
5.7 KiB
Go

package dialogue
import (
"testing"
"time"
)
func parked(text string, asked time.Time) *PendingQuestion {
return &PendingQuestion{
Intent: IntentReminder,
Missing: []Slot{SlotTime},
Utterance: text,
Asked: asked,
TTL: time.Minute,
}
}
func TestStackPushPeekPop(t *testing.T) {
s := NewClarifyStore(time.Minute)
if dropped := s.Push("voice", parked("напомни позвонить маме", pendingBase)); dropped != nil {
t.Fatalf("first push dropped %q", dropped.Utterance)
}
if dropped := s.Push("voice", parked("погода в риме", pendingBase)); dropped != nil {
t.Fatalf("second push dropped %q", dropped.Utterance)
}
if got := s.Depth("voice"); got != 2 {
t.Fatalf("depth = %d, want 2", got)
}
if got := s.Peek("voice", pendingBase); got == nil || got.Utterance != "погода в риме" {
t.Fatalf("peek = %+v, want the newest", got)
}
// Peek must not consume: two peeks are the same question.
if got := s.Peek("voice", pendingBase); got == nil || got.Utterance != "погода в риме" {
t.Fatalf("second peek = %+v, want the newest still", got)
}
got := s.Pop("voice", pendingBase)
if got == nil || got.Utterance != "погода в риме" {
t.Fatalf("pop = %+v, want the newest", got)
}
// The flow underneath survived the one on top of it.
if got := s.Peek("voice", pendingBase); got == nil || got.Utterance != "напомни позвонить маме" {
t.Fatalf("after pop, peek = %+v, want the suspended flow", got)
}
if got := s.Pop("voice", pendingBase); got == nil {
t.Fatal("pop of the last entry returned nil")
}
if s.Peek("voice", pendingBase) != nil || s.Depth("voice") != 0 {
t.Error("stack not empty after popping everything")
}
if s.Pop("voice", pendingBase) != nil {
t.Error("pop of an empty stack returned something")
}
}
// A popped entry is gone: it must not come back on the next peek.
func TestStackPoppedEntryIsGone(t *testing.T) {
s := NewClarifyStore(time.Minute)
s.Push("voice", parked("напомни", pendingBase))
s.Pop("voice", pendingBase)
if got := s.Peek("voice", pendingBase); got != nil {
t.Errorf("peek after pop = %+v, want nil", got)
}
}
// Past MaxStackDepth the oldest entry comes back to the caller instead of
// vanishing — it is the caller's job to say it was dropped.
func TestStackDepthBoundReturnsTheDroppedEntry(t *testing.T) {
s := NewClarifyStore(time.Minute)
for i := 0; i < MaxStackDepth; i++ {
if dropped := s.Push("voice", parked("first", pendingBase)); dropped != nil {
t.Fatalf("push %d dropped early", i)
}
}
dropped := s.Push("voice", parked("newest", pendingBase))
if dropped == nil {
t.Fatal("push past the bound dropped an entry silently")
}
if dropped.Utterance != "first" {
t.Errorf("dropped %q, want the oldest", dropped.Utterance)
}
if got := s.Depth("voice"); got != MaxStackDepth {
t.Errorf("depth = %d, want %d", got, MaxStackDepth)
}
if got := s.Peek("voice", pendingBase); got == nil || got.Utterance != "newest" {
t.Errorf("peek = %+v, want the newest", got)
}
}
// Put still replaces rather than stacks: a re-ask is another question about the
// same action, so the daemon's depth stays one.
func TestPutReplacesTopWithoutGrowing(t *testing.T) {
s := NewClarifyStore(time.Minute)
s.Put("voice", parked("напомни", pendingBase))
s.Put("voice", parked("напомни ещё раз", pendingBase))
if got := s.Depth("voice"); got != 1 {
t.Fatalf("depth = %d, want 1", got)
}
if got := s.Peek("voice", pendingBase); got == nil || got.Utterance != "напомни ещё раз" {
t.Fatalf("peek = %+v, want the replacement", got)
}
}
// An expired top takes the stack with it, and TakeExpired is what reports it —
// the whole exchange timed out, and one notice is what a reply can carry.
func TestStackExpiryDropsTheStackAndIsReported(t *testing.T) {
s := NewClarifyStore(time.Minute)
s.Push("voice", parked("напомни", pendingBase))
s.Push("voice", parked("погода", pendingBase))
late := pendingBase.Add(2 * time.Minute)
if s.Peek("voice", late) != nil {
t.Error("peek returned an expired question")
}
if s.Depth("voice") != 0 {
t.Error("expired stack survived a peek")
}
s.Push("voice", parked("напомни", pendingBase))
s.Push("voice", parked("погода", pendingBase))
// Two died, and the count says two: the notice that reports this has a
// plural wording since V-561, and it is chosen off this number.
if n := s.TakeExpired("voice", late); n != 2 {
t.Errorf("TakeExpired reported %d timed-out questions, want 2", n)
}
if s.Depth("voice") != 0 {
t.Error("TakeExpired left entries behind")
}
if n := s.TakeExpired("voice", late); n != 0 {
t.Errorf("TakeExpired reported twice: %d", n)
}
// Pop of an expired top yields nothing rather than a dead action.
s.Push("voice", parked("напомни", pendingBase))
if s.Pop("voice", late) != nil {
t.Error("pop returned an expired question")
}
}
// Delete ends the exchange, every level of it.
func TestStackDeleteDropsAll(t *testing.T) {
s := NewClarifyStore(time.Minute)
s.Push("voice", parked("напомни", pendingBase))
s.Push("voice", parked("погода", pendingBase))
s.Delete("voice")
if s.Depth("voice") != 0 || s.Peek("voice", pendingBase) != nil {
t.Error("Delete left questions parked")
}
}
// Stacks are per dialogue id: the mic and the web must not read each other's.
func TestStacksAreIsolatedByID(t *testing.T) {
s := NewClarifyStore(time.Minute)
s.Push("voice", parked("напомни", pendingBase))
s.Push("web", parked("погода", pendingBase))
s.Delete("voice")
if got := s.Peek("web", pendingBase); got == nil || got.Utterance != "погода" {
t.Errorf("web stack = %+v, want its own question", got)
}
}