158 lines
6.9 KiB
Go
158 lines
6.9 KiB
Go
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"])
|
|
}
|
|
}
|