Files
-rpi-sendspin/third_party/sendspin-go/pkg/sendspin/client_dialer_test.go
Stanislav Fifik 6e22834c5c Fix ESP32 audio dropouts and improve streaming reliability
FLAC encoder now tracks frame numbers and reuses per-channel sample
buffers to reduce allocations. Client dialer always frees the active
slot on release so mDNS re-emissions can reconnect cleanly. Server
send buffer uses drop-oldest instead of hard failure, with lateness
tracking. Added engine stats logging (chunks/sec, kbps) and writer
diagnostics. UI simplified to a single play/pause toggle that
auto-starts playback on preset selection. Added --preset CLI flag
for headless startup. Removed stale systemd service files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-16 14:50:30 +02:00

165 lines
4.4 KiB
Go

// ABOUTME: Tests for outbound client discovery and dialing
// ABOUTME: Uses injected dial functions — no real network I/O
package sendspin
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Sendspin/sendspin-go/internal/discovery"
)
func TestClientInfoURL(t *testing.T) {
tests := []struct {
name string
info *discovery.ClientInfo
want string
}{
{
name: "ipv4 host",
info: &discovery.ClientInfo{Host: "192.168.1.42", Port: 8928, Path: "/sendspin"},
want: "ws://192.168.1.42:8928/sendspin",
},
{
name: "path missing leading slash is normalized",
info: &discovery.ClientInfo{Host: "192.168.1.42", Port: 8928, Path: "sendspin"},
want: "ws://192.168.1.42:8928/sendspin",
},
{
name: "custom path preserved",
info: &discovery.ClientInfo{Host: "10.0.0.5", Port: 9000, Path: "/custom/endpoint"},
want: "ws://10.0.0.5:9000/custom/endpoint",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := clientInfoURL(tt.info)
if got != tt.want {
t.Errorf("clientInfoURL = %q, want %q", got, tt.want)
}
})
}
}
func TestClientDialerDedupesByInstance(t *testing.T) {
in := make(chan *discovery.ClientInfo, 10)
var dialCalls int32
var mu sync.Mutex
var seen []string
// Block each dial until the test releases it, so concurrent
// duplicate emissions for the same instance arrive while the
// dial is in flight — which is what the dedupe protects against.
gate := make(chan struct{})
dial := func(ctx context.Context, info *discovery.ClientInfo) error {
atomic.AddInt32(&dialCalls, 1)
mu.Lock()
seen = append(seen, info.Instance)
mu.Unlock()
<-gate
return nil
}
d := newClientDialer(in, dial)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
d.run(ctx)
close(done)
}()
// Emit the same instance 3 times and a different instance once,
// all while dials are blocked in `gate`.
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
in <- &discovery.ClientInfo{Instance: "b._sendspin._tcp.local.", Host: "1.1.1.2", Port: 8928, Path: "/sendspin"}
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
waitForCalls(t, &dialCalls, 2, 500*time.Millisecond)
// Give any extra (incorrect) calls a chance to fire while dials
// are still blocked.
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&dialCalls); got != 2 {
t.Errorf("dialCalls = %d, want 2 (one per unique instance while in flight)", got)
}
mu.Lock()
if len(seen) != 2 {
mu.Unlock()
t.Fatalf("seen = %v, want 2 entries", seen)
}
mu.Unlock()
close(gate)
cancel()
<-done
}
func waitForCalls(t *testing.T, counter *int32, want int32, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if atomic.LoadInt32(counter) >= want {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("timed out waiting for %d calls, got %d", want, atomic.LoadInt32(counter))
}
func TestClientDialerBackoffOnFailure(t *testing.T) {
in := make(chan *discovery.ClientInfo, 10)
var dialCalls int32
dial := func(ctx context.Context, info *discovery.ClientInfo) error {
atomic.AddInt32(&dialCalls, 1)
return errors.New("boom")
}
d := newClientDialer(in, dial)
// Override backoff clock to keep the test fast
d.baseBackoff = 20 * time.Millisecond
d.maxBackoff = 80 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
d.run(ctx)
close(done)
}()
info := &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
// First event — should dial immediately
in <- info
waitForCalls(t, &dialCalls, 1, 200*time.Millisecond)
// Immediately re-emit — should be rejected by backoff
in <- info
time.Sleep(5 * time.Millisecond)
if got := atomic.LoadInt32(&dialCalls); got != 1 {
t.Errorf("dialCalls after immediate retry = %d, want 1 (blocked by backoff)", got)
}
// Wait past base backoff, re-emit — should dial again
time.Sleep(30 * time.Millisecond)
in <- info
waitForCalls(t, &dialCalls, 2, 200*time.Millisecond)
cancel()
<-done
}