260 lines
8.0 KiB
Go
260 lines
8.0 KiB
Go
// ABOUTME: Tests for refactored Player composing Receiver
|
|
// ABOUTME: Verifies backward-compatible API and new ProcessCallback
|
|
package sendspin
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNewPlayer_Defaults(t *testing.T) {
|
|
player, err := NewPlayer(PlayerConfig{
|
|
ServerAddr: "localhost:8927",
|
|
PlayerName: "Test Player",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if player == nil {
|
|
t.Fatal("expected non-nil player")
|
|
}
|
|
if player.receiver != nil {
|
|
t.Error("receiver should be nil before Connect")
|
|
}
|
|
}
|
|
|
|
func TestNewPlayer_ProcessCallbackStored(t *testing.T) {
|
|
player, _ := NewPlayer(PlayerConfig{
|
|
ServerAddr: "localhost:8927",
|
|
PlayerName: "Test Player",
|
|
ProcessCallback: func(samples []int32) {},
|
|
})
|
|
if player.config.ProcessCallback == nil {
|
|
t.Error("expected ProcessCallback to be stored in config")
|
|
}
|
|
}
|
|
|
|
// TestNewPlayer_DeviceInfoPassesThrough guards #48: --manufacturer and
|
|
// --product-name CLI flags write into PlayerConfig.DeviceInfo, and must
|
|
// survive the trip into the underlying Receiver without being replaced
|
|
// by the library defaults.
|
|
func TestNewPlayer_DeviceInfoPassesThrough(t *testing.T) {
|
|
custom := DeviceInfo{
|
|
ProductName: "Custom Product",
|
|
Manufacturer: "Custom Mfg",
|
|
SoftwareVersion: "9.9.9",
|
|
}
|
|
player, err := NewPlayer(PlayerConfig{
|
|
ServerAddr: "localhost:8927",
|
|
PlayerName: "Test",
|
|
DeviceInfo: custom,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewPlayer: %v", err)
|
|
}
|
|
if player.config.DeviceInfo != custom {
|
|
t.Errorf("config.DeviceInfo = %+v, want %+v", player.config.DeviceInfo, custom)
|
|
}
|
|
}
|
|
|
|
// TestNewPlayer_StaticDelayStored guards #47: PlayerConfig.StaticDelayMs
|
|
// must be preserved on the Player so Connect can plumb it into Receiver
|
|
// and from there into Scheduler.
|
|
func TestNewPlayer_StaticDelayStored(t *testing.T) {
|
|
player, err := NewPlayer(PlayerConfig{
|
|
ServerAddr: "localhost:8927",
|
|
PlayerName: "Test",
|
|
StaticDelayMs: 250,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewPlayer: %v", err)
|
|
}
|
|
if player.config.StaticDelayMs != 250 {
|
|
t.Errorf("config.StaticDelayMs = %d, want 250", player.config.StaticDelayMs)
|
|
}
|
|
}
|
|
|
|
// TestReceiver_StaticDelayDefaultZero sanity-checks that a ReceiverConfig
|
|
// without StaticDelayMs set produces a scheduler with zero offset. This is
|
|
// the test that would have flagged the new field if a future refactor
|
|
// stopped plumbing it through. Uses a short connect attempt + teardown
|
|
// because we don't want to actually dial anything.
|
|
func TestReceiver_StaticDelayDefaultZero(t *testing.T) {
|
|
recv, err := NewReceiver(ReceiverConfig{
|
|
ServerAddr: "localhost:0",
|
|
PlayerName: "Test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewReceiver: %v", err)
|
|
}
|
|
defer recv.Close()
|
|
if recv.config.StaticDelayMs != 0 {
|
|
t.Errorf("default StaticDelayMs = %d, want 0", recv.config.StaticDelayMs)
|
|
}
|
|
}
|
|
|
|
// TestNewPlayer_ReconnectDefaultsApplied guards the reconnect backoff
|
|
// defaults (#38). When the caller enables reconnect but leaves timing
|
|
// fields zero, NewPlayer must fill them with the documented defaults so
|
|
// an accidentally-zero delay never spin-loops.
|
|
func TestNewPlayer_ReconnectDefaultsApplied(t *testing.T) {
|
|
player, err := NewPlayer(PlayerConfig{
|
|
ServerAddr: "localhost:8927",
|
|
PlayerName: "Test",
|
|
Reconnect: ReconnectConfig{Enabled: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewPlayer: %v", err)
|
|
}
|
|
rc := player.config.Reconnect
|
|
if rc.InitialDelay != 500*time.Millisecond {
|
|
t.Errorf("InitialDelay = %v, want 500ms", rc.InitialDelay)
|
|
}
|
|
if rc.MaxDelay != 30*time.Second {
|
|
t.Errorf("MaxDelay = %v, want 30s", rc.MaxDelay)
|
|
}
|
|
if rc.Multiplier != 2.0 {
|
|
t.Errorf("Multiplier = %v, want 2.0", rc.Multiplier)
|
|
}
|
|
if rc.MaxAttempts != 0 {
|
|
t.Errorf("MaxAttempts = %d, want 0 (infinite)", rc.MaxAttempts)
|
|
}
|
|
}
|
|
|
|
// TestNewPlayer_ReconnectDefaultsSkippedWhenDisabled makes sure we don't
|
|
// silently turn reconnect on. If Enabled is false we leave the zero values
|
|
// alone — there is no supervisor goroutine to read them anyway.
|
|
func TestNewPlayer_ReconnectDefaultsSkippedWhenDisabled(t *testing.T) {
|
|
player, _ := NewPlayer(PlayerConfig{
|
|
ServerAddr: "localhost:8927",
|
|
PlayerName: "Test",
|
|
})
|
|
if player.config.Reconnect.Enabled {
|
|
t.Error("Reconnect.Enabled should default to false")
|
|
}
|
|
if player.config.Reconnect.InitialDelay != 0 {
|
|
t.Error("InitialDelay should not be populated when Reconnect.Enabled is false")
|
|
}
|
|
}
|
|
|
|
// TestNewPlayer_ReconnectRediscoverCallbackStored confirms the closure
|
|
// survives into player.config so the reconnect supervisor can call it.
|
|
func TestNewPlayer_ReconnectRediscoverCallbackStored(t *testing.T) {
|
|
called := false
|
|
player, _ := NewPlayer(PlayerConfig{
|
|
ServerAddr: "localhost:8927",
|
|
PlayerName: "Test",
|
|
Reconnect: ReconnectConfig{
|
|
Enabled: true,
|
|
Rediscover: func(ctx context.Context) (string, error) {
|
|
called = true
|
|
return "other:1234", nil
|
|
},
|
|
},
|
|
})
|
|
if player.config.Reconnect.Rediscover == nil {
|
|
t.Fatal("Rediscover callback not stored")
|
|
}
|
|
addr, err := player.config.Reconnect.Rediscover(context.Background())
|
|
if err != nil || addr != "other:1234" || !called {
|
|
t.Errorf("callback not invoked correctly: addr=%q err=%v called=%v", addr, err, called)
|
|
}
|
|
}
|
|
|
|
// TestJitter stays inside the ±frac band. With frac=0.2 and a 1s delay,
|
|
// the result must always fall within [800ms, 1200ms].
|
|
func TestJitter(t *testing.T) {
|
|
base := 1 * time.Second
|
|
for i := 0; i < 100; i++ {
|
|
got := jitter(base, 0.2)
|
|
if got < 800*time.Millisecond || got > 1200*time.Millisecond {
|
|
t.Errorf("jitter(%v, 0.2) = %v, outside ±20%% band", base, got)
|
|
}
|
|
}
|
|
if jitter(0, 0.2) != 0 {
|
|
t.Error("jitter(0, _) should return 0")
|
|
}
|
|
}
|
|
|
|
func TestPlayer_StatusBeforeConnect(t *testing.T) {
|
|
player, _ := NewPlayer(PlayerConfig{
|
|
ServerAddr: "localhost:8927",
|
|
PlayerName: "Test Player",
|
|
Volume: 80,
|
|
})
|
|
|
|
status := player.Status()
|
|
if status.Volume != 80 {
|
|
t.Errorf("expected volume 80, got %d", status.Volume)
|
|
}
|
|
if status.Connected {
|
|
t.Error("expected not connected before Connect()")
|
|
}
|
|
if status.State != "idle" {
|
|
t.Errorf("expected state idle, got %s", status.State)
|
|
}
|
|
}
|
|
|
|
// TestPlayer_EnsureCapsResolved_ExplicitOverrideSkipsProbe asserts replace
|
|
// semantics: if the user has set either MaxSampleRate or MaxBitDepth,
|
|
// ensureCapsResolved must NOT overwrite the other field via probe.
|
|
// Explicit user choice wins entirely, even when only one of the two
|
|
// fields is non-zero.
|
|
func TestPlayer_EnsureCapsResolved_ExplicitOverrideSkipsProbe(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
rate int
|
|
depth int
|
|
wantRate int
|
|
wantDepth int
|
|
}{
|
|
{"both set", 48000, 16, 48000, 16},
|
|
{"rate only", 96000, 0, 96000, 0},
|
|
{"depth only", 0, 16, 0, 16},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
player, _ := NewPlayer(PlayerConfig{
|
|
ServerAddr: "localhost:8927",
|
|
PlayerName: "Test",
|
|
MaxSampleRate: tt.rate,
|
|
MaxBitDepth: tt.depth,
|
|
})
|
|
player.ensureCapsResolved()
|
|
if player.config.MaxSampleRate != tt.wantRate {
|
|
t.Errorf("MaxSampleRate = %d, want %d (probe must not run)",
|
|
player.config.MaxSampleRate, tt.wantRate)
|
|
}
|
|
if player.config.MaxBitDepth != tt.wantDepth {
|
|
t.Errorf("MaxBitDepth = %d, want %d (probe must not run)",
|
|
player.config.MaxBitDepth, tt.wantDepth)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPlayer_EnsureCapsResolved_RunsOnce guards reconnect behavior: the
|
|
// caps are probed at most once. The cached flag is set even on probe-skip
|
|
// paths so subsequent reconnects don't re-probe miniaudio either.
|
|
func TestPlayer_EnsureCapsResolved_RunsOnce(t *testing.T) {
|
|
player, _ := NewPlayer(PlayerConfig{
|
|
ServerAddr: "localhost:8927",
|
|
PlayerName: "Test",
|
|
MaxSampleRate: 48000, // user-set, so no probe
|
|
})
|
|
player.ensureCapsResolved()
|
|
if !player.capsResolved {
|
|
t.Error("capsResolved should be true after first call")
|
|
}
|
|
// Mutating MaxSampleRate after first call must not be re-resolved by a
|
|
// second invocation — proves the early return on capsResolved fires.
|
|
player.config.MaxSampleRate = 99999
|
|
player.ensureCapsResolved()
|
|
if player.config.MaxSampleRate != 99999 {
|
|
t.Errorf("second call should be no-op; got MaxSampleRate=%d",
|
|
player.config.MaxSampleRate)
|
|
}
|
|
}
|