package output import ( "bytes" "context" "errors" "testing" "time" "mxl-player/internal/playback" "mxl-player/internal/sdl" ) type fakeAudioBackend struct { nextStream uintptr openSpecs []sdl.AudioSpec openDevice []uint32 resumeOK bool putOK bool puts [][]byte queued []int32 queueCalls int destroyed []uintptr errText string } func (b *fakeAudioBackend) Open(device uint32, spec sdl.AudioSpec) uintptr { b.openDevice = append(b.openDevice, device) b.openSpecs = append(b.openSpecs, spec) return b.nextStream } func (b *fakeAudioBackend) Resume(uintptr) bool { return b.resumeOK } func (b *fakeAudioBackend) Put(_ uintptr, data []byte) bool { b.puts = append(b.puts, append([]byte(nil), data...)) return b.putOK } func (b *fakeAudioBackend) Queued(uintptr) int32 { if len(b.queued) == 0 { return 0 } index := b.queueCalls if index >= len(b.queued) { index = len(b.queued) - 1 } b.queueCalls++ return b.queued[index] } func (b *fakeAudioBackend) Destroy(stream uintptr) { b.destroyed = append(b.destroyed, stream) } func (b *fakeAudioBackend) Error() string { return b.errText } func newTestSDLAudioSink(backend audioBackend) *SDLAudioSink { return &SDLAudioSink{ deviceID: 7, backend: backend, maxQueuedBatches: 2, queuePoll: time.Millisecond, wait: func(context.Context, time.Duration) error { return nil }, } } func TestAudioOutputFormat(t *testing.T) { tests := []struct { name string frame playback.AudioFrame want audioFormat wantErr bool }{ { name: "valid", frame: playback.AudioFrame{Channels: 2, SampleRateNumerator: 48000, SampleRateDenominator: 1}, want: audioFormat{channels: 2, frequency: 48000}, }, {name: "zero channels", frame: playback.AudioFrame{SampleRateNumerator: 48000, SampleRateDenominator: 1}, wantErr: true}, {name: "zero numerator", frame: playback.AudioFrame{Channels: 2, SampleRateDenominator: 1}, wantErr: true}, {name: "zero denominator", frame: playback.AudioFrame{Channels: 2, SampleRateNumerator: 48000}, wantErr: true}, {name: "fractional frequency", frame: playback.AudioFrame{Channels: 2, SampleRateNumerator: 30000, SampleRateDenominator: 1001}, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := audioOutputFormat(tt.frame) if tt.wantErr { if !errors.Is(err, ErrInvalidAudioFormat) { t.Fatalf("audioOutputFormat() error = %v, want %v", err, ErrInvalidAudioFormat) } return } if err != nil || got != tt.want { t.Fatalf("audioOutputFormat() = %#v, %v; want %#v, nil", got, err, tt.want) } }) } } func TestSDLAudioSinkOpensInterleavesAndReusesStream(t *testing.T) { backend := &fakeAudioBackend{nextStream: 11, resumeOK: true, putOK: true} sink := newTestSDLAudioSink(backend) frame := validAudioFrame() if err := sink.ConsumeAudio(context.Background(), frame); err != nil { t.Fatalf("first ConsumeAudio() error = %v", err) } if err := sink.ConsumeAudio(context.Background(), frame); err != nil { t.Fatalf("second ConsumeAudio() error = %v", err) } if len(backend.openSpecs) != 1 { t.Fatalf("open calls = %d, want 1", len(backend.openSpecs)) } wantSpec := sdl.AudioSpec{Format: sdl.AudioF32, Channels: 2, Freq: 48000} if backend.openDevice[0] != 7 || backend.openSpecs[0] != wantSpec { t.Fatalf("open = device %d spec %#v, want 7 %#v", backend.openDevice[0], backend.openSpecs[0], wantSpec) } wantData := []byte{1, 2, 3, 4, 9, 10, 11, 12, 5, 6, 7, 8, 13, 14, 15, 16} if len(backend.puts) != 2 || !bytes.Equal(backend.puts[0], wantData) { t.Fatalf("queued data = %v, want %v twice", backend.puts, wantData) } } func TestSDLAudioSinkRecreatesStreamOnFormatChange(t *testing.T) { backend := &fakeAudioBackend{nextStream: 11, resumeOK: true, putOK: true} sink := newTestSDLAudioSink(backend) first := validAudioFrame() if err := sink.ConsumeAudio(context.Background(), first); err != nil { t.Fatalf("first ConsumeAudio() error = %v", err) } backend.nextStream = 12 second := first second.SampleRateNumerator = 96000 if err := sink.ConsumeAudio(context.Background(), second); err != nil { t.Fatalf("second ConsumeAudio() error = %v", err) } if len(backend.openSpecs) != 2 || len(backend.destroyed) != 1 || backend.destroyed[0] != 11 { t.Fatalf("opens=%d destroyed=%v, want 2 and [11]", len(backend.openSpecs), backend.destroyed) } } func TestSDLAudioSinkReportsOpenResumeAndPutFailures(t *testing.T) { frame := validAudioFrame() t.Run("open", func(t *testing.T) { backend := &fakeAudioBackend{errText: "open failed"} err := newTestSDLAudioSink(backend).ConsumeAudio(context.Background(), frame) if !errors.Is(err, ErrOpenAudioStream) { t.Fatalf("ConsumeAudio() error = %v, want %v", err, ErrOpenAudioStream) } }) t.Run("resume", func(t *testing.T) { backend := &fakeAudioBackend{nextStream: 11, errText: "resume failed"} sink := newTestSDLAudioSink(backend) err := sink.ConsumeAudio(context.Background(), frame) if !errors.Is(err, ErrResumeAudioStream) || sink.stream != 0 { t.Fatalf("ConsumeAudio() error=%v stream=%d, want resume error and zero stream", err, sink.stream) } if len(backend.destroyed) != 1 || backend.destroyed[0] != 11 { t.Fatalf("destroyed = %v, want [11]", backend.destroyed) } }) t.Run("put", func(t *testing.T) { backend := &fakeAudioBackend{nextStream: 11, resumeOK: true, errText: "put failed"} err := newTestSDLAudioSink(backend).ConsumeAudio(context.Background(), frame) if !errors.Is(err, ErrQueueAudioData) { t.Fatalf("ConsumeAudio() error = %v, want %v", err, ErrQueueAudioData) } }) } func TestSDLAudioSinkWaitsForQueueAndHonorsCancellation(t *testing.T) { backend := &fakeAudioBackend{ nextStream: 11, resumeOK: true, putOK: true, queued: []int32{100, 100}, } sink := newTestSDLAudioSink(backend) ctx, cancel := context.WithCancel(context.Background()) waits := 0 sink.wait = func(ctx context.Context, _ time.Duration) error { waits++ cancel() return ctx.Err() } err := sink.ConsumeAudio(ctx, validAudioFrame()) if !errors.Is(err, context.Canceled) { t.Fatalf("ConsumeAudio() error = %v, want context.Canceled", err) } if waits != 1 || len(backend.puts) != 0 { t.Fatalf("waits=%d puts=%d, want 1 and 0", waits, len(backend.puts)) } } func TestSDLAudioSinkCloseIsIdempotent(t *testing.T) { backend := &fakeAudioBackend{nextStream: 11, resumeOK: true, putOK: true} sink := newTestSDLAudioSink(backend) if err := sink.ConsumeAudio(context.Background(), validAudioFrame()); err != nil { t.Fatalf("ConsumeAudio() error = %v", err) } if err := sink.Close(); err != nil { t.Fatalf("Close() error = %v", err) } if err := sink.Close(); err != nil { t.Fatalf("second Close() error = %v", err) } if len(backend.destroyed) != 1 || sink.stream != 0 || sink.format != (audioFormat{}) { t.Fatalf("destroyed=%v stream=%d format=%#v", backend.destroyed, sink.stream, sink.format) } }