268 lines
6.6 KiB
Go
268 lines
6.6 KiB
Go
package playback
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type queuedAudioFactory struct {
|
|
readers []AudioReader
|
|
errs []error
|
|
calls int
|
|
}
|
|
|
|
func (f *queuedAudioFactory) OpenAudio(
|
|
context.Context,
|
|
FeedConfig,
|
|
) (AudioReader, error) {
|
|
index := f.calls
|
|
f.calls++
|
|
if index < len(f.errs) && f.errs[index] != nil {
|
|
return nil, f.errs[index]
|
|
}
|
|
if index < len(f.readers) {
|
|
return f.readers[index], nil
|
|
}
|
|
return nil, errors.New("unexpected audio open")
|
|
}
|
|
|
|
func newAudioWorkerForTest(
|
|
t *testing.T,
|
|
factory AudioReaderFactory,
|
|
sink AudioSink,
|
|
maxAttempts int,
|
|
shouldRetry func(error) bool,
|
|
observer StatusObserver,
|
|
) *AudioWorker {
|
|
t.Helper()
|
|
worker, err := NewAudioWorker(
|
|
factory,
|
|
sink,
|
|
testRetryPolicy(maxAttempts),
|
|
shouldRetry,
|
|
observer,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewAudioWorker() error = %v", err)
|
|
}
|
|
worker.wait = func(context.Context, time.Duration) error { return nil }
|
|
return worker
|
|
}
|
|
|
|
func TestNewAudioWorkerValidatesDependencies(t *testing.T) {
|
|
factory := &fakeAudioFactory{}
|
|
sink := &fakeAudioSink{}
|
|
retry := testRetryPolicy(3)
|
|
decider := func(error) bool { return true }
|
|
|
|
tests := []struct {
|
|
name string
|
|
factory AudioReaderFactory
|
|
sink AudioSink
|
|
retry RetryPolicy
|
|
decider func(error) bool
|
|
wantErr error
|
|
}{
|
|
{name: "factory", sink: sink, retry: retry, decider: decider, wantErr: ErrAudioFactoryRequired},
|
|
{name: "sink", factory: factory, retry: retry, decider: decider, wantErr: ErrAudioSinkRequired},
|
|
{name: "decider", factory: factory, sink: sink, retry: retry, wantErr: ErrAudioRetryDeciderRequired},
|
|
{
|
|
name: "retry policy",
|
|
factory: factory,
|
|
sink: sink,
|
|
retry: RetryPolicy{},
|
|
decider: decider,
|
|
wantErr: ErrInvalidRetryDelay,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
worker, err := NewAudioWorker(
|
|
tt.factory,
|
|
tt.sink,
|
|
tt.retry,
|
|
tt.decider,
|
|
nil,
|
|
)
|
|
if worker != nil {
|
|
t.Fatalf("NewAudioWorker() worker = %#v, want nil", worker)
|
|
}
|
|
if !errors.Is(err, tt.wantErr) {
|
|
t.Fatalf("NewAudioWorker() error = %v, want %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAudioWorkerRejectsInactiveFeed(t *testing.T) {
|
|
worker := newAudioWorkerForTest(
|
|
t,
|
|
&fakeAudioFactory{},
|
|
&fakeAudioSink{},
|
|
1,
|
|
func(error) bool { return false },
|
|
nil,
|
|
)
|
|
|
|
err := worker.Run(
|
|
context.Background(),
|
|
FeedConfig{Domain: "/audio", UUID: "audio", Active: false},
|
|
)
|
|
if !errors.Is(err, ErrAudioFeedInactive) {
|
|
t.Fatalf("Run() error = %v, want %v", err, ErrAudioFeedInactive)
|
|
}
|
|
}
|
|
|
|
func TestAudioWorkerStatusesInheritGeneration(t *testing.T) {
|
|
openErr := errors.New("unavailable")
|
|
var statuses []Status
|
|
worker := newAudioWorkerForTest(
|
|
t,
|
|
&queuedAudioFactory{errs: []error{openErr}},
|
|
&fakeAudioSink{},
|
|
1,
|
|
func(error) bool { return true },
|
|
func(status Status) { statuses = append(statuses, status) },
|
|
)
|
|
config := FeedConfig{Domain: "/audio", UUID: "audio", Active: true}
|
|
_ = worker.Run(withGeneration(context.Background(), 8), config)
|
|
if len(statuses) == 0 {
|
|
t.Fatal("no statuses emitted")
|
|
}
|
|
for _, status := range statuses {
|
|
if status.Generation != 8 {
|
|
t.Fatalf("status generation = %d, want 8: %+v", status.Generation, status)
|
|
}
|
|
if status.Feed != config {
|
|
t.Fatalf("status feed = %#v, want %#v", status.Feed, config)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAudioWorkerPublishesPlayingThenFailed(t *testing.T) {
|
|
readErr := errors.New("audio disappeared")
|
|
reader := &fakeAudioReader{
|
|
frames: []AudioFrame{{Index: 1, Samples: [][]byte{{1, 2, 3, 4}}}},
|
|
readErr: readErr,
|
|
}
|
|
var statuses []Status
|
|
worker := newAudioWorkerForTest(
|
|
t,
|
|
&fakeAudioFactory{reader: reader},
|
|
&fakeAudioSink{},
|
|
1,
|
|
func(error) bool { return false },
|
|
func(status Status) { statuses = append(statuses, status) },
|
|
)
|
|
|
|
err := worker.Run(
|
|
context.Background(),
|
|
FeedConfig{Domain: "/audio", UUID: "audio", Active: true},
|
|
)
|
|
if !errors.Is(err, readErr) {
|
|
t.Fatalf("Run() error = %v, want %v", err, readErr)
|
|
}
|
|
wantStates := []State{StateConnecting, StatePlaying, StateFailed}
|
|
if len(statuses) != len(wantStates) {
|
|
t.Fatalf("status count = %d, want %d: %#v", len(statuses), len(wantStates), statuses)
|
|
}
|
|
for i, want := range wantStates {
|
|
if statuses[i].Unit != UnitAudio || statuses[i].State != want {
|
|
t.Errorf("status[%d] = %#v, want audio/%v", i, statuses[i], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAudioWorkerRetriesUpToAttemptLimit(t *testing.T) {
|
|
openErr := errors.New("audio unavailable")
|
|
factory := &queuedAudioFactory{errs: []error{openErr, openErr, openErr}}
|
|
var statuses []Status
|
|
worker := newAudioWorkerForTest(
|
|
t,
|
|
factory,
|
|
&fakeAudioSink{},
|
|
3,
|
|
func(error) bool { return true },
|
|
func(status Status) { statuses = append(statuses, status) },
|
|
)
|
|
|
|
err := worker.Run(
|
|
context.Background(),
|
|
FeedConfig{Domain: "/audio", UUID: "audio", Active: true},
|
|
)
|
|
if !errors.Is(err, openErr) {
|
|
t.Fatalf("Run() error = %v, want %v", err, openErr)
|
|
}
|
|
if factory.calls != 3 {
|
|
t.Fatalf("open calls = %d, want 3", factory.calls)
|
|
}
|
|
last := statuses[len(statuses)-1]
|
|
if last.State != StateFailed || last.Attempt != 3 || last.FailedAttempts != 3 {
|
|
t.Fatalf("last status = %#v, want failed attempt 3", last)
|
|
}
|
|
}
|
|
|
|
func TestAudioWorkerDoesNotRetrySinkFailure(t *testing.T) {
|
|
sinkErr := errors.New("output failed")
|
|
factory := &fakeAudioFactory{
|
|
reader: &fakeAudioReader{frames: []AudioFrame{{Index: 1}}},
|
|
}
|
|
worker := newAudioWorkerForTest(
|
|
t,
|
|
factory,
|
|
&fakeAudioSink{err: sinkErr},
|
|
3,
|
|
func(error) bool { return true },
|
|
nil,
|
|
)
|
|
|
|
err := worker.Run(
|
|
context.Background(),
|
|
FeedConfig{Domain: "/audio", UUID: "audio", Active: true},
|
|
)
|
|
if !errors.Is(err, sinkErr) {
|
|
t.Fatalf("Run() error = %v, want %v", err, sinkErr)
|
|
}
|
|
if factory.calls != 1 {
|
|
t.Fatalf("open calls = %d, want 1", factory.calls)
|
|
}
|
|
}
|
|
|
|
func TestAudioWorkerCancellationPublishesStoppingAndIdle(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
reader := &fakeAudioReader{
|
|
read: func(ctx context.Context) (AudioFrame, error) {
|
|
cancel()
|
|
<-ctx.Done()
|
|
return AudioFrame{}, ctx.Err()
|
|
},
|
|
}
|
|
var statuses []Status
|
|
worker := newAudioWorkerForTest(
|
|
t,
|
|
&fakeAudioFactory{reader: reader},
|
|
&fakeAudioSink{},
|
|
1,
|
|
func(error) bool { return true },
|
|
func(status Status) { statuses = append(statuses, status) },
|
|
)
|
|
|
|
err := worker.Run(
|
|
ctx,
|
|
FeedConfig{Domain: "/audio", UUID: "audio", Active: true},
|
|
)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
|
|
}
|
|
if len(statuses) < 3 {
|
|
t.Fatalf("statuses = %#v, want connecting, stopping, idle", statuses)
|
|
}
|
|
last := statuses[len(statuses)-2:]
|
|
if last[0].State != StateStopping || last[1].State != StateIdle {
|
|
t.Fatalf("final statuses = %#v, want stopping then idle", last)
|
|
}
|
|
}
|