Files
picoclaw/pkg/events/subscription_test.go
T
Hoshina eedebabbea feat(events): add runtime event bus
Introduce pkg/events with filtered channels, subscription policies, backpressure, and stats. Wire AgentLoop to dual-publish legacy agent events into runtime events while preserving old event APIs.

Validation: go test ./pkg/events/... ./pkg/agent; go test -race ./pkg/events/...; make lint
2026-04-26 15:36:03 +08:00

157 lines
3.2 KiB
Go

package events
import (
"context"
"sync/atomic"
"testing"
"time"
)
func TestSubscribeOnceClosesAfterFirstEvent(t *testing.T) {
t.Parallel()
bus := NewBus()
defer closeBus(t, bus)
var handled atomic.Uint64
sub, err := bus.Channel().SubscribeOnce(
context.Background(),
SubscribeOptions{Name: "once", Buffer: 2},
func(context.Context, Event) error {
handled.Add(1)
return nil
},
)
if err != nil {
t.Fatalf("SubscribeOnce failed: %v", err)
}
bus.Publish(context.Background(), Event{Kind: KindAgentTurnStart})
waitForSubscriptionDone(t, sub)
bus.Publish(context.Background(), Event{Kind: KindAgentTurnEnd})
if got := handled.Load(); got != 1 {
t.Fatalf("handled = %d, want 1", got)
}
if got := sub.Stats().Handled; got != 1 {
t.Fatalf("subscription handled = %d, want 1", got)
}
}
func TestUnsubscribeClosesChannel(t *testing.T) {
t.Parallel()
bus := NewBus()
defer closeBus(t, bus)
sub, ch, err := bus.Channel().SubscribeChan(context.Background(), SubscribeOptions{Name: "chan"})
if err != nil {
t.Fatalf("SubscribeChan failed: %v", err)
}
if err := sub.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
select {
case _, ok := <-ch:
if ok {
t.Fatal("channel is open, want closed")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for channel close")
}
waitForSubscriptionDone(t, sub)
}
func TestHandlerPanicRecovered(t *testing.T) {
t.Parallel()
bus := NewBus()
defer closeBus(t, bus)
sub, err := bus.Channel().Subscribe(
context.Background(),
SubscribeOptions{Name: "panic", Buffer: 1},
func(context.Context, Event) error {
panic("boom")
},
)
if err != nil {
t.Fatalf("Subscribe failed: %v", err)
}
bus.Publish(context.Background(), Event{Kind: KindAgentError})
waitForStat(t, func() uint64 {
return sub.Stats().Panicked
}, 1)
}
func TestLockedHandlerProcessesSequentially(t *testing.T) {
t.Parallel()
bus := NewBus()
defer closeBus(t, bus)
var active atomic.Int64
var maxActive atomic.Int64
sub, err := bus.Channel().Subscribe(
context.Background(),
SubscribeOptions{Name: "locked", Buffer: 8, Concurrency: Locked},
func(context.Context, Event) error {
current := active.Add(1)
for {
currentMax := maxActive.Load()
if current <= currentMax || maxActive.CompareAndSwap(currentMax, current) {
break
}
}
time.Sleep(10 * time.Millisecond)
active.Add(-1)
return nil
},
)
if err != nil {
t.Fatalf("Subscribe failed: %v", err)
}
for i := 0; i < 5; i++ {
bus.Publish(context.Background(), Event{Kind: KindAgentLLMDelta})
}
waitForStat(t, func() uint64 {
return sub.Stats().Handled
}, 5)
if got := maxActive.Load(); got != 1 {
t.Fatalf("max active handlers = %d, want 1", got)
}
}
func waitForSubscriptionDone(t *testing.T, sub Subscription) {
t.Helper()
select {
case <-sub.Done():
case <-time.After(time.Second):
t.Fatal("timed out waiting for subscription to stop")
}
}
func waitForStat(t *testing.T, stat func() uint64, want uint64) {
t.Helper()
deadline := time.After(time.Second)
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
for {
if got := stat(); got >= want {
return
}
select {
case <-ticker.C:
case <-deadline:
t.Fatalf("timed out waiting for stat >= %d", want)
}
}
}