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