package main import ( "fmt" "testing" ) // TestConvIDFlatCaseInvariant pins the load-bearing prompt-cache invariant of the // per-(room,thread) refactor: the MAIN-timeline conv id (threadRoot "") must stay // byte-identical to the pre-threading per-room value, so rooms that existed before // threading keep their warm Grok prompt-cache routing; and a thread must get a DISTINCT // id so divergent thread prefixes don't thrash one shared cache slot. Also asserts the // flag-off path returns "" (no header) for both cases. func TestConvIDFlatCaseInvariant(t *testing.T) { b := &Bot{cfg: &Config{GrokPromptCache: true}} flat := b.convID("!room:vojo.chat", "") legacy := fmt.Sprintf("vojo-%08x", hashString("!room:vojo.chat")) if flat != legacy { t.Fatalf("flat convID = %q, want legacy per-room value %q (prompt-cache continuity)", flat, legacy) } threaded := b.convID("!room:vojo.chat", "$root") if threaded == flat { t.Fatalf("threaded convID must differ from the flat/main-timeline id, both = %q", flat) } if want := fmt.Sprintf("vojo-%08x", hashString("!room:vojo.chat|$root")); threaded != want { t.Fatalf("threaded convID = %q, want %q", threaded, want) } off := &Bot{cfg: &Config{GrokPromptCache: false}} if got := off.convID("!room:vojo.chat", ""); got != "" { t.Fatalf("convID with GrokPromptCache off must be empty, got %q", got) } if got := off.convID("!room:vojo.chat", "$root"); got != "" { t.Fatalf("threaded convID with GrokPromptCache off must be empty, got %q", got) } } // TestSnapshotAppendBufNilPaths exercises the nested-map nil reads the per-(room,thread) // keying introduced: a never-seen room, a known room but unknown thread, and the round-trip // of a single append. A nil inner map / nil convBuf must read back as nil history (exactly // what a fresh "new chat" wants), never panic. func TestSnapshotAppendBufNilPaths(t *testing.T) { b := &Bot{cfg: &Config{MaxCtxEvent: 10}, buf: make(map[string]map[string]*convBuf)} if got := b.snapshotBuf("!a", ""); got != nil { t.Fatalf("snapshot of a never-seen room must be nil, got %v", got) } b.appendBuf("!a", "", bufferedMsg{sender: "@u:vojo.chat", body: "hi", isBot: false}) got := b.snapshotBuf("!a", "") if len(got) != 1 || got[0].body != "hi" { t.Fatalf("snapshot after one append = %v, want one message 'hi'", got) } // Same room, a DIFFERENT thread that was never appended to → nil (inner map exists, key absent). if got := b.snapshotBuf("!a", "$other"); got != nil { t.Fatalf("snapshot of an unknown thread in a known room must be nil, got %v", got) } // A different room entirely → nil (outer key absent). if got := b.snapshotBuf("!b", ""); got != nil { t.Fatalf("snapshot of a different room must be nil, got %v", got) } } // TestAppendBufTrimsToLimit asserts a single conversation's buffer is bounded to // MaxCtxEvent*2 (min 8) and keeps the most recent messages (FIFO drop of the oldest). func TestAppendBufTrimsToLimit(t *testing.T) { b := &Bot{cfg: &Config{MaxCtxEvent: 10}, buf: make(map[string]map[string]*convBuf)} const limit = 20 // MaxCtxEvent*2 for i := 0; i < limit+5; i++ { b.appendBuf("!a", "", bufferedMsg{sender: "@u:vojo.chat", body: fmt.Sprintf("m%d", i)}) } got := b.snapshotBuf("!a", "") if len(got) != limit { t.Fatalf("buffer length = %d, want capped at %d", len(got), limit) } // Oldest 5 dropped; the window starts at m5 and ends at m24. if got[0].body != "m5" || got[len(got)-1].body != "m24" { t.Fatalf("buffer window = [%s..%s], want [m5..m24]", got[0].body, got[len(got)-1].body) } } // TestAppendBufLRUEviction proves the per-room conversation-buffer cap: once a room exceeds // maxConvBuffersPerRoom, the LEAST-recently-touched conversation is evicted, and the // just-touched conversation is never the victim. func TestAppendBufLRUEviction(t *testing.T) { b := &Bot{cfg: &Config{MaxCtxEvent: 4}, buf: make(map[string]map[string]*convBuf)} // Touch exactly maxConvBuffersPerRoom+1 distinct threads, oldest-first. for i := 0; i <= maxConvBuffersPerRoom; i++ { b.appendBuf("!a", fmt.Sprintf("$t%d", i), bufferedMsg{body: "x"}) } if n := len(b.buf["!a"]); n != maxConvBuffersPerRoom { t.Fatalf("room buffer count = %d, want capped at %d", n, maxConvBuffersPerRoom) } if got := b.snapshotBuf("!a", "$t0"); got != nil { t.Fatalf("the coldest conversation ($t0) must have been evicted, got %v", got) } if got := b.snapshotBuf("!a", fmt.Sprintf("$t%d", maxConvBuffersPerRoom)); got == nil { t.Fatal("the just-appended conversation must never be the eviction victim") } // Re-touch protection: fill to the cap, re-touch the OLDEST, then overflow by one. // The re-touched conversation must survive; the new second-oldest becomes the victim. b2 := &Bot{cfg: &Config{MaxCtxEvent: 4}, buf: make(map[string]map[string]*convBuf)} for i := 0; i < maxConvBuffersPerRoom; i++ { b2.appendBuf("!a", fmt.Sprintf("$t%d", i), bufferedMsg{body: "x"}) } b2.appendBuf("!a", "$t0", bufferedMsg{body: "x"}) // re-touch the oldest → now newest b2.appendBuf("!a", "$overflow", bufferedMsg{body: "x"}) // overflow → evict the coldest if got := b2.snapshotBuf("!a", "$t0"); got == nil { t.Fatal("a re-touched conversation ($t0) must NOT be evicted") } if got := b2.snapshotBuf("!a", "$t1"); got != nil { t.Fatalf("the new coldest conversation ($t1) must be the victim, got %v", got) } } // TestTypingRefcount covers the per-room typing refcount the per-(room,thread) concurrency // relies on (Matrix typing is room-scoped, so several thread generations share one // indicator). Only the LAST release reports "last" (clears the indicator), and a release // after forgetRoom dropped the key must self-heal without leaving a leaked negative entry. func TestTypingRefcount(t *testing.T) { b := &Bot{typingRefs: make(map[string]int)} b.typingAcquire("!a") b.typingAcquire("!a") if b.typingRefs["!a"] != 2 { t.Fatalf("typingRefs after two acquires = %d, want 2", b.typingRefs["!a"]) } if last := b.typingRelease("!a"); last { t.Fatal("first release of two must NOT be the last") } if last := b.typingRelease("!a"); !last { t.Fatal("second release of two must be the last") } if _, ok := b.typingRefs["!a"]; ok { t.Fatalf("the key must be deleted once the refcount hits 0, still present = %d", b.typingRefs["!a"]) } // forgetRoom mid-flight: two acquires, then the room is forgotten (key deleted), then the // in-flight generations release against a missing key. Each lands at -1 (<=0 → "last") and // deletes the entry, so no persistent negative count leaks. b.typingAcquire("!b") b.typingAcquire("!b") b.forgetRoom("!b") if last := b.typingRelease("!b"); !last { t.Fatal("a release after forgetRoom must report last (counter <= 0)") } if last := b.typingRelease("!b"); !last { t.Fatal("a second stale release must also report last") } if _, ok := b.typingRefs["!b"]; ok { t.Fatalf("no negative entry must leak after forgetRoom + stale releases, value = %d", b.typingRefs["!b"]) } }