From e8f4baf4079e956909de3e794c396cde4b39d23a Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 00:37:01 +0400 Subject: [PATCH] =?UTF-8?q?dialogue:=20stack=20tests=20=E2=80=94=20push,?= =?UTF-8?q?=20peek,=20pop,=20the=20bound=20and=20expiry=20(V-559)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push/peek/pop including that a peek does not consume and that the flow under a popped entry survives; that a popped entry stays gone; that a push past MaxStackDepth returns the evicted entry rather than dropping it silently; that Put keeps the depth at one; that an expired top takes the stack with it and is reported once by TakeExpired; and that two dialogue ids do not read each other's stack. --- internal/dialogue/stack_test.go | 155 ++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 internal/dialogue/stack_test.go diff --git a/internal/dialogue/stack_test.go b/internal/dialogue/stack_test.go new file mode 100644 index 0000000..8cd9156 --- /dev/null +++ b/internal/dialogue/stack_test.go @@ -0,0 +1,155 @@ +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)) + if !s.TakeExpired("voice", late) { + t.Error("TakeExpired did not report the timed-out exchange") + } + if s.Depth("voice") != 0 { + t.Error("TakeExpired left entries behind") + } + if s.TakeExpired("voice", late) { + t.Error("TakeExpired reported twice") + } + // 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) + } +}