🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

View File

@@ -0,0 +1,416 @@
// ABOUTME: Tests for the MetadataGroupRole implementation
// ABOUTME: Verifies metadata state push to joining clients and broadcast paths
package sendspin
import (
"sync"
"testing"
"time"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
// drainServerStateMessage reads one queued send from a fake client's
// sendChan and returns the decoded protocol.Message + ServerStateMessage.
// Fails the test if nothing is queued or if the queued payload is not a
// server/state message.
func drainServerStateMessage(t *testing.T, sc *ServerClient) (protocol.Message, protocol.ServerStateMessage) {
t.Helper()
select {
case raw := <-sc.sendChan:
msg, ok := raw.(protocol.Message)
if !ok {
t.Fatalf("queued send was %T, want protocol.Message", raw)
}
state, ok := msg.Payload.(protocol.ServerStateMessage)
if !ok {
t.Fatalf("Message.Payload was %T, want protocol.ServerStateMessage", msg.Payload)
}
return msg, state
case <-time.After(50 * time.Millisecond):
t.Fatal("expected a server/state send, none arrived")
return protocol.Message{}, protocol.ServerStateMessage{}
}
}
// expectNoSend fails the test if the client received any queued send
// within the wait window. Used to assert non-metadata clients don't get
// broadcasts.
func expectNoSend(t *testing.T, sc *ServerClient, wait time.Duration, label string) {
t.Helper()
select {
case raw := <-sc.sendChan:
t.Fatalf("%s: unexpected send queued: %T %+v", label, raw, raw)
case <-time.After(wait):
// Expected: nothing.
}
}
func TestMetadataRole_OnClientJoinSendsState(t *testing.T) {
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) {
return "Track Title", "Artist Name", "Album Name"
},
ClockMicros: func() int64 { return 12345 },
})
sc := &ServerClient{
id: "c1",
roles: []string{"metadata@v1"},
sendChan: make(chan interface{}, 10),
}
meta.OnClientJoin(sc)
select {
case msg := <-sc.sendChan:
_ = msg
default:
t.Fatal("OnClientJoin did not send metadata state")
}
}
func TestMetadataRole_OnClientJoinSkipsNonMetadataClient(t *testing.T) {
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) { return "t", "a", "al" },
ClockMicros: func() int64 { return 0 },
})
sc := &ServerClient{
id: "c1",
roles: []string{"player@v1"},
sendChan: make(chan interface{}, 10),
}
meta.OnClientJoin(sc)
select {
case <-sc.sendChan:
t.Error("should not send metadata to non-metadata client")
default:
}
}
func TestMetadataRole_Role(t *testing.T) {
meta := NewMetadataRole(MetadataConfig{})
if meta.Role() != "metadata" {
t.Errorf("Role() = %q, want %q", meta.Role(), "metadata")
}
}
// TestMetadataRole_OnPlaybackStateChanged_BroadcastsToMetadataClients
// builds a Group with two attached clients (one with the metadata role,
// one without), registers a MetadataGroupRole, and invokes
// OnPlaybackStateChanged("stopped", "playing"). The metadata client must
// receive a server/state with the expected payload; the non-metadata
// client must not.
func TestMetadataRole_OnPlaybackStateChanged_BroadcastsToMetadataClients(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) {
return "Track Title", "Artist Name", "Album Name"
},
ClockMicros: func() int64 { return 42 },
})
g.RegisterRole(meta)
metaClient := &ServerClient{
id: "meta-c",
name: "metadata client",
roles: []string{"metadata@v1"},
sendChan: make(chan interface{}, 10),
}
playerClient := &ServerClient{
id: "player-c",
name: "player client",
roles: []string{"player@v1"},
sendChan: make(chan interface{}, 10),
}
// Attach via addClient so they're members of g.Clients(); drain the
// group/update that addClient sends before exercising broadcast.
g.addClient(metaClient)
g.addClient(playerClient)
<-metaClient.sendChan // group/update
<-playerClient.sendChan // group/update
// Drain the OnClientJoin server/state that fires on the metadata
// client (since meta was registered before the client joined).
// Sleep briefly so the dispatcher goroutine processes the join.
time.Sleep(20 * time.Millisecond)
select {
case raw := <-metaClient.sendChan:
t.Logf("drained join-time send: %+v", raw)
default:
// Already drained or never sent — fine, this test exercises the
// broadcast path, not the join path.
}
// Direct invocation — independent of event dispatch.
meta.OnPlaybackStateChanged("stopped", "playing")
msg, state := drainServerStateMessage(t, metaClient)
t.Logf("metaClient received: type=%s payload=%+v metadata=%+v", msg.Type, state, state.Metadata)
if msg.Type != "server/state" {
t.Errorf("Message.Type = %q, want server/state", msg.Type)
}
if state.Metadata == nil {
t.Fatal("state.Metadata is nil")
}
if state.Metadata.Title == nil || *state.Metadata.Title != "Track Title" {
t.Errorf("Title = %v, want \"Track Title\"", state.Metadata.Title)
}
if state.Metadata.Artist == nil || *state.Metadata.Artist != "Artist Name" {
t.Errorf("Artist = %v, want \"Artist Name\"", state.Metadata.Artist)
}
if state.Metadata.Album == nil || *state.Metadata.Album != "Album Name" {
t.Errorf("Album = %v, want \"Album Name\"", state.Metadata.Album)
}
if state.Metadata.Timestamp != 42 {
t.Errorf("Timestamp = %d, want 42", state.Metadata.Timestamp)
}
expectNoSend(t, playerClient, 30*time.Millisecond, "non-metadata client")
}
// TestMetadataRole_OnPlaybackStateChanged_IgnoresNonPlayingTransitions
// asserts that transitions that aren't "* → playing" (excluding
// playing→playing) do not broadcast.
func TestMetadataRole_OnPlaybackStateChanged_IgnoresNonPlayingTransitions(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) { return "t", "a", "al" },
ClockMicros: func() int64 { return 0 },
})
g.RegisterRole(meta)
sc := &ServerClient{
id: "meta-c",
roles: []string{"metadata@v1"},
sendChan: make(chan interface{}, 10),
}
g.addClient(sc)
<-sc.sendChan // group/update
// Drain any OnClientJoin send that landed.
time.Sleep(20 * time.Millisecond)
select {
case <-sc.sendChan:
default:
}
cases := []struct {
oldState, newState string
}{
{"playing", "paused"},
{"playing", "stopped"},
{"stopped", "paused"},
{"playing", "playing"}, // would have been filtered by Group; pin role-level guard too
}
for _, tc := range cases {
t.Logf("trying transition %s -> %s (must not broadcast)", tc.oldState, tc.newState)
meta.OnPlaybackStateChanged(tc.oldState, tc.newState)
expectNoSend(t, sc, 30*time.Millisecond, tc.oldState+"->"+tc.newState)
}
// Sanity: stopped -> playing IS broadcast.
t.Log("sanity: stopped -> playing must broadcast")
meta.OnPlaybackStateChanged("stopped", "playing")
msg, state := drainServerStateMessage(t, sc)
t.Logf("sanity broadcast received: type=%s metadata=%+v", msg.Type, state.Metadata)
}
// TestMetadataRole_BroadcastMetadata_BroadcastsRegardlessOfState confirms
// that the public BroadcastMetadata method pushes the snapshot to every
// metadata-role client unconditionally.
func TestMetadataRole_BroadcastMetadata_BroadcastsRegardlessOfState(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) {
return "Updated Title", "Updated Artist", "Updated Album"
},
ClockMicros: func() int64 { return 100 },
})
g.RegisterRole(meta)
sc1 := &ServerClient{
id: "meta-1",
roles: []string{"metadata@v1"},
sendChan: make(chan interface{}, 10),
}
sc2 := &ServerClient{
id: "meta-2",
roles: []string{"metadata@v2"}, // versioned form must still match
sendChan: make(chan interface{}, 10),
}
scPlayer := &ServerClient{
id: "player-1",
roles: []string{"player@v1"},
sendChan: make(chan interface{}, 10),
}
g.addClient(sc1)
g.addClient(sc2)
g.addClient(scPlayer)
<-sc1.sendChan
<-sc2.sendChan
<-scPlayer.sendChan
// Drain join-time server/state on the metadata clients.
time.Sleep(20 * time.Millisecond)
for _, sc := range []*ServerClient{sc1, sc2} {
select {
case <-sc.sendChan:
default:
}
}
meta.BroadcastMetadata()
msg1, state1 := drainServerStateMessage(t, sc1)
t.Logf("sc1 (metadata@v1) received: type=%s metadata=%+v", msg1.Type, state1.Metadata)
if state1.Metadata == nil || state1.Metadata.Title == nil || *state1.Metadata.Title != "Updated Title" {
t.Errorf("sc1: title = %v, want \"Updated Title\"", state1.Metadata)
}
msg2, state2 := drainServerStateMessage(t, sc2)
t.Logf("sc2 (metadata@v2) received: type=%s metadata=%+v", msg2.Type, state2.Metadata)
if state2.Metadata == nil || state2.Metadata.Title == nil || *state2.Metadata.Title != "Updated Title" {
t.Errorf("sc2: title = %v, want \"Updated Title\"", state2.Metadata)
}
expectNoSend(t, scPlayer, 30*time.Millisecond, "player-only client")
}
// TestMetadataRole_OnPlaybackStateChanged_FiresFromGroupEvent is the
// end-to-end wiring proof: register the role on a real Group, attach a
// metadata-role client, call g.SetPlaybackState("stopped") then
// g.SetPlaybackState("playing"), assert the client received the broadcast
// via the dispatcher path.
func TestMetadataRole_OnPlaybackStateChanged_FiresFromGroupEvent(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) {
return "E2E Title", "E2E Artist", "E2E Album"
},
ClockMicros: func() int64 { return 7 },
})
g.RegisterRole(meta)
sc := &ServerClient{
id: "meta-c",
name: "metadata client",
roles: []string{"metadata@v1"},
sendChan: make(chan interface{}, 10),
}
g.addClient(sc)
<-sc.sendChan // group/update
// Drain join-time server/state.
time.Sleep(20 * time.Millisecond)
select {
case msg := <-sc.sendChan:
t.Logf("drained join-time send: %+v", msg)
default:
}
// Default playback state is "playing"; transition through stopped to
// trigger an actual stopped→playing edge.
g.SetPlaybackState("stopped")
g.SetPlaybackState("playing")
// Wait for the dispatcher to deliver, then assert.
deadline := time.After(200 * time.Millisecond)
for {
select {
case raw := <-sc.sendChan:
msg, ok := raw.(protocol.Message)
if !ok {
t.Fatalf("queued send was %T, want protocol.Message", raw)
}
if msg.Type != "server/state" {
continue
}
state, ok := msg.Payload.(protocol.ServerStateMessage)
if !ok {
t.Fatalf("Message.Payload was %T, want ServerStateMessage", msg.Payload)
}
t.Logf("E2E broadcast received: type=%s metadata=%+v", msg.Type, state.Metadata)
if state.Metadata == nil || state.Metadata.Title == nil || *state.Metadata.Title != "E2E Title" {
t.Errorf("E2E broadcast title = %v, want \"E2E Title\"", state.Metadata)
}
return
case <-deadline:
t.Fatal("timed out waiting for E2E broadcast via SetPlaybackState")
}
}
}
// TestMetadataRole_BroadcastMetadata_ConcurrentCallsAreSafe runs many
// parallel BroadcastMetadata calls and asserts no panics, no races, and
// every call lands at least one send on the metadata client. The fake
// client's sendChan has capacity 200; the test bounds itself to fewer
// broadcasts so we don't need to drain in parallel.
func TestMetadataRole_BroadcastMetadata_ConcurrentCallsAreSafe(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) { return "t", "a", "al" },
ClockMicros: func() int64 { return 0 },
})
g.RegisterRole(meta)
sc := &ServerClient{
id: "meta-c",
roles: []string{"metadata@v1"},
sendChan: make(chan interface{}, 200),
}
g.addClient(sc)
<-sc.sendChan
// Drain any join-time send.
time.Sleep(20 * time.Millisecond)
select {
case <-sc.sendChan:
default:
}
const callers = 10
const perCaller = 5
var wg sync.WaitGroup
for i := 0; i < callers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < perCaller; j++ {
meta.BroadcastMetadata()
}
}()
}
wg.Wait()
count := 0
loop:
for {
select {
case <-sc.sendChan:
count++
default:
break loop
}
}
t.Logf("concurrent broadcasts queued %d sends (callers=%d, perCaller=%d)", count, callers, perCaller)
if count == 0 {
t.Error("expected at least one queued send from concurrent broadcasts, got 0")
}
}