🎉 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,595 @@
// ABOUTME: Tests for Receiver type construction and basic lifecycle
package sendspin
import (
"encoding/json"
"fmt"
"sync"
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio"
"github.com/Sendspin/sendspin-go/pkg/audio/decode"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
func TestNewReceiver_Defaults(t *testing.T) {
config := ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test Receiver",
}
r, err := NewReceiver(config)
if err != nil {
t.Fatalf("NewReceiver failed: %v", err)
}
defer r.Close()
if r == nil {
t.Fatal("Expected non-nil Receiver")
}
if r.clockSync == nil {
t.Error("Expected clockSync to be initialized")
}
if r.Output() == nil {
t.Error("Expected Output() channel to be non-nil")
}
if r.config.BufferMs != 500 {
t.Errorf("Expected default BufferMs=500, got %d", r.config.BufferMs)
}
if r.config.DeviceInfo.ProductName == "" {
t.Error("Expected default DeviceInfo.ProductName to be set")
}
if r.config.DeviceInfo.Manufacturer == "" {
t.Error("Expected default DeviceInfo.Manufacturer to be set")
}
if r.config.DeviceInfo.SoftwareVersion == "" {
t.Error("Expected default DeviceInfo.SoftwareVersion to be set")
}
}
func TestNewReceiver_RequiresServerAddr(t *testing.T) {
config := ReceiverConfig{
PlayerName: "Test Receiver",
// ServerAddr intentionally omitted
}
r, err := NewReceiver(config)
if err == nil {
t.Error("Expected error when ServerAddr is empty")
if r != nil {
r.Close()
}
}
if r != nil {
t.Error("Expected nil Receiver on error")
}
}
func TestReceiver_Connect_BadAddress(t *testing.T) {
recv, _ := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:99999",
PlayerName: "Test",
})
err := recv.Connect()
if err == nil {
t.Fatal("expected connection error for bad address")
}
}
func TestReceiver_ClockSyncIsOwnInstance(t *testing.T) {
config1 := ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Receiver One",
}
config2 := ReceiverConfig{
ServerAddr: "localhost:8928",
PlayerName: "Receiver Two",
}
r1, err := NewReceiver(config1)
if err != nil {
t.Fatalf("NewReceiver r1 failed: %v", err)
}
defer r1.Close()
r2, err := NewReceiver(config2)
if err != nil {
t.Fatalf("NewReceiver r2 failed: %v", err)
}
defer r2.Close()
if r1.ClockSync() == r2.ClockSync() {
t.Error("Expected each Receiver to have its own ClockSync instance")
}
}
func TestReceiver_CloseBeforeConnect(t *testing.T) {
recv, _ := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
})
// Should not panic
err := recv.Close()
if err != nil {
t.Fatalf("unexpected error closing unconnected receiver: %v", err)
}
}
func TestReceiver_StatsBeforeConnect(t *testing.T) {
recv, _ := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
})
stats := recv.Stats()
if stats.Received != 0 || stats.Played != 0 || stats.Dropped != 0 {
t.Error("expected zero stats before connect")
}
}
func TestReceiver_OnStreamStartCallback(t *testing.T) {
var receivedFormat audio.Format
_, err := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
OnStreamStart: func(f audio.Format) {
receivedFormat = f
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Callback is stored but not invoked until stream starts
if receivedFormat.Codec != "" {
t.Error("expected empty format before stream start")
}
}
func TestReceiver_CustomDecoderFactory(t *testing.T) {
factoryCalled := false
recv, _ := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
DecoderFactory: func(f audio.Format) (decode.Decoder, error) {
factoryCalled = true
return nil, fmt.Errorf("test decoder")
},
})
if recv.config.DecoderFactory == nil {
t.Error("expected DecoderFactory to be stored")
}
if factoryCalled {
t.Error("factory should not be called before connect")
}
}
// maxRateAndDepth scans formats and returns the largest sample rate and bit
// depth present. Test helper: lets assertions describe the cap by intent
// ("nothing above 48k/16") rather than counting list entries.
func maxRateAndDepth(formats []protocol.AudioFormat) (int, int) {
var maxRate, maxDepth int
for _, f := range formats {
if f.SampleRate > maxRate {
maxRate = f.SampleRate
}
if f.BitDepth > maxDepth {
maxDepth = f.BitDepth
}
}
return maxRate, maxDepth
}
func TestBuildSupportedFormats_NoCaps(t *testing.T) {
got := buildSupportedFormats("", 0, 0)
if len(got) == 0 {
t.Fatal("expected non-empty format list with no caps")
}
maxRate, maxDepth := maxRateAndDepth(got)
if maxRate != 192000 {
t.Errorf("expected max rate 192000 with no caps, got %d", maxRate)
}
if maxDepth != 24 {
t.Errorf("expected max depth 24 with no caps, got %d", maxDepth)
}
}
func TestBuildSupportedFormats_RateCap(t *testing.T) {
got := buildSupportedFormats("", 48000, 0)
if len(got) == 0 {
t.Fatal("expected non-empty list with 48000Hz cap")
}
for _, f := range got {
if f.SampleRate > 48000 {
t.Errorf("expected no rate > 48000, got %s @ %dHz", f.Codec, f.SampleRate)
}
}
// Boundary: 48000Hz formats must survive.
hasFortyEight := false
for _, f := range got {
if f.SampleRate == 48000 {
hasFortyEight = true
break
}
}
if !hasFortyEight {
t.Error("expected at least one 48000Hz format to survive the cap")
}
}
func TestBuildSupportedFormats_BitDepthCap(t *testing.T) {
got := buildSupportedFormats("", 0, 16)
if len(got) == 0 {
t.Fatal("expected non-empty list with 16-bit cap")
}
for _, f := range got {
if f.BitDepth > 16 {
t.Errorf("expected no depth > 16, got %s @ %d-bit", f.Codec, f.BitDepth)
}
}
}
func TestBuildSupportedFormats_BothCaps(t *testing.T) {
got := buildSupportedFormats("", 48000, 16)
if len(got) == 0 {
t.Fatal("expected non-empty list with 48k/16 caps")
}
for _, f := range got {
if f.SampleRate > 48000 || f.BitDepth > 16 {
t.Errorf("expected no entries beyond 48000Hz/16-bit, got %s @ %dHz/%d-bit",
f.Codec, f.SampleRate, f.BitDepth)
}
}
}
func TestBuildSupportedFormats_PreferredCodecAfterFilter(t *testing.T) {
// Filter eliminates high-res FLAC; the survivor should still be ordered
// with FLAC at the front when preferredCodec="flac".
got := buildSupportedFormats("flac", 48000, 24)
if len(got) == 0 {
t.Fatal("expected non-empty list")
}
if got[0].Codec != "flac" {
t.Errorf("expected preferred codec at front, got %s", got[0].Codec)
}
}
func TestBuildSupportedFormats_CapsAtExactBoundary(t *testing.T) {
// maxSampleRate=192000, maxBitDepth=24 should keep everything.
full := buildSupportedFormats("", 0, 0)
bounded := buildSupportedFormats("", 192000, 24)
if len(bounded) != len(full) {
t.Errorf("boundary cap should keep all formats: full=%d bounded=%d", len(full), len(bounded))
}
}
func TestBuildSupportedFormats_AggressiveCapEmpty(t *testing.T) {
// User asks for ≤ 8000Hz / ≤ 8-bit. Nothing in our list matches; we
// honestly return empty rather than fabricating a fallback. The handshake
// will fail, which is the right user-visible signal that the cap is wrong.
got := buildSupportedFormats("", 8000, 8)
if len(got) != 0 {
t.Errorf("expected empty list for impossible caps, got %d entries", len(got))
}
}
// metadataStateWithKeys builds a MetadataState by JSON-marshaling the input
// map and decoding through the real wire path so presentKeys is populated
// correctly. This is the only way to construct a MetadataState whose
// HasField returns false for unspecified keys, since presentKeys is
// unexported. Tests that rely on tristate semantics must use this helper.
func metadataStateWithKeys(t *testing.T, fields map[string]any) *protocol.MetadataState {
t.Helper()
data, err := json.Marshal(fields)
if err != nil {
t.Fatalf("metadataStateWithKeys: marshal: %v", err)
}
var m protocol.MetadataState
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("metadataStateWithKeys: unmarshal: %v", err)
}
return &m
}
// receiverForMetadataTests builds a Receiver with a fixed fake clock and an
// OnMetadata callback that captures every snapshot. The captured slice is
// guarded by its own mutex because OnMetadata may be invoked from either
// the channel reader goroutine or the apply-loop goroutine.
type metadataCapture struct {
mu sync.Mutex
captured []Metadata
}
func (mc *metadataCapture) record(m Metadata) {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.captured = append(mc.captured, m)
}
func (mc *metadataCapture) latest() (Metadata, bool) {
mc.mu.Lock()
defer mc.mu.Unlock()
if len(mc.captured) == 0 {
return Metadata{}, false
}
return mc.captured[len(mc.captured)-1], true
}
func (mc *metadataCapture) count() int {
mc.mu.Lock()
defer mc.mu.Unlock()
return len(mc.captured)
}
// newMetadataReceiver returns a Receiver wired up for direct enqueue/apply
// testing — no Connect, no protocol client, no scheduler. clockNow is
// overridden to a fixed value so timestamp-deferral tests are deterministic.
func newMetadataReceiver(t *testing.T, fakeNow int64, mc *metadataCapture) *Receiver {
t.Helper()
r, err := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Metadata Test",
OnMetadata: mc.record,
})
if err != nil {
t.Fatalf("NewReceiver: %v", err)
}
r.clockNow = func() int64 { return fakeNow }
t.Cleanup(func() { _ = r.Close() })
return r
}
// TestReceiver_MetadataMergePreservesUnchangedFields is the headline
// regression: a track A snapshot followed by a track B diff_update that
// only carries `title` must not blank out artist/album. Pre-fix, the
// receiver dereffed nil pointers and dispatched empty strings for both.
func TestReceiver_MetadataMergePreservesUnchangedFields(t *testing.T) {
mc := &metadataCapture{}
r := newMetadataReceiver(t, 0, mc)
// Snapshot: title + artist + album set.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"title": "Song A",
"artist": "Artist X",
"album": "Album Y",
}))
// Diff: only title changes. Artist + album omitted → must preserve.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"title": "Song B",
}))
got, ok := mc.latest()
if !ok {
t.Fatal("OnMetadata was not invoked")
}
t.Logf("merged snapshot after diff: %+v", got)
if got.Title != "Song B" {
t.Errorf("Title = %q, want %q", got.Title, "Song B")
}
if got.Artist != "Artist X" {
t.Errorf("Artist = %q, want %q (must be preserved across diff)", got.Artist, "Artist X")
}
if got.Album != "Album Y" {
t.Errorf("Album = %q, want %q (must be preserved across diff)", got.Album, "Album Y")
}
if mc.count() != 2 {
t.Errorf("OnMetadata invocation count = %d, want 2", mc.count())
}
}
// TestReceiver_MetadataNullClearsField verifies the "null" leg of the
// tristate contract: a wire-explicit null must reset the prior value.
func TestReceiver_MetadataNullClearsField(t *testing.T) {
mc := &metadataCapture{}
r := newMetadataReceiver(t, 0, mc)
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"title": "Song A",
"artist": "Artist X",
"artwork_url": "http://example/a.jpg",
}))
// Explicit null on artwork_url and artist: must clear both.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"artist": nil,
"artwork_url": nil,
}))
got, ok := mc.latest()
if !ok {
t.Fatal("OnMetadata was not invoked")
}
t.Logf("merged snapshot after null-clear: %+v", got)
if got.Title != "Song A" {
t.Errorf("Title = %q, want %q (omitted, should be preserved)", got.Title, "Song A")
}
if got.Artist != "" {
t.Errorf("Artist = %q, want empty (null on wire clears it)", got.Artist)
}
if got.ArtworkURL != "" {
t.Errorf("ArtworkURL = %q, want empty (null on wire clears it)", got.ArtworkURL)
}
}
// TestReceiver_MetadataFutureTimestampDeferred verifies a future-dated
// update is held in pendingMetadata until the fake clock advances past
// the timestamp, then drainPendingMetadata flushes it.
func TestReceiver_MetadataFutureTimestampDeferred(t *testing.T) {
mc := &metadataCapture{}
// Fake clock starts at server-time = 1000 µs.
r, err := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Defer Test",
OnMetadata: mc.record,
})
if err != nil {
t.Fatalf("NewReceiver: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
var now int64 = 1000
r.clockNow = func() int64 { return now }
// Apply-now snapshot to set baseline.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"title": "Now Playing",
"artist": "Current Artist",
}))
if mc.count() != 1 {
t.Fatalf("expected 1 immediate apply, got %d", mc.count())
}
// Future-dated update at server-time = 5000 µs. Should NOT apply yet.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 5000,
"title": "Up Next",
}))
if mc.count() != 1 {
t.Errorf("future-dated update applied early; OnMetadata count = %d, want 1", mc.count())
}
r.metadataMu.Lock()
pendingLen := len(r.pendingMetadata)
r.metadataMu.Unlock()
if pendingLen != 1 {
t.Errorf("pendingMetadata length = %d, want 1", pendingLen)
}
// Drain at clock = 4000 µs (still before timestamp): nothing applies.
now = 4000
r.drainPendingMetadata()
if mc.count() != 1 {
t.Errorf("drain at clock<timestamp applied prematurely; count = %d, want 1", mc.count())
}
// Advance clock past the timestamp; drain should flush the queued update.
now = 5500
r.drainPendingMetadata()
if mc.count() != 2 {
t.Fatalf("expected drain to apply 1 deferred update; count = %d, want 2", mc.count())
}
got, _ := mc.latest()
t.Logf("post-defer-flush snapshot: %+v", got)
if got.Title != "Up Next" {
t.Errorf("Title = %q, want %q", got.Title, "Up Next")
}
// Artist was omitted in the future-dated diff → must still be the
// baseline value from the first update.
if got.Artist != "Current Artist" {
t.Errorf("Artist = %q, want %q (omitted in diff, preserved)", got.Artist, "Current Artist")
}
// Pending must be empty after drain.
r.metadataMu.Lock()
pendingLen = len(r.pendingMetadata)
r.metadataMu.Unlock()
if pendingLen != 0 {
t.Errorf("pendingMetadata length after drain = %d, want 0", pendingLen)
}
}
// TestReceiver_MetadataPendingSortedByTimestamp confirms enqueue maintains
// ascending-timestamp order even when callers send out-of-order updates.
// drainPendingMetadata depends on this to short-circuit at the first
// future-dated entry.
func TestReceiver_MetadataPendingSortedByTimestamp(t *testing.T) {
mc := &metadataCapture{}
r, err := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Sort Test",
OnMetadata: mc.record,
})
if err != nil {
t.Fatalf("NewReceiver: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
r.clockNow = func() int64 { return 0 }
// Enqueue three future-dated updates out of order.
for _, ts := range []int64{500, 100, 300} {
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": ts,
"title": fmt.Sprintf("ts-%d", ts),
}))
}
r.metadataMu.Lock()
got := make([]int64, len(r.pendingMetadata))
for i, m := range r.pendingMetadata {
got[i] = m.Timestamp
}
r.metadataMu.Unlock()
t.Logf("pending timestamps after out-of-order enqueue: %v", got)
want := []int64{100, 300, 500}
if len(got) != len(want) {
t.Fatalf("pending length = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("pending[%d].Timestamp = %d, want %d", i, got[i], want[i])
}
}
}
// TestReceiver_MetadataProgressTristate exercises the progress field
// across set / null / omitted, since it's the only nested struct in the
// merge path. Per spec, progress is atomic — a non-null value always
// carries TrackDuration; a null clears Duration; an omitted progress
// preserves the prior Duration.
func TestReceiver_MetadataProgressTristate(t *testing.T) {
mc := &metadataCapture{}
r := newMetadataReceiver(t, 0, mc)
// Set progress with track_duration = 60_000 ms (= 60s).
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"progress": map[string]any{
"track_progress": 0,
"track_duration": 60000,
"playback_speed": 1000,
},
}))
got, _ := mc.latest()
if got.Duration != 60 {
t.Errorf("Duration = %d, want 60 (60000ms / 1000)", got.Duration)
}
// Diff with progress omitted → Duration preserved.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"title": "still playing",
}))
got, _ = mc.latest()
if got.Duration != 60 {
t.Errorf("Duration after omitted progress = %d, want 60 preserved", got.Duration)
}
// Explicit null progress → Duration cleared.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"progress": nil,
}))
got, _ = mc.latest()
t.Logf("snapshot after null progress: %+v", got)
if got.Duration != 0 {
t.Errorf("Duration after null progress = %d, want 0", got.Duration)
}
}