1package cef
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "sync/atomic"
8 "testing"
9 "time"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/infrastructure/audio/null"
13 "github.com/bnema/dumber/internal/logging"
14 purecef "github.com/bnema/purego-cef/cef"
15 "github.com/rs/zerolog"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18)
19
20// TestEngine_WiresAudioOutputFactory verifies that the audio output factory
21// is properly injected through engine initialization.
22func TestEngine_WiresAudioOutputFactory(t *testing.T) {
23 // This test verifies the wiring pattern. Since we can't easily
24 // initialize a full CEF engine in unit tests, we verify the
25 // webViewFactoryOptions struct accepts the audio factory.
26 audioFactory := &null.Factory{}
27
28 // Verify webViewFactoryOptions can hold the audio factory
29 opts := webViewFactoryOptions{
30 windowlessFrameRate: 60,
31 audioOutputFactory: audioFactory,
32 }
33
34 require.NotNil(t, opts.audioOutputFactory)
35 assert.Equal(t, audioFactory, opts.audioOutputFactory)
36
37 // Verify the factory is passed through
38 factory := newWebViewFactory(&Engine{}, opts)
39 require.NotNil(t, factory.audioOutputFactory)
40}
41
42// TestWebViewFactory_AudioFactoryIsOptional verifies that nil audio factory
43// is handled gracefully (no panic, no audio output).
44func TestWebViewFactory_AudioFactoryIsOptional(t *testing.T) {
45 // Arrange
46 opts := webViewFactoryOptions{
47 windowlessFrameRate: 60,
48 audioOutputFactory: nil,
49 }
50
51 // Act
52 factory := newWebViewFactory(&Engine{}, opts)
53
54 // Assert
55 require.NotNil(t, factory)
56 assert.Nil(t, factory.audioOutputFactory)
57}
58
59// TestHandlerSet_ReceivesAudioFactory verifies the handler set receives
60// the audio factory from the WebView.
61func TestHandlerSet_ReceivesAudioFactory(t *testing.T) {
62 // This test verifies that handlerSet can access the audio factory
63 // through its associated WebView.
64 audioFactory := &null.Factory{}
65
66 wv := &WebView{
67 ctx: context.Background(),
68 audioOutputFactory: audioFactory,
69 }
70
71 handlers := &handlerSet{wv: wv}
72
73 // Verify the handler can access the audio factory through the WebView
74 require.NotNil(t, handlers.wv)
75 assert.Equal(t, audioFactory, handlers.wv.audioOutputFactory)
76}
77
78// TestWebView_AudioStreamLifecycleFieldsExist verifies the WebView has
79// the necessary fields for audio stream lifecycle management.
80func TestWebView_AudioStreamLifecycleFieldsExist(t *testing.T) {
81 wv := &WebView{
82 ctx: context.Background(),
83 audioOutputFactory: &null.Factory{},
84 }
85
86 // Verify the fields exist and can be set
87 setAudioStream(wv, &mockAudioStream{})
88
89 assert.NotNil(t, wv.audioOutputFactory)
90 assert.NotNil(t, activeAudioStream(wv))
91}
92
93// mockAudioStream is a test double for port.AudioOutputStream
94type mockAudioStream struct {
95 writeCalled bool
96 closeCalled bool
97 writeSamples [][]float32
98}
99
100func (m *mockAudioStream) Write(samples [][]float32) error {
101 m.writeCalled = true
102 m.writeSamples = samples
103 return nil
104}
105
106func (m *mockAudioStream) Close() error {
107 m.closeCalled = true
108 return nil
109}
110
111// Verify mock implements the interface
112var _ port.AudioOutputStream = (*mockAudioStream)(nil)
113
114func setAudioStream(wv *WebView, stream port.AudioOutputStream) {
115 wv.audioStream.Store(&audioStreamHandle{stream: stream, generation: wv.audioStreamGeneration.Load()})
116}
117
118func activeAudioStream(wv *WebView) port.AudioOutputStream {
119 handle := wv.audioStream.Load()
120 if handle == nil {
121 return nil
122 }
123 return handle.stream
124}
125
126// racyMockAudioStream blocks inside Write until doneCh is closed, allowing
127// tests to verify that the caller holds a lock across the Write call.
128type racyMockAudioStream struct {
129 writeCh chan struct{} // closed when Write is entered
130 doneCh chan struct{} // Write blocks until this is closed
131 closeCalled bool
132}
133
134func (m *racyMockAudioStream) Write(_ [][]float32) error {
135 close(m.writeCh) // signal that Write has been entered
136 <-m.doneCh // block until test unblocks us
137 return nil
138}
139
140func (m *racyMockAudioStream) Close() error {
141 m.closeCalled = true
142 return nil
143}
144
145var _ port.AudioOutputStream = (*racyMockAudioStream)(nil)
146
147type countedAudioStream struct{ closes atomic.Int32 }
148
149func (*countedAudioStream) Write([][]float32) error { return nil }
150func (s *countedAudioStream) Close() error {
151 s.closes.Add(1)
152 return nil
153}
154
155type controlledAudioFactory struct {
156 entered chan struct{}
157 release chan struct{}
158 stream port.AudioOutputStream
159}
160
161func (f *controlledAudioFactory) NewStream(context.Context, port.AudioStreamFormat) (port.AudioOutputStream, error) {
162 close(f.entered)
163 <-f.release
164 return f.stream, nil
165}
166
167// ============================================================================
168// Task 6: Audio Stream Lifecycle Handling Tests
169// ============================================================================
170
171// TestHandlerSet_OnAudioStreamStarted_CreatesStream verifies that
172// OnAudioStreamStarted creates a new audio stream from the factory.
173func TestHandlerSet_OnAudioStreamStarted_CreatesStream(t *testing.T) {
174 ctx := context.Background()
175 mockStream := &mockAudioStream{}
176 mockFactory := &mockAudioFactory{stream: mockStream}
177
178 wv := &WebView{
179 ctx: ctx,
180 audioOutputFactory: mockFactory,
181 }
182
183 handlers := &handlerSet{wv: wv}
184
185 params := &purecef.AudioParameters{
186 SampleRate: 48000,
187 ChannelLayout: purecef.ChannelLayoutStereo,
188 FramesPerBuffer: 512,
189 }
190
191 // Act — third arg is channels, not framesPerBuffer
192 handlers.OnAudioStreamStarted(nil, params, 2)
193
194 // Assert
195 require.NotNil(t, activeAudioStream(wv))
196 assert.Equal(t, mockStream, activeAudioStream(wv))
197 assert.True(t, wv.audioPlaying.Load())
198
199 // Verify factory was called with correct format
200 require.NotNil(t, mockFactory.lastFormat)
201 assert.Equal(t, 48000, mockFactory.lastFormat.SampleRate)
202 assert.Equal(t, 2, mockFactory.lastFormat.ChannelCount)
203 assert.Equal(t, 512, mockFactory.lastFormat.FramesPerBuffer)
204}
205
206// TestHandlerSet_OnAudioStreamStarted_NoFactory_DoesNotPanic verifies
207// that OnAudioStreamStarted handles nil factory gracefully.
208func TestHandlerSet_OnAudioStreamStarted_NoFactory_DoesNotPanic(t *testing.T) {
209 wv := &WebView{
210 ctx: context.Background(),
211 audioOutputFactory: nil,
212 }
213
214 handlers := &handlerSet{wv: wv}
215
216 params := &purecef.AudioParameters{
217 SampleRate: 48000,
218 ChannelLayout: purecef.ChannelLayoutStereo,
219 FramesPerBuffer: 512,
220 }
221
222 // Should not panic
223 assert.NotPanics(t, func() {
224 handlers.OnAudioStreamStarted(nil, params, 512)
225 })
226
227 assert.False(t, wv.audioPlaying.Load())
228}
229
230// TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream verifies
231// that starting a new stream closes any existing one.
232func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) {
233 ctx := context.Background()
234 oldStream := &mockAudioStream{}
235 newStream := &mockAudioStream{}
236 mockFactory := &mockAudioFactory{stream: newStream}
237
238 wv := &WebView{
239 ctx: ctx,
240 audioOutputFactory: mockFactory,
241 }
242 setAudioStream(wv, oldStream)
243
244 handlers := &handlerSet{wv: wv}
245
246 params := &purecef.AudioParameters{
247 SampleRate: 44100,
248 ChannelLayout: purecef.ChannelLayoutMono,
249 FramesPerBuffer: 256,
250 }
251
252 // Act — third arg is channels (1 for mono)
253 handlers.OnAudioStreamStarted(nil, params, 1)
254
255 // Assert
256 assert.True(t, oldStream.closeCalled)
257 assert.Equal(t, newStream, activeAudioStream(wv))
258}
259
260// TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies
261// that factory errors are handled without panic and audioPlaying is reset to false.
262func TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully(t *testing.T) {
263 ctx := context.Background()
264 mockFactory := &mockAudioFactory{err: errors.New("factory error")}
265
266 wv := &WebView{
267 ctx: ctx,
268 audioOutputFactory: mockFactory,
269 }
270
271 handlers := &handlerSet{wv: wv}
272
273 params := &purecef.AudioParameters{
274 SampleRate: 48000,
275 ChannelLayout: purecef.ChannelLayoutStereo,
276 FramesPerBuffer: 512,
277 }
278
279 // Should not panic
280 assert.NotPanics(t, func() {
281 handlers.OnAudioStreamStarted(nil, params, 512)
282 })
283
284 // audioPlaying must be false when NewStream fails — if it stays true,
285 // the UI/state layer incorrectly believes audio is active.
286 assert.False(t, wv.audioPlaying.Load(), "audioPlaying must be false after NewStream failure")
287 assert.Nil(t, activeAudioStream(wv), "activeAudioStream must be nil after NewStream failure")
288}
289
290// ============================================================================
291// Task 7: PCM Packet Forwarding Tests
292// ============================================================================
293
294// TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData verifies that CEF
295// does not allocate a staging copy; the audio output port owns that handoff.
296func TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData(t *testing.T) {
297 ctx := context.Background()
298 mockStream := &mockAudioStream{}
299
300 wv := &WebView{
301 ctx: ctx,
302 }
303 setAudioStream(wv, mockStream)
304
305 handlers := &handlerSet{wv: wv}
306
307 // Create sample data: 2 channels, 512 frames each
308 originalData := [][]float32{
309 make([]float32, 512), // Channel 0
310 make([]float32, 512), // Channel 1
311 }
312 originalData[0][0] = 0.5
313 originalData[1][0] = -0.5
314
315 // Act
316 handlers.OnAudioStreamPacket(nil, originalData, 512, 0)
317
318 // Assert
319 require.True(t, mockStream.writeCalled)
320 require.Len(t, mockStream.writeSamples, 2)
321 require.Len(t, mockStream.writeSamples[0], 512)
322
323 assert.InDelta(t, 0.5, mockStream.writeSamples[0][0], 0.000001)
324 assert.InDelta(t, -0.5, mockStream.writeSamples[1][0], 0.000001)
325 assert.Same(t, &originalData[0][0], &mockStream.writeSamples[0][0])
326}
327
328// TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing verifies
329// that packets are silently discarded when no stream is active.
330func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) {
331 wv := &WebView{
332 ctx: context.Background(),
333 }
334
335 handlers := &handlerSet{wv: wv}
336
337 data := [][]float32{
338 {0.1, 0.2, 0.3},
339 {0.4, 0.5, 0.6},
340 }
341
342 // Should not panic
343 assert.NotPanics(t, func() {
344 handlers.OnAudioStreamPacket(nil, data, 3, 0)
345 })
346}
347
348// TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies
349// that nil data is handled gracefully.
350func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) {
351 wv := &WebView{
352 ctx: context.Background(),
353 }
354
355 handlers := &handlerSet{wv: wv}
356
357 // Should not panic with nil data
358 assert.NotPanics(t, func() {
359 handlers.OnAudioStreamPacket(nil, nil, 0, 0)
360 })
361}
362
363func BenchmarkHandlerSet_OnAudioStreamPacketStereo(b *testing.B) {
364 stream := &mockAudioStream{}
365 wv := &WebView{ctx: context.Background()}
366 setAudioStream(wv, stream)
367 handlers := &handlerSet{wv: wv}
368 data := [][]float32{make([]float32, 512), make([]float32, 512)}
369 b.ReportAllocs()
370 b.SetBytes(2 * 512 * 4)
371 for i := 0; i < b.N; i++ {
372 handlers.OnAudioStreamPacket(nil, data, 512, 0)
373 }
374}
375
376// ============================================================================
377// Task 8: Stream Cleanup Tests
378// ============================================================================
379
380// TestHandlerSet_OnAudioStreamStopped_ClosesStream verifies that
381// OnAudioStreamStopped closes the active stream.
382func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) {
383 mockStream := &mockAudioStream{}
384 wv := &WebView{
385 ctx: context.Background(),
386 }
387 setAudioStream(wv, mockStream)
388 wv.audioPlaying.Store(true)
389
390 handlers := &handlerSet{wv: wv}
391
392 // Act
393 handlers.OnAudioStreamStopped(nil)
394
395 // Assert
396 assert.True(t, mockStream.closeCalled)
397 assert.Nil(t, activeAudioStream(wv))
398 assert.False(t, wv.audioPlaying.Load())
399}
400
401// TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic verifies
402// that stopping when no stream exists doesn't panic.
403func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) {
404 wv := &WebView{
405 ctx: context.Background(),
406 }
407 wv.audioPlaying.Store(true)
408
409 handlers := &handlerSet{wv: wv}
410
411 // Should not panic
412 assert.NotPanics(t, func() {
413 handlers.OnAudioStreamStopped(nil)
414 })
415
416 assert.False(t, wv.audioPlaying.Load())
417}
418
419// TestHandlerSet_OnAudioStreamError_ClosesStream verifies that
420// OnAudioStreamError closes the active stream.
421func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) {
422 mockStream := &mockAudioStream{}
423 wv := &WebView{
424 ctx: context.Background(),
425 }
426 setAudioStream(wv, mockStream)
427 wv.audioPlaying.Store(true)
428
429 handlers := &handlerSet{wv: wv}
430
431 // Act
432 handlers.OnAudioStreamError(nil, "test error")
433
434 // Assert
435 assert.True(t, mockStream.closeCalled)
436 assert.Nil(t, activeAudioStream(wv))
437 assert.False(t, wv.audioPlaying.Load())
438}
439
440// TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic verifies
441// that error handling when no stream exists doesn't panic.
442func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) {
443 wv := &WebView{
444 ctx: context.Background(),
445 }
446 wv.audioPlaying.Store(true)
447
448 handlers := &handlerSet{wv: wv}
449
450 // Should not panic
451 assert.NotPanics(t, func() {
452 handlers.OnAudioStreamError(nil, "test error")
453 })
454
455 assert.False(t, wv.audioPlaying.Load())
456}
457
458func TestHandlerSet_OnAudioStreamError_LogsWarnForNormalError(t *testing.T) {
459 var logs bytes.Buffer
460 logger := zerolog.New(&logs).Level(zerolog.DebugLevel)
461 ctx := logging.WithContext(context.Background(), logger)
462
463 wv := &WebView{ctx: ctx}
464 handlers := &handlerSet{wv: wv}
465
466 handlers.OnAudioStreamError(nil, "test error")
467
468 output := logs.String()
469 assert.Contains(t, output, `"level":"warn"`)
470 assert.Contains(t, output, `"message":"cef: audio stream error"`)
471 assert.Contains(t, output, `"error":"test error"`)
472}
473
474func TestHandlerSet_OnAudioStreamError_ShutdownSocketCloseLogsDebug(t *testing.T) {
475 var logs bytes.Buffer
476 logger := zerolog.New(&logs).Level(zerolog.DebugLevel)
477 ctx := logging.WithContext(context.Background(), logger)
478
479 wv := &WebView{ctx: ctx}
480 wv.destroyed.Store(true)
481 handlers := &handlerSet{wv: wv}
482
483 handlers.OnAudioStreamError(nil, "Socket closed unexpectedly")
484
485 output := logs.String()
486 assert.Contains(t, output, `"level":"debug"`)
487 assert.NotContains(t, output, `"level":"warn"`)
488 assert.Contains(t, output, `"message":"cef: audio stream closed during shutdown"`)
489 assert.Contains(t, output, `"error":"Socket closed unexpectedly"`)
490}
491
492// TestWebView_Destroy_ClosesAudioStream verifies that destroying
493// the WebView closes any active audio stream.
494func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {
495 mockStream := &mockAudioStream{}
496 wv := &WebView{
497 ctx: context.Background(),
498 }
499 setAudioStream(wv, mockStream)
500 wv.audioPlaying.Store(true)
501
502 // Act — Destroy must close the audio stream as part of teardown.
503 wv.Destroy()
504
505 // Assert
506 assert.True(t, mockStream.closeCalled, "Destroy must close the active audio stream")
507 assert.Nil(t, activeAudioStream(wv))
508}
509
510// TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy
511// handles the case where no audio stream is active.
512func TestWebView_Destroy_NoAudioStream_DoesNotPanic(t *testing.T) {
513 wv := &WebView{
514 ctx: context.Background(),
515 }
516
517 assert.NotPanics(t, func() {
518 wv.Destroy()
519 })
520}
521
522// ============================================================================
523// Callback parameter mapping tests
524// ============================================================================
525
526// TestBuildAudioStreamFormat_UsesParamsFramesPerBuffer verifies that
527// buildAudioStreamFormat reads FramesPerBuffer from the AudioParameters struct,
528// NOT from the callback's third argument (which is channels).
529func TestBuildAudioStreamFormat_UsesParamsFramesPerBuffer(t *testing.T) {
530 wv := &WebView{ctx: context.Background()}
531 handlers := &handlerSet{wv: wv}
532
533 params := &purecef.AudioParameters{
534 SampleRate: 48000,
535 ChannelLayout: purecef.ChannelLayoutStereo,
536 FramesPerBuffer: 1024,
537 }
538 channels := int32(2) // third callback arg is channels, not framesPerBuffer
539
540 format := handlers.buildAudioStreamFormat(params, channels)
541
542 assert.Equal(t, 48000, format.SampleRate, "sample rate should come from params")
543 assert.Equal(t, 2, format.ChannelCount, "channel count should match callback channels arg")
544 assert.Equal(t, 1024, format.FramesPerBuffer, "frames_per_buffer should come from params.FramesPerBuffer, not the channels arg")
545}
546
547// TestBuildAudioStreamFormat_ChannelsArgDiffersFromLayout verifies correct
548// behavior when the callback's channels arg and the layout-derived count differ.
549// The channels callback arg is the authoritative channel count.
550func TestBuildAudioStreamFormat_ChannelsArgDiffersFromLayout(t *testing.T) {
551 wv := &WebView{ctx: context.Background()}
552 handlers := &handlerSet{wv: wv}
553
554 params := &purecef.AudioParameters{
555 SampleRate: 44100,
556 ChannelLayout: purecef.ChannelLayoutStereo, // layout says 2
557 FramesPerBuffer: 512,
558 }
559 channels := int32(1) // but CEF says 1 channel
560
561 format := handlers.buildAudioStreamFormat(params, channels)
562
563 assert.Equal(t, 44100, format.SampleRate)
564 assert.Equal(t, 1, format.ChannelCount, "should use callback channels arg, not layout-derived count")
565 assert.Equal(t, 512, format.FramesPerBuffer, "should use params.FramesPerBuffer")
566}
567
568// TestOnAudioStreamStarted_CorrectParameterMapping verifies end-to-end that
569// OnAudioStreamStarted passes the correct values to the factory:
570// - channels from the callback arg (not from params.FramesPerBuffer)
571// - framesPerBuffer from params.FramesPerBuffer
572func TestOnAudioStreamStarted_CorrectParameterMapping(t *testing.T) {
573 ctx := context.Background()
574 mockStream := &mockAudioStream{}
575 mockFactory := &mockAudioFactory{stream: mockStream}
576
577 wv := &WebView{
578 ctx: ctx,
579 audioOutputFactory: mockFactory,
580 }
581
582 handlers := &handlerSet{wv: wv}
583
584 params := &purecef.AudioParameters{
585 SampleRate: 48000,
586 ChannelLayout: purecef.ChannelLayoutStereo,
587 FramesPerBuffer: 1024,
588 }
589
590 // Third arg is channels=2, NOT framesPerBuffer.
591 handlers.OnAudioStreamStarted(nil, params, 2)
592
593 require.NotNil(t, mockFactory.lastFormat)
594 assert.Equal(t, 48000, mockFactory.lastFormat.SampleRate)
595 assert.Equal(t, 2, mockFactory.lastFormat.ChannelCount, "channel count should be 2 (from callback arg)")
596 assert.Equal(t, 1024, mockFactory.lastFormat.FramesPerBuffer, "frames_per_buffer should be 1024 (from params.FramesPerBuffer)")
597}
598
599func TestHandlerSet_OnAudioStreamStarted_StopRejectsInFlightCreation(t *testing.T) {
600 stream := &countedAudioStream{}
601 factory := &controlledAudioFactory{entered: make(chan struct{}), release: make(chan struct{}), stream: stream}
602 wv := &WebView{ctx: context.Background(), audioOutputFactory: factory}
603 handlers := &handlerSet{wv: wv}
604 params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512}
605 done := make(chan struct{})
606 go func() { handlers.OnAudioStreamStarted(nil, params, 2); close(done) }()
607 <-factory.entered
608 handlers.OnAudioStreamStopped(nil)
609 close(factory.release)
610 <-done
611
612 assert.Nil(t, activeAudioStream(wv))
613 assert.False(t, wv.audioPlaying.Load())
614 assert.Equal(t, int32(1), stream.closes.Load())
615}
616
617func TestHandlerSet_OnAudioStreamStarted_DestroyRejectsInFlightCreation(t *testing.T) {
618 stream := &countedAudioStream{}
619 factory := &controlledAudioFactory{entered: make(chan struct{}), release: make(chan struct{}), stream: stream}
620 wv := &WebView{ctx: context.Background(), audioOutputFactory: factory}
621 handlers := &handlerSet{wv: wv}
622 params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512}
623 done := make(chan struct{})
624 go func() { handlers.OnAudioStreamStarted(nil, params, 2); close(done) }()
625 <-factory.entered
626 wv.Destroy()
627 close(factory.release)
628 <-done
629
630 assert.Nil(t, activeAudioStream(wv))
631 assert.True(t, wv.destroyed.Load())
632 assert.Equal(t, int32(1), stream.closes.Load())
633}
634
635func TestHandlerSet_OnAudioStreamStarted_ReplacesAndClosesEachStreamOnce(t *testing.T) {
636 old, first, second := &countedAudioStream{}, &countedAudioStream{}, &countedAudioStream{}
637 factory := &mockAudioFactory{stream: first}
638 wv := &WebView{ctx: context.Background(), audioOutputFactory: factory}
639 setAudioStream(wv, old)
640 handlers := &handlerSet{wv: wv}
641 params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512}
642
643 handlers.OnAudioStreamStarted(nil, params, 2)
644 factory.stream = second
645 handlers.OnAudioStreamStarted(nil, params, 2)
646 wv.closeAudioStream()
647
648 assert.Equal(t, int32(1), old.closes.Load())
649 assert.Equal(t, int32(1), first.closes.Load())
650 assert.Equal(t, int32(1), second.closes.Load())
651 assert.Nil(t, activeAudioStream(wv))
652}
653
654// ============================================================================
655// Race-window tests (write/close interleave)
656// ============================================================================
657
658// TestOnAudioStreamPacket_CloseDoesNotWaitForWrite verifies that lifecycle
659// shutdown is not serialized behind a potentially slow callback write. The
660// port contract makes Close/Write concurrency safe.
661func TestOnAudioStreamPacket_CloseDoesNotWaitForWrite(t *testing.T) {
662 ctx := context.Background()
663
664 // racyStream blocks inside Write until we signal.
665 stream := &racyMockAudioStream{
666 writeCh: make(chan struct{}),
667 doneCh: make(chan struct{}),
668 }
669
670 wv := &WebView{
671 ctx: ctx,
672 }
673 setAudioStream(wv, stream)
674 handlers := &handlerSet{wv: wv}
675
676 data := [][]float32{{0.1, 0.2}, {0.3, 0.4}}
677
678 // Start a packet write — it will block inside Write.
679 go handlers.OnAudioStreamPacket(nil, data, 2, 0)
680
681 // Wait for Write to be entered.
682 <-stream.writeCh
683
684 closeDone := make(chan struct{})
685 go func() {
686 wv.closeAudioStream()
687 close(closeDone)
688 }()
689
690 select {
691 case <-closeDone:
692 // expected: only the short stream snapshot takes the mutex
693 case <-time.After(time.Second):
694 t.Fatal("closeAudioStream waited for an in-flight Write")
695 }
696
697 // The write can finish after shutdown without holding the lifecycle mutex.
698 close(stream.doneCh)
699
700 assert.True(t, stream.closeCalled)
701 assert.Nil(t, activeAudioStream(wv))
702}
703
704// TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a
705// stream is closed between two OnAudioStreamPacket calls, the second call
706// sees nil and silently drops the packet.
707func TestOnAudioStreamPacket_StreamClosedBetweenPackets(t *testing.T) {
708 ctx := context.Background()
709 stream := &mockAudioStream{}
710
711 wv := &WebView{
712 ctx: ctx,
713 }
714 setAudioStream(wv, stream)
715 handlers := &handlerSet{wv: wv}
716 data := [][]float32{{0.1, 0.2}, {0.3, 0.4}}
717
718 // First packet succeeds.
719 handlers.OnAudioStreamPacket(nil, data, 2, 0)
720 assert.True(t, stream.writeCalled)
721
722 // Close the stream.
723 wv.closeAudioStream()
724
725 // Second packet should be silently dropped.
726 assert.NotPanics(t, func() {
727 handlers.OnAudioStreamPacket(nil, data, 2, 1)
728 })
729}
730
731// ============================================================================
732// Mock Factories
733// ============================================================================
734
735// mockAudioFactory is a test double for port.AudioOutputFactory
736type mockAudioFactory struct {
737 stream port.AudioOutputStream
738 err error
739 lastFormat *port.AudioStreamFormat
740 lastCtx context.Context
741}
742
743func (m *mockAudioFactory) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) {
744 m.lastCtx = ctx
745 m.lastFormat = &format
746 return m.stream, m.err
747}
748
749// Verify mock implements the interface
750var _ port.AudioOutputFactory = (*mockAudioFactory)(nil)