detached 5041353

webview_audio_test.go RawHistoryFolder

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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close