Refactor real-time sync from per-event precise mutations to WS-as-invalidation-signal + debounced refetch. Backend: - Add SubscribeAll to Event Bus — auto-broadcasts ALL events, eliminates manual 25-item allEvents list - Add skill event constants to protocol, fix skill handler string literals - Add title_changed activity tracking Frontend: - WSClient: add onAny() method for wildcard event subscription - useRealtimeSync: rewrite to refreshMap + prefix routing + 100ms debounce - Precise handlers only for side effects: workspace:deleted, member:removed, member:added (self-check) - Reconnect now refetches all stores (fixes missing members/skills/workspace refresh) - Stale-while-revalidate: fetch() only shows loading spinner on initial load, not on refetch - Remove redundant useWSEvent in agents/page.tsx and skills-page.tsx - WSClient.disconnect() now clears all handler registrations Inbox bugfixes: - Unify sidebar badge count with page count via dedupedItems + unreadCount in store - Sort by time DESC (removed severity-first ordering) - Ellipsis on truncated detail labels UI: - Status/Priority pickers: replace RadioGroup with MenuItem for auto-close on selection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
146 lines
3.1 KiB
Go
146 lines
3.1 KiB
Go
package events
|
|
|
|
import (
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
func TestPublishDeliversToSubscribers(t *testing.T) {
|
|
bus := New()
|
|
var count int32
|
|
|
|
bus.Subscribe("test:event", func(e Event) {
|
|
atomic.AddInt32(&count, 1)
|
|
})
|
|
bus.Subscribe("test:event", func(e Event) {
|
|
atomic.AddInt32(&count, 1)
|
|
})
|
|
|
|
bus.Publish(Event{Type: "test:event", Payload: "hello"})
|
|
|
|
if count != 2 {
|
|
t.Errorf("expected 2 handlers called, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestPublishOnlyMatchingType(t *testing.T) {
|
|
bus := New()
|
|
var called bool
|
|
|
|
bus.Subscribe("type:a", func(e Event) {
|
|
called = true
|
|
})
|
|
|
|
bus.Publish(Event{Type: "type:b"})
|
|
|
|
if called {
|
|
t.Error("handler for type:a should not be called for type:b event")
|
|
}
|
|
}
|
|
|
|
func TestPublishNoSubscribersIsNoop(t *testing.T) {
|
|
bus := New()
|
|
// Should not panic
|
|
bus.Publish(Event{Type: "no:listeners"})
|
|
}
|
|
|
|
func TestPanicInHandlerDoesNotBreakOthers(t *testing.T) {
|
|
bus := New()
|
|
var secondCalled bool
|
|
|
|
bus.Subscribe("test:panic", func(e Event) {
|
|
panic("handler panic")
|
|
})
|
|
bus.Subscribe("test:panic", func(e Event) {
|
|
secondCalled = true
|
|
})
|
|
|
|
bus.Publish(Event{Type: "test:panic"})
|
|
|
|
if !secondCalled {
|
|
t.Error("second handler should still be called after first panics")
|
|
}
|
|
}
|
|
|
|
func TestSubscribeAllReceivesAllEventTypes(t *testing.T) {
|
|
bus := New()
|
|
|
|
var received []string
|
|
bus.SubscribeAll(func(e Event) {
|
|
received = append(received, e.Type)
|
|
})
|
|
|
|
bus.Publish(Event{Type: "issue:created"})
|
|
bus.Publish(Event{Type: "comment:deleted"})
|
|
bus.Publish(Event{Type: "skill:updated"})
|
|
|
|
if len(received) != 3 {
|
|
t.Fatalf("expected 3 events, got %d", len(received))
|
|
}
|
|
if received[0] != "issue:created" || received[1] != "comment:deleted" || received[2] != "skill:updated" {
|
|
t.Fatalf("unexpected events: %v", received)
|
|
}
|
|
}
|
|
|
|
func TestSubscribeAllCalledAfterTypeSpecific(t *testing.T) {
|
|
bus := New()
|
|
|
|
var order []string
|
|
bus.Subscribe("issue:created", func(e Event) {
|
|
order = append(order, "specific")
|
|
})
|
|
bus.SubscribeAll(func(e Event) {
|
|
order = append(order, "global")
|
|
})
|
|
|
|
bus.Publish(Event{Type: "issue:created"})
|
|
|
|
if len(order) != 2 || order[0] != "specific" || order[1] != "global" {
|
|
t.Fatalf("expected [specific, global], got %v", order)
|
|
}
|
|
}
|
|
|
|
func TestSubscribeAllPanicRecovery(t *testing.T) {
|
|
bus := New()
|
|
|
|
var secondCalled bool
|
|
bus.SubscribeAll(func(e Event) {
|
|
panic("test panic")
|
|
})
|
|
bus.SubscribeAll(func(e Event) {
|
|
secondCalled = true
|
|
})
|
|
|
|
bus.Publish(Event{Type: "test"})
|
|
|
|
if !secondCalled {
|
|
t.Fatal("second global handler was not called after first panicked")
|
|
}
|
|
}
|
|
|
|
func TestEventFieldsPassedThrough(t *testing.T) {
|
|
bus := New()
|
|
var received Event
|
|
|
|
bus.Subscribe("test:fields", func(e Event) {
|
|
received = e
|
|
})
|
|
|
|
bus.Publish(Event{
|
|
Type: "test:fields",
|
|
WorkspaceID: "ws-123",
|
|
ActorType: "member",
|
|
ActorID: "user-456",
|
|
Payload: map[string]string{"key": "value"},
|
|
})
|
|
|
|
if received.WorkspaceID != "ws-123" {
|
|
t.Errorf("expected WorkspaceID ws-123, got %s", received.WorkspaceID)
|
|
}
|
|
if received.ActorType != "member" {
|
|
t.Errorf("expected ActorType member, got %s", received.ActorType)
|
|
}
|
|
if received.ActorID != "user-456" {
|
|
t.Errorf("expected ActorID user-456, got %s", received.ActorID)
|
|
}
|
|
}
|