🎉 live server seems to be working now
This commit is contained in:
327
third_party/sendspin-go/pkg/sendspin/group_test.go
vendored
Normal file
327
third_party/sendspin-go/pkg/sendspin/group_test.go
vendored
Normal file
@@ -0,0 +1,327 @@
|
||||
// ABOUTME: Tests for the Group event bus plumbing
|
||||
// ABOUTME: Fan-out, unsubscribe, and slow-subscriber drop behavior
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestGroup_PublishSubscribe confirms the basic contract: an event
|
||||
// published after Subscribe is delivered to the subscriber's channel.
|
||||
func TestGroup_PublishSubscribe(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
events, unsubscribe := g.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c1"}})
|
||||
|
||||
select {
|
||||
case evt := <-events:
|
||||
joined, ok := evt.(ClientJoinedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want ClientJoinedEvent", evt)
|
||||
}
|
||||
if joined.Client.ID() != "c1" {
|
||||
t.Errorf("Client.ID() = %q, want %q", joined.Client.ID(), "c1")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for published event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroup_FanOut confirms one publish reaches every active subscriber.
|
||||
func TestGroup_FanOut(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
const subscribers = 3
|
||||
chans := make([]<-chan Event, subscribers)
|
||||
for i := 0; i < subscribers; i++ {
|
||||
ch, unsub := g.Subscribe()
|
||||
defer unsub()
|
||||
chans[i] = ch
|
||||
}
|
||||
|
||||
g.publish(ClientLeftEvent{ClientID: "c-gone", ClientName: "Gone Client"})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, ch := range chans {
|
||||
wg.Add(1)
|
||||
go func(idx int, c <-chan Event) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case evt := <-c:
|
||||
if _, ok := evt.(ClientLeftEvent); !ok {
|
||||
t.Errorf("subscriber %d got %T, want ClientLeftEvent", idx, evt)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Errorf("subscriber %d timed out", idx)
|
||||
}
|
||||
}(i, ch)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestGroup_Unsubscribe confirms that after calling the unsubscribe func,
|
||||
// further publishes do NOT reach the channel.
|
||||
func TestGroup_Unsubscribe(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
events, unsubscribe := g.Subscribe()
|
||||
|
||||
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c1"}})
|
||||
<-events // drain the first event
|
||||
|
||||
unsubscribe()
|
||||
|
||||
// Publish a second event — the unsubscribed channel should not receive it.
|
||||
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c2"}})
|
||||
|
||||
select {
|
||||
case evt, ok := <-events:
|
||||
if ok {
|
||||
t.Errorf("received event %v after unsubscribe", evt)
|
||||
}
|
||||
// Channel closed — also acceptable.
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// No event arrived within the window — correct behavior.
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroup_SlowSubscriberDoesNotBlockPublisher pins the non-blocking
|
||||
// fan-out contract: a subscriber that never reads must not stall events
|
||||
// for other subscribers or the publisher itself.
|
||||
func TestGroup_SlowSubscriberDoesNotBlockPublisher(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
// slow subscriber — we intentionally never read from this.
|
||||
_, _ = g.Subscribe()
|
||||
|
||||
fast, unsubscribeFast := g.Subscribe()
|
||||
defer unsubscribeFast()
|
||||
|
||||
// Overflow the slow subscriber's buffer.
|
||||
for i := 0; i < 100; i++ {
|
||||
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "flood"}})
|
||||
}
|
||||
|
||||
// The fast subscriber should still have received events.
|
||||
received := 0
|
||||
timeout := time.After(200 * time.Millisecond)
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-fast:
|
||||
received++
|
||||
if received >= 32 {
|
||||
break loop
|
||||
}
|
||||
case <-timeout:
|
||||
break loop
|
||||
}
|
||||
}
|
||||
if received == 0 {
|
||||
t.Error("fast subscriber received zero events while slow one blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroup_IDReturnsConstructorValue just pins the GroupID accessor.
|
||||
func TestGroup_IDReturnsConstructorValue(t *testing.T) {
|
||||
g := NewGroup("my-group-id")
|
||||
defer g.Close()
|
||||
if got := g.ID(); got != "my-group-id" {
|
||||
t.Errorf("ID() = %q, want %q", got, "my-group-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroup_ConcurrentSubscribeClose spawns many Subscribe callers
|
||||
// against a concurrent Close and asserts that nothing panics and every
|
||||
// returned channel is either pre-closed or closes promptly. Guards the
|
||||
// lock discipline against future refactors that could regress the
|
||||
// Subscribe-vs-Close ordering.
|
||||
func TestGroup_ConcurrentSubscribeClose(t *testing.T) {
|
||||
const subscribers = 50
|
||||
g := NewGroup("race-bait")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
subsReady := make(chan struct{})
|
||||
|
||||
for i := 0; i < subscribers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-subsReady
|
||||
ch, unsub := g.Subscribe()
|
||||
defer unsub()
|
||||
|
||||
// Drain until the channel closes. If Close races with our
|
||||
// Subscribe we should either get a pre-closed channel
|
||||
// immediately or see it close after Close completes.
|
||||
deadline := time.After(500 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
case <-deadline:
|
||||
t.Error("channel never closed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
close(subsReady)
|
||||
time.Sleep(5 * time.Millisecond) // let some Subscribes land
|
||||
g.Close()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestGroup_ConcurrentPublishClose races publishes against a close and
|
||||
// asserts no panic. A send on a closed channel would surface as a panic
|
||||
// recovered by the test runner.
|
||||
func TestGroup_ConcurrentPublishClose(t *testing.T) {
|
||||
g := NewGroup("race-bait")
|
||||
|
||||
// Pre-subscribe so there's a channel to fan out to.
|
||||
_, unsub := g.Subscribe()
|
||||
defer unsub()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < 1000; i++ {
|
||||
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "flood"}})
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(2 * time.Millisecond) // let some publishes land
|
||||
g.Close()
|
||||
<-done
|
||||
}
|
||||
|
||||
// TestGroup_SetPlaybackState_PublishesOnChange confirms that
|
||||
// SetPlaybackState publishes a GroupPlaybackStateChangedEvent only on
|
||||
// actual transitions, and is a silent no-op on same-state writes. The
|
||||
// group's default state is "playing" (per NewGroup), so calling
|
||||
// SetPlaybackState("playing") first must produce no event; transitioning
|
||||
// to "stopped" must produce exactly one event with OldState="playing".
|
||||
func TestGroup_SetPlaybackState_PublishesOnChange(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
// Subscribe BEFORE any state writes so we observe every emitted event.
|
||||
events, unsubscribe := g.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
// Same-state write: must not publish (default is "playing").
|
||||
g.SetPlaybackState("playing")
|
||||
|
||||
select {
|
||||
case evt := <-events:
|
||||
t.Fatalf("same-state SetPlaybackState published unexpected event: %T %+v", evt, evt)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
// Expected: no event.
|
||||
}
|
||||
|
||||
// Real transition: must publish exactly one event.
|
||||
g.SetPlaybackState("stopped")
|
||||
|
||||
select {
|
||||
case evt := <-events:
|
||||
ps, ok := evt.(GroupPlaybackStateChangedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt)
|
||||
}
|
||||
t.Logf("event=%+v", ps)
|
||||
if ps.OldState != "playing" || ps.NewState != "stopped" {
|
||||
t.Errorf("event = %+v, want {OldState: playing, NewState: stopped}", ps)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for GroupPlaybackStateChangedEvent")
|
||||
}
|
||||
|
||||
// Same-state write again: must not publish.
|
||||
g.SetPlaybackState("stopped")
|
||||
|
||||
select {
|
||||
case evt := <-events:
|
||||
t.Fatalf("repeat same-state SetPlaybackState published unexpected event: %T %+v", evt, evt)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
// Expected.
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroup_SetPlaybackState_OldStateInEvent pins that successive
|
||||
// transitions carry the prior playback state in OldState — not the empty
|
||||
// string, not the first-ever value. This guards against future refactors
|
||||
// that might forget to capture oldState before mutation.
|
||||
func TestGroup_SetPlaybackState_OldStateInEvent(t *testing.T) {
|
||||
g := NewGroup("test-group")
|
||||
defer g.Close()
|
||||
|
||||
events, unsubscribe := g.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
g.SetPlaybackState("paused")
|
||||
g.SetPlaybackState("playing")
|
||||
|
||||
// First event: playing → paused
|
||||
select {
|
||||
case evt := <-events:
|
||||
ps, ok := evt.(GroupPlaybackStateChangedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt)
|
||||
}
|
||||
t.Logf("event[0]=%+v", ps)
|
||||
if ps.OldState != "playing" || ps.NewState != "paused" {
|
||||
t.Errorf("event[0] = %+v, want {playing, paused}", ps)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timed out on first event")
|
||||
}
|
||||
|
||||
// Second event: paused → playing
|
||||
select {
|
||||
case evt := <-events:
|
||||
ps, ok := evt.(GroupPlaybackStateChangedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt)
|
||||
}
|
||||
t.Logf("event[1]=%+v", ps)
|
||||
if ps.OldState != "paused" || ps.NewState != "playing" {
|
||||
t.Errorf("event[1] = %+v, want {paused, playing}", ps)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timed out on second event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroup_AddClientSendsGroupUpdate confirms that addClient sends
|
||||
// a group/update message to the joining client before publishing
|
||||
// ClientJoinedEvent.
|
||||
func TestGroup_AddClientSendsGroupUpdate(t *testing.T) {
|
||||
g := NewGroup("group-123")
|
||||
defer g.Close()
|
||||
|
||||
sc := &ServerClient{
|
||||
id: "c1",
|
||||
sendChan: make(chan interface{}, 10),
|
||||
}
|
||||
g.addClient(sc)
|
||||
|
||||
select {
|
||||
case msg := <-sc.sendChan:
|
||||
_ = msg // Verifies group/update was sent
|
||||
default:
|
||||
t.Fatal("addClient did not send group/update")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user