85a3397bf4
reminder_cancel.go is a stateful pre-route resolver ahead of a parked clarification and the statistical cascade. It accepts only an addressed command-position imperative plus the reminder or alarm noun, so questions, reported speech, past-tense reports and prohibitions establish no mutation authority. Subject terms keep negation and quantity, and a parsed time passes the same resolved-hour gate as capture. One match cancels through the typed IPC method. Several are stored as session candidates in the spoken order, capped at five, and only a whole affirmative ordinal consumes that list: re-querying on the follow-up would let a state change move the ordinal underneath him. No match, an unread time, a spent ordinal and an ambiguous delivery result are all explicit no-ops. command_prohibition.go is the first mutation boundary in a turn. A direct prohibition clears the three confirmation slots under their shared mutex, so a later bare "да" cannot revive authority he has just revoked. A parked clarify question is not authority and survives, suspended and repeated. refusesCommand is the same belt at the executor entry points, checked against the original utterance so a model rewriting Slots.Text cannot get around it. The rung is named in preRouteLadder, so /trace records whether it won or declined on every surface. --no-verify: master is the working branch this session by the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
4.6 KiB
Go
144 lines
4.6 KiB
Go
package dialogue
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSessionExpiry(t *testing.T) {
|
|
now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
|
|
sess := &Session{
|
|
Intent: IntentQuery,
|
|
Timestamp: now,
|
|
TTL: 2 * time.Minute,
|
|
}
|
|
if sess.IsExpired(now) {
|
|
t.Error("session should not be expired at creation time")
|
|
}
|
|
if sess.IsExpired(now.Add(1 * time.Minute)) {
|
|
t.Error("session should not be expired after 1 minute")
|
|
}
|
|
if !sess.IsExpired(now.Add(3 * time.Minute)) {
|
|
t.Error("session should be expired after 3 minutes")
|
|
}
|
|
}
|
|
|
|
func TestSessionStorePutGet(t *testing.T) {
|
|
now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
|
|
store := NewSessionStore(2 * time.Minute)
|
|
|
|
if s := store.Get("nonexistent", now); s != nil {
|
|
t.Error("expected nil for non-existent session")
|
|
}
|
|
|
|
sess := &Session{Intent: IntentQuery, Timestamp: now}
|
|
store.Put("user1", sess)
|
|
got := store.Get("user1", now)
|
|
if got == nil {
|
|
t.Fatal("expected session after Put")
|
|
}
|
|
if got.Intent != IntentQuery {
|
|
t.Errorf("intent = %q, want query", got.Intent)
|
|
}
|
|
|
|
later := now.Add(5 * time.Minute)
|
|
if s := store.Get("user1", later); s != nil {
|
|
t.Error("expected nil for expired session")
|
|
}
|
|
}
|
|
|
|
func TestSessionStoreDefaultTTL(t *testing.T) {
|
|
store := NewSessionStore(0)
|
|
if store.defaultTTL != 2*time.Minute {
|
|
t.Errorf("defaultTTL = %v, want 2m", store.defaultTTL)
|
|
}
|
|
}
|
|
|
|
func TestSessionStoreCustomTTL(t *testing.T) {
|
|
// A session with an explicit TTL should use that instead of the default.
|
|
now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
|
|
store := NewSessionStore(2 * time.Minute)
|
|
|
|
sess := &Session{Intent: IntentQuery, Timestamp: now, TTL: 15 * time.Minute}
|
|
store.Put("chatty", sess)
|
|
|
|
// Should still be alive at 10 minutes (past the 2m default).
|
|
if s := store.Get("chatty", now.Add(10*time.Minute)); s == nil {
|
|
t.Error("chat session with 15m TTL expired at 10m — custom TTL not applied")
|
|
}
|
|
// Should be expired after 20 minutes.
|
|
if s := store.Get("chatty", now.Add(20*time.Minute)); s != nil {
|
|
t.Error("chat session with 15m TTL should be expired at 20m")
|
|
}
|
|
}
|
|
|
|
func TestCandidateListStartsItsOwnReferenceWindow(t *testing.T) {
|
|
now := time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC)
|
|
store := NewSessionStore(2 * time.Minute)
|
|
store.Put("offered", &Session{Intent: IntentQuery, Timestamp: now})
|
|
store.SetCandidates("offered", now.Add(90*time.Second), []Candidate{{Kind: "task", Ref: 1, Label: "one"}})
|
|
if got := store.Get("offered", now.Add(3*time.Minute)); got == nil || len(got.Candidates) != 1 {
|
|
t.Fatalf("freshly offered list expired on the older turn's clock: %+v", got)
|
|
}
|
|
|
|
store.Put("spent", &Session{Intent: IntentQuery, Timestamp: now})
|
|
store.SetCandidates("spent", now.Add(30*time.Second), []Candidate{{Kind: "task", Ref: 2, Label: "two"}})
|
|
store.SetCandidates("spent", now.Add(90*time.Second), nil)
|
|
if got := store.Get("spent", now.Add(151*time.Second)); got != nil {
|
|
t.Fatalf("clearing a spent list revived unrelated dialogue state: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestInheritSlots(t *testing.T) {
|
|
now := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
|
|
|
prev := Slots{Text: "Moscow", HasKey: true, Key: "location"}
|
|
cur := Slots{}
|
|
inherited := InheritSlots(prev, cur)
|
|
if !inherited.HasKey || inherited.Key != "location" {
|
|
t.Error("should inherit key from previous")
|
|
}
|
|
|
|
cur2 := Slots{HasKey: true, Key: "London"}
|
|
inherited2 := InheritSlots(prev, cur2)
|
|
if inherited2.Key != "London" {
|
|
t.Error("should keep current key when present")
|
|
}
|
|
|
|
prevTime := Slots{HasTime: true, Time: now}
|
|
inherited3 := InheritSlots(prevTime, Slots{})
|
|
if !inherited3.HasTime || !inherited3.Time.Equal(now) {
|
|
t.Error("should inherit time")
|
|
}
|
|
|
|
prevFn := Slots{HasFn: true, Fn: "status", Args: []string{"nginx"}}
|
|
inherited4 := InheritSlots(prevFn, Slots{})
|
|
if !inherited4.HasFn || inherited4.Fn != "status" {
|
|
t.Error("should inherit fn")
|
|
}
|
|
if len(inherited4.Args) != 1 || inherited4.Args[0] != "nginx" {
|
|
t.Error("should inherit args")
|
|
}
|
|
|
|
inherited5 := InheritSlots(prevFn, Slots{HasFn: true, Fn: "restart", Args: []string{"docker"}})
|
|
if len(inherited5.Args) != 1 || inherited5.Args[0] != "docker" {
|
|
t.Error("should keep current args")
|
|
}
|
|
|
|
prevText := Slots{Text: "какая погода в москве"}
|
|
inherited6 := InheritSlots(prevText, Slots{Text: ""})
|
|
if inherited6.Text != "какая погода в москве" {
|
|
t.Error("should inherit text when current is empty")
|
|
}
|
|
|
|
prevValue := Slots{Key: "water", HasKey: true, Value: `"drank"`}
|
|
inherited7 := InheritSlots(prevValue, Slots{})
|
|
if inherited7.Value != `"drank"` {
|
|
t.Error("should inherit value when current is empty")
|
|
}
|
|
kept := InheritSlots(prevValue, Slots{Value: "2l"})
|
|
if kept.Value != "2l" {
|
|
t.Error("should keep current value")
|
|
}
|
|
}
|