Refactoring #3
@@ -0,0 +1,55 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type audioSinkError struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *audioSinkError) Error() string {
|
||||||
|
return fmt.Sprintf("consume audio: %v", e.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *audioSinkError) Unwrap() error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAudioAttempt(
|
||||||
|
ctx context.Context,
|
||||||
|
factory AudioReaderFactory,
|
||||||
|
sink AudioSink,
|
||||||
|
config FeedConfig,
|
||||||
|
) (resultErr error) {
|
||||||
|
reader, err := factory.OpenAudio(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open audio: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if closeErr := reader.Close(); closeErr != nil {
|
||||||
|
closeErr = fmt.Errorf("close audio: %w", closeErr)
|
||||||
|
resultErr = errors.Join(resultErr, closeErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
frame, err := reader.ReadAudio(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read audio: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sink.ConsumeAudio(ctx, frame); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return &audioSinkError{err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeAudioFactory struct {
|
||||||
|
reader AudioReader
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAudioFactory) OpenAudio(
|
||||||
|
context.Context,
|
||||||
|
FeedConfig,
|
||||||
|
) (AudioReader, error) {
|
||||||
|
f.calls++
|
||||||
|
return f.reader, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAudioReader struct {
|
||||||
|
frames []AudioFrame
|
||||||
|
readErr error
|
||||||
|
closeErr error
|
||||||
|
readCalls int
|
||||||
|
closed bool
|
||||||
|
read func(context.Context) (AudioFrame, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAudioReader) ReadAudio(ctx context.Context) (AudioFrame, error) {
|
||||||
|
r.readCalls++
|
||||||
|
if r.read != nil {
|
||||||
|
return r.read(ctx)
|
||||||
|
}
|
||||||
|
if len(r.frames) == 0 {
|
||||||
|
return AudioFrame{}, r.readErr
|
||||||
|
}
|
||||||
|
frame := r.frames[0]
|
||||||
|
r.frames = r.frames[1:]
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAudioReader) Close() error {
|
||||||
|
r.closed = true
|
||||||
|
return r.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAudioSink struct {
|
||||||
|
frames []AudioFrame
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeAudioSink) ConsumeAudio(_ context.Context, frame AudioFrame) error {
|
||||||
|
s.frames = append(s.frames, frame)
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAudioAttemptOpenFailure(t *testing.T) {
|
||||||
|
openErr := errors.New("open failed")
|
||||||
|
factory := &fakeAudioFactory{err: openErr}
|
||||||
|
sink := &fakeAudioSink{}
|
||||||
|
|
||||||
|
err := runAudioAttempt(context.Background(), factory, sink, FeedConfig{})
|
||||||
|
|
||||||
|
if !errors.Is(err, openErr) {
|
||||||
|
t.Fatalf("runAudioAttempt() error = %v, want %v", err, openErr)
|
||||||
|
}
|
||||||
|
if factory.calls != 1 {
|
||||||
|
t.Errorf("factory calls = %d, want 1", factory.calls)
|
||||||
|
}
|
||||||
|
if len(sink.frames) != 0 {
|
||||||
|
t.Fatalf("consumed frame count = %d, want 0", len(sink.frames))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAudioAttemptConsumesFrameWithoutCopyThenReturnsReadFailure(t *testing.T) {
|
||||||
|
readErr := errors.New("read failed")
|
||||||
|
wantFrame := AudioFrame{
|
||||||
|
Index: 42,
|
||||||
|
SampleCount: 2,
|
||||||
|
Channels: 2,
|
||||||
|
SampleRateNumerator: 48000,
|
||||||
|
SampleRateDenominator: 1,
|
||||||
|
Samples: [][]byte{
|
||||||
|
{1, 2, 3, 4},
|
||||||
|
{5, 6, 7, 8},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
reader := &fakeAudioReader{
|
||||||
|
frames: []AudioFrame{wantFrame},
|
||||||
|
readErr: readErr,
|
||||||
|
}
|
||||||
|
sink := &fakeAudioSink{}
|
||||||
|
|
||||||
|
err := runAudioAttempt(
|
||||||
|
context.Background(),
|
||||||
|
&fakeAudioFactory{reader: reader},
|
||||||
|
sink,
|
||||||
|
FeedConfig{},
|
||||||
|
)
|
||||||
|
|
||||||
|
if !errors.Is(err, readErr) {
|
||||||
|
t.Fatalf("runAudioAttempt() error = %v, want %v", err, readErr)
|
||||||
|
}
|
||||||
|
if !reader.closed {
|
||||||
|
t.Fatal("reader was not closed")
|
||||||
|
}
|
||||||
|
if reader.readCalls != 2 {
|
||||||
|
t.Errorf("read calls = %d, want 2", reader.readCalls)
|
||||||
|
}
|
||||||
|
if len(sink.frames) != 1 {
|
||||||
|
t.Fatalf("consumed frame count = %d, want 1", len(sink.frames))
|
||||||
|
}
|
||||||
|
got := sink.frames[0]
|
||||||
|
if got.Index != wantFrame.Index ||
|
||||||
|
got.SampleCount != wantFrame.SampleCount ||
|
||||||
|
got.Channels != wantFrame.Channels ||
|
||||||
|
got.SampleRateNumerator != wantFrame.SampleRateNumerator ||
|
||||||
|
got.SampleRateDenominator != wantFrame.SampleRateDenominator {
|
||||||
|
t.Errorf("consumed frame metadata = %+v, want %+v", got, wantFrame)
|
||||||
|
}
|
||||||
|
for channel := range wantFrame.Samples {
|
||||||
|
if &got.Samples[channel][0] != &wantFrame.Samples[channel][0] {
|
||||||
|
t.Fatalf("channel %d samples were copied", channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAudioAttemptSinkFailureStopsReadingAndCloses(t *testing.T) {
|
||||||
|
sinkErr := errors.New("audio output unavailable")
|
||||||
|
reader := &fakeAudioReader{
|
||||||
|
frames: []AudioFrame{
|
||||||
|
{Index: 1, Samples: [][]byte{{1}}},
|
||||||
|
{Index: 2, Samples: [][]byte{{2}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runAudioAttempt(
|
||||||
|
context.Background(),
|
||||||
|
&fakeAudioFactory{reader: reader},
|
||||||
|
&fakeAudioSink{err: sinkErr},
|
||||||
|
FeedConfig{},
|
||||||
|
)
|
||||||
|
|
||||||
|
if !errors.Is(err, sinkErr) {
|
||||||
|
t.Fatalf("runAudioAttempt() error = %v, want %v", err, sinkErr)
|
||||||
|
}
|
||||||
|
var typedErr *audioSinkError
|
||||||
|
if !errors.As(err, &typedErr) {
|
||||||
|
t.Fatalf("runAudioAttempt() error type = %T, want *audioSinkError", err)
|
||||||
|
}
|
||||||
|
if reader.readCalls != 1 {
|
||||||
|
t.Errorf("read calls = %d, want 1", reader.readCalls)
|
||||||
|
}
|
||||||
|
if !reader.closed {
|
||||||
|
t.Fatal("reader was not closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAudioAttemptCanceledRead(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
reader := &fakeAudioReader{
|
||||||
|
read: func(ctx context.Context) (AudioFrame, error) {
|
||||||
|
cancel()
|
||||||
|
return AudioFrame{}, ctx.Err()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runAudioAttempt(
|
||||||
|
ctx,
|
||||||
|
&fakeAudioFactory{reader: reader},
|
||||||
|
&fakeAudioSink{},
|
||||||
|
FeedConfig{},
|
||||||
|
)
|
||||||
|
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("runAudioAttempt() error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
if !reader.closed {
|
||||||
|
t.Fatal("reader was not closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAudioAttemptJoinsReadAndCloseErrors(t *testing.T) {
|
||||||
|
readErr := errors.New("read failed")
|
||||||
|
closeErr := errors.New("close failed")
|
||||||
|
reader := &fakeAudioReader{readErr: readErr, closeErr: closeErr}
|
||||||
|
|
||||||
|
err := runAudioAttempt(
|
||||||
|
context.Background(),
|
||||||
|
&fakeAudioFactory{reader: reader},
|
||||||
|
&fakeAudioSink{},
|
||||||
|
FeedConfig{},
|
||||||
|
)
|
||||||
|
|
||||||
|
if !errors.Is(err, readErr) {
|
||||||
|
t.Errorf("runAudioAttempt() error does not contain read error: %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, closeErr) {
|
||||||
|
t.Errorf("runAudioAttempt() error does not contain close error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user