1package core
2
3import (
4 "errors"
5 "strings"
6 "sync"
7 "testing"
8 "time"
9 "unsafe"
10
11 portout "github.com/bnema/purego-pipewire/internal/ports/out"
12 "github.com/bnema/purego-pipewire/internal/ports/out/mocks"
13 "github.com/stretchr/testify/mock"
14)
15
16// defaultTestConfig returns a PlayerConfig suitable for tests that need
17// valid PipeWire format parameters.
18func defaultTestConfig() PlayerConfig {
19 return PlayerConfig{
20 SampleRate: 48000,
21 Channels: 2,
22 FramesPerBuffer: 256,
23 }
24}
25
26// expectStartWithSync sets up mock expectations for a first-time Start()
27// and returns a pointer to a WaitGroup that is signaled when RunMainLoop completes.
28// The caller must call waitOnMainLoop(t, wg) after Start() returns.
29func expectStartWithSync(mockOps *mocks.MockStreamOps, fakeLoop, fakeStream unsafe.Pointer, cfg PlayerConfig) *sync.WaitGroup {
30 wg := &sync.WaitGroup{}
31 wg.Add(1)
32
33 mockOps.EXPECT().CreateMainLoop().Return(fakeLoop, nil)
34 mockOps.EXPECT().CreatePlaybackStream(fakeLoop, "purego-pipewire-player", mock.AnythingOfType("func()")).Return(fakeStream, nil)
35 mockOps.EXPECT().ConnectPlaybackStream(fakeStream, portout.PlaybackFormat{
36 SampleRate: cfg.SampleRate,
37 Channels: cfg.Channels,
38 FramesPerBuffer: cfg.FramesPerBuffer,
39 }).Return(nil)
40 mockOps.EXPECT().SetStreamActive(fakeStream, true).Return(nil).Once()
41 mockOps.EXPECT().RunMainLoop(fakeLoop).RunAndReturn(func(unsafe.Pointer) error {
42 wg.Done()
43 return nil
44 })
45
46 return wg
47}
48
49// waitOnMainLoop waits for the RunMainLoop goroutine to finish, with a timeout.
50func waitOnMainLoop(t *testing.T, wg *sync.WaitGroup) {
51 t.Helper()
52 done := make(chan struct{})
53 go func() {
54 wg.Wait()
55 close(done)
56 }()
57 select {
58 case <-done:
59 case <-time.After(2 * time.Second):
60 t.Fatal("timed out waiting for RunMainLoop goroutine")
61 }
62}
63
64func TestPlayerStopIsRestartableButCloseIsTerminal(t *testing.T) {
65 mockOps := mocks.NewMockStreamOps(t)
66 fakeLoop := opaqueTestPtr()
67 fakeStream := opaqueTestPtr()
68 cfg := defaultTestConfig()
69
70 // First start creates resources
71 wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
72 // Stop deactivates the stream
73 mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(nil)
74 // Restart reactivates existing stream
75 mockOps.EXPECT().SetStreamActive(fakeStream, true).Return(nil)
76 // Close tears down resources
77 mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
78 mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
79 mockOps.EXPECT().DestroyStream(fakeStream).Return()
80 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
81
82 p := newPlayer(cfg, PlayerCallbacks{})
83 p.streamOps = mockOps
84
85 // Start from Stopped state should work
86 p.setState(PlayerStateStopped)
87 if err := p.Start(); err != nil {
88 t.Fatalf("Start from Stopped failed: %v", err)
89 }
90 waitOnMainLoop(t, wg)
91
92 // Should be in Playing state after Start
93 if p.State() != PlayerStatePlaying {
94 t.Fatalf("expected Playing state after Start, got %v", p.State())
95 }
96
97 // Stop should transition to Stopped
98 if err := p.Stop(); err != nil {
99 t.Fatalf("Stop failed: %v", err)
100 }
101 if p.State() != PlayerStateStopped {
102 t.Fatalf("expected Stopped state after Stop, got %v", p.State())
103 }
104
105 // Start from Stopped should work again (restartable)
106 if err := p.Start(); err != nil {
107 t.Fatalf("Start from Stopped (restart) failed: %v", err)
108 }
109 if p.State() != PlayerStatePlaying {
110 t.Fatalf("expected Playing state after restart, got %v", p.State())
111 }
112
113 // Close should transition to Closed
114 if err := p.Close(); err != nil {
115 t.Fatalf("Close failed: %v", err)
116 }
117 if p.State() != PlayerStateClosed {
118 t.Fatalf("expected Closed state after Close, got %v", p.State())
119 }
120
121 // Start from Closed should fail (terminal)
122 if err := p.Start(); err == nil {
123 t.Fatal("expected error starting from Closed state, got nil")
124 }
125}
126
127func TestPlayerStartFromClosedReturnsError(t *testing.T) {
128 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
129 p.setState(PlayerStateClosed)
130
131 err := p.Start()
132 if err == nil {
133 t.Fatal("expected error starting from Closed state, got nil")
134 }
135 if !errors.Is(err, ErrPlayerClosed) {
136 t.Fatalf("expected ErrPlayerClosed, got %v", err)
137 }
138}
139
140func TestPlayerStartFromClosingReturnsError(t *testing.T) {
141 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
142 p.setState(PlayerStateClosing)
143
144 err := p.Start()
145 if err == nil {
146 t.Fatal("expected error starting from Closing state, got nil")
147 }
148 if !errors.Is(err, ErrPlayerClosed) {
149 t.Fatalf("expected ErrPlayerClosed, got %v", err)
150 }
151}
152
153func TestPlayerStartCreateResourcesAndActivateKeepsCreateAndTransitionErrors(t *testing.T) {
154 mockOps := mocks.NewMockStreamOps(t)
155 createErr := errors.New("create main loop failed")
156
157 mockOps.EXPECT().CreateMainLoop().Return(nil, createErr)
158
159 p := newPlayer(defaultTestConfig(), PlayerCallbacks{})
160 p.streamOps = mockOps
161
162 err := p.startCreateResourcesAndActivate()
163 if err == nil {
164 t.Fatal("expected startCreateResourcesAndActivate to fail, got nil")
165 }
166 if !errors.Is(err, createErr) {
167 t.Fatalf("expected createErr, got %v", err)
168 }
169 if !errors.Is(err, ErrInvalidPlayerState) {
170 t.Fatalf("expected ErrInvalidPlayerState, got %v", err)
171 }
172}
173
174func TestPlayerEnsureRuntimeWrapsInitPipeWireError(t *testing.T) {
175 origRegister := registerPipeWire
176 origDefault := defaultPipeWireCAPI
177 origOps := defaultPipeWireOps
178 origInit := initializePipeWire
179 t.Cleanup(func() {
180 registerPipeWire = origRegister
181 defaultPipeWireCAPI = origDefault
182 defaultPipeWireOps = origOps
183 initializePipeWire = origInit
184 })
185
186 initErr := errors.New("init pipewire failed")
187 registerPipeWire = func() error { return nil }
188 defaultPipeWireCAPI = func() portout.CAPI { return mocks.NewMockCAPI(t) }
189 defaultPipeWireOps = func() portout.StreamOps {
190 t.Fatal("defaultPipeWireOps should not be called when init fails")
191 return nil
192 }
193 initializePipeWire = func(portout.CAPI) error { return initErr }
194
195 p := newPlayer(defaultTestConfig(), PlayerCallbacks{})
196 err := p.ensureRuntime()
197 if err == nil {
198 t.Fatal("expected ensureRuntime to fail, got nil")
199 }
200 if !errors.Is(err, initErr) {
201 t.Fatalf("expected initErr, got %v", err)
202 }
203 if !strings.Contains(err.Error(), "initialize pipewire runtime") {
204 t.Fatalf("expected context in error, got %v", err)
205 }
206}
207
208func TestPlayerStartTransitionsThroughStartingToPlaying(t *testing.T) {
209 mockOps := mocks.NewMockStreamOps(t)
210 fakeLoop := opaqueTestPtr()
211 fakeStream := opaqueTestPtr()
212 cfg := defaultTestConfig()
213
214 wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
215
216 p := newPlayer(cfg, PlayerCallbacks{})
217 p.streamOps = mockOps
218
219 // Initial state should be Idle
220 if p.State() != PlayerStateIdle {
221 t.Fatalf("expected initial state Idle, got %v", p.State())
222 }
223
224 // Start should transition to Playing
225 if err := p.Start(); err != nil {
226 t.Fatalf("Start failed: %v", err)
227 }
228 waitOnMainLoop(t, wg)
229
230 if p.State() != PlayerStatePlaying {
231 t.Fatalf("expected Playing state after Start, got %v", p.State())
232 }
233}
234
235func TestPlayerPauseTransitionsToPaused(t *testing.T) {
236 mockOps := mocks.NewMockStreamOps(t)
237 fakeLoop := opaqueTestPtr()
238 fakeStream := opaqueTestPtr()
239 cfg := defaultTestConfig()
240
241 wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
242
243 p := newPlayer(cfg, PlayerCallbacks{})
244 p.streamOps = mockOps
245
246 // Start first
247 if err := p.Start(); err != nil {
248 t.Fatalf("Start failed: %v", err)
249 }
250 waitOnMainLoop(t, wg)
251
252 // Pause should transition to Paused
253 if err := p.Pause(); err != nil {
254 t.Fatalf("Pause failed: %v", err)
255 }
256
257 if p.State() != PlayerStatePaused {
258 t.Fatalf("expected Paused state after Pause, got %v", p.State())
259 }
260}
261
262func TestPlayerStopClearsPausedState(t *testing.T) {
263 mockOps := mocks.NewMockStreamOps(t)
264 fakeLoop := opaqueTestPtr()
265 fakeStream := opaqueTestPtr()
266 cfg := defaultTestConfig()
267
268 wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
269 mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(nil)
270
271 p := newPlayer(cfg, PlayerCallbacks{})
272 p.streamOps = mockOps
273
274 // Start then pause
275 if err := p.Start(); err != nil {
276 t.Fatalf("Start failed: %v", err)
277 }
278 waitOnMainLoop(t, wg)
279
280 if err := p.Pause(); err != nil {
281 t.Fatalf("Pause failed: %v", err)
282 }
283 if p.State() != PlayerStatePaused {
284 t.Fatalf("expected Paused state, got %v", p.State())
285 }
286
287 // Stop should transition to Stopped (clearing paused state)
288 if err := p.Stop(); err != nil {
289 t.Fatalf("Stop failed: %v", err)
290 }
291
292 if p.State() != PlayerStateStopped {
293 t.Fatalf("expected Stopped state after Stop, got %v", p.State())
294 }
295}
296
297func TestPlayerCloseIsIdempotent(t *testing.T) {
298 mockOps := mocks.NewMockStreamOps(t)
299 fakeLoop := opaqueTestPtr()
300 fakeStream := opaqueTestPtr()
301 cfg := defaultTestConfig()
302
303 wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
304 mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
305 mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
306 mockOps.EXPECT().DestroyStream(fakeStream).Return()
307 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
308
309 p := newPlayer(cfg, PlayerCallbacks{})
310 p.streamOps = mockOps
311
312 // Start first
313 if err := p.Start(); err != nil {
314 t.Fatalf("Start failed: %v", err)
315 }
316 waitOnMainLoop(t, wg)
317
318 // Close should work
319 if err := p.Close(); err != nil {
320 t.Fatalf("first Close failed: %v", err)
321 }
322 if p.State() != PlayerStateClosed {
323 t.Fatalf("expected Closed state, got %v", p.State())
324 }
325
326 // Second close should also succeed (idempotent)
327 if err := p.Close(); err != nil {
328 t.Fatalf("second Close failed: %v", err)
329 }
330 if p.State() != PlayerStateClosed {
331 t.Fatalf("expected Closed state after second close, got %v", p.State())
332 }
333}
334
335// TestPlayerStopDeactivatesStream verifies that Stop() calls
336// SetStreamActive(false) via StreamOps and transitions to Stopped.
337func TestPlayerStopDeactivatesStream(t *testing.T) {
338 mockOps := mocks.NewMockStreamOps(t)
339 fakeLoop := opaqueTestPtr()
340 fakeStream := opaqueTestPtr()
341 cfg := defaultTestConfig()
342
343 wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
344 mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(nil)
345
346 p := newPlayer(cfg, PlayerCallbacks{})
347 p.streamOps = mockOps
348
349 // Start the player
350 if err := p.Start(); err != nil {
351 t.Fatalf("Start failed: %v", err)
352 }
353 waitOnMainLoop(t, wg)
354 if p.State() != PlayerStatePlaying {
355 t.Fatalf("expected Playing state, got %v", p.State())
356 }
357
358 // Stop should call SetStreamActive(false) and transition to Stopped
359 if err := p.Stop(); err != nil {
360 t.Fatalf("Stop failed: %v", err)
361 }
362 if p.State() != PlayerStateStopped {
363 t.Fatalf("expected Stopped state after Stop, got %v", p.State())
364 }
365}
366
367// TestPlayerStopReturnsDeactivateError verifies that Stop() surfaces the
368// SetStreamActive error and leaves the player in the pre-stop state.
369func TestPlayerStopReturnsDeactivateError(t *testing.T) {
370 mockOps := mocks.NewMockStreamOps(t)
371 fakeLoop := opaqueTestPtr()
372 fakeStream := opaqueTestPtr()
373 deactivateErr := errors.New("deactivate failed")
374 cfg := defaultTestConfig()
375
376 wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
377 mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(deactivateErr)
378
379 p := newPlayer(cfg, PlayerCallbacks{})
380 p.streamOps = mockOps
381
382 // Start the player
383 if err := p.Start(); err != nil {
384 t.Fatalf("Start failed: %v", err)
385 }
386 waitOnMainLoop(t, wg)
387 if p.State() != PlayerStatePlaying {
388 t.Fatalf("expected Playing state, got %v", p.State())
389 }
390
391 // Stop should return the deactivation error
392 err := p.Stop()
393 if err == nil {
394 t.Fatal("expected deactivation error, got nil")
395 }
396 if !errors.Is(err, deactivateErr) {
397 t.Fatalf("expected %v, got %v", deactivateErr, err)
398 }
399
400 // State should remain Playing (not transitioned to Stopped)
401 if p.State() != PlayerStatePlaying {
402 t.Fatalf("expected state to remain Playing after failed deactivation, got %v", p.State())
403 }
404}
405
406// TestPlayerStopWithNilStreamPtrIsNoop verifies that Stop() works
407// when streamOps is set but streamPtr is nil (backward compat).
408func TestPlayerStopWithNilStreamPtrIsNoop(t *testing.T) {
409 mockOps := mocks.NewMockStreamOps(t)
410 cfg := defaultTestConfig()
411
412 p := newPlayer(cfg, PlayerCallbacks{})
413 p.streamOps = mockOps
414
415 // Set state to Playing manually (without going through Start)
416 // to test Stop without an active stream pointer
417 p.setState(PlayerStatePlaying)
418
419 // Stop without a streamPtr should succeed (deactivation is a no-op)
420 if err := p.Stop(); err != nil {
421 t.Fatalf("Stop failed: %v", err)
422 }
423 if p.State() != PlayerStateStopped {
424 t.Fatalf("expected Stopped state, got %v", p.State())
425 }
426}
427
428// TestPlayerCloseTeardownCallsStreamOps verifies that Close() calls
429// DisconnectStream, DestroyStream, QuitMainLoop, and DestroyMainLoop through StreamOps
430// when the corresponding pointers are set.
431func TestPlayerCloseTeardownCallsStreamOps(t *testing.T) {
432 mockOps := mocks.NewMockStreamOps(t)
433 fakeStream := opaqueTestPtr()
434 fakeLoop := opaqueTestPtr()
435
436 // Expect teardown calls in order: DisconnectStream, QuitMainLoop, DestroyStream, DestroyMainLoop.
437 mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
438 mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
439 mockOps.EXPECT().DestroyStream(fakeStream).Return()
440 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
441
442 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
443 p.streamOps = mockOps
444 p.streamPtr = fakeStream
445 p.loopPtr = fakeLoop
446
447 // Move to a state that allows Close.
448 p.setState(PlayerStatePlaying)
449
450 if err := p.Close(); err != nil {
451 t.Fatalf("Close failed: %v", err)
452 }
453 if p.State() != PlayerStateClosed {
454 t.Fatalf("expected Closed state, got %v", p.State())
455 }
456
457 // Pointers should be nil after teardown so repeated close is safe.
458 if p.streamPtr != nil {
459 t.Error("expected streamPtr to be nil after teardown")
460 }
461 if p.loopPtr != nil {
462 t.Error("expected loopPtr to be nil after teardown")
463 }
464}
465
466// TestPlayerCloseWithNilPointersIsNoop verifies that teardown is safe
467// when no stream/loop pointers are set.
468func TestPlayerCloseWithNilPointersIsNoop(t *testing.T) {
469 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
470 p.setState(PlayerStatePlaying)
471
472 // No streamOps, no pointers — Close should succeed without panic.
473 if err := p.Close(); err != nil {
474 t.Fatalf("Close failed: %v", err)
475 }
476 if p.State() != PlayerStateClosed {
477 t.Fatalf("expected Closed state, got %v", p.State())
478 }
479}
480
481// TestPlayerRepeatedCloseIsSafe verifies that calling Close() twice
482// with teardown resources does not call StreamOps methods a second time.
483func TestPlayerRepeatedCloseIsSafe(t *testing.T) {
484 mockOps := mocks.NewMockStreamOps(t)
485 fakeStream := opaqueTestPtr()
486 fakeLoop := opaqueTestPtr()
487
488 // Only expect one set of teardown calls.
489 mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
490 mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
491 mockOps.EXPECT().DestroyStream(fakeStream).Return()
492 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
493
494 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
495 p.streamOps = mockOps
496 p.streamPtr = fakeStream
497 p.loopPtr = fakeLoop
498 p.setState(PlayerStatePlaying)
499
500 if err := p.Close(); err != nil {
501 t.Fatalf("first Close failed: %v", err)
502 }
503
504 // Second close should succeed and not call StreamOps again.
505 if err := p.Close(); err != nil {
506 t.Fatalf("second Close failed: %v", err)
507 }
508}
509
510// TestPlayerTeardownWithOnlyStreamPtr verifies teardown works
511// when only streamPtr is set (no loopPtr).
512func TestPlayerTeardownWithOnlyStreamPtr(t *testing.T) {
513 mockOps := mocks.NewMockStreamOps(t)
514 fakeStream := opaqueTestPtr()
515
516 mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
517 mockOps.EXPECT().DestroyStream(fakeStream).Return()
518
519 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
520 p.streamOps = mockOps
521 p.streamPtr = fakeStream
522 p.setState(PlayerStatePlaying)
523
524 if err := p.Close(); err != nil {
525 t.Fatalf("Close failed: %v", err)
526 }
527 if p.streamPtr != nil {
528 t.Error("expected streamPtr to be nil after teardown")
529 }
530}
531
532// TestPlayerTeardownWithOnlyLoopPtr verifies teardown works
533// when only loopPtr is set (no streamPtr).
534func TestPlayerTeardownWithOnlyLoopPtr(t *testing.T) {
535 mockOps := mocks.NewMockStreamOps(t)
536 fakeLoop := opaqueTestPtr()
537
538 mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
539 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
540
541 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
542 p.streamOps = mockOps
543 p.loopPtr = fakeLoop
544 p.setState(PlayerStatePlaying)
545
546 if err := p.Close(); err != nil {
547 t.Fatalf("Close failed: %v", err)
548 }
549 if p.loopPtr != nil {
550 t.Error("expected loopPtr to be nil after teardown")
551 }
552}
553
554func TestPlayerCloseWaitsForMainLoopExitBeforeDestroy(t *testing.T) {
555 mockOps := mocks.NewMockStreamOps(t)
556 fakeLoop := opaqueTestPtr()
557 loopDone := make(chan struct{})
558 quitCalled := make(chan struct{})
559 destroyCalled := make(chan struct{})
560 closeReturned := make(chan error, 1)
561
562 mockOps.EXPECT().QuitMainLoop(fakeLoop).Run(func(unsafe.Pointer) {
563 close(quitCalled)
564 }).Return()
565 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Run(func(unsafe.Pointer) {
566 close(destroyCalled)
567 }).Return()
568
569 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
570 p.streamOps = mockOps
571 p.loopPtr = fakeLoop
572 p.loopDone = loopDone
573 p.setState(PlayerStatePlaying)
574
575 go func() {
576 closeReturned <- p.Close()
577 }()
578
579 select {
580 case <-quitCalled:
581 case <-time.After(2 * time.Second):
582 t.Fatal("timed out waiting for QuitMainLoop")
583 }
584
585 select {
586 case <-destroyCalled:
587 t.Fatal("DestroyMainLoop called before loopDone was closed")
588 case <-time.After(100 * time.Millisecond):
589 }
590
591 close(loopDone)
592
593 select {
594 case err := <-closeReturned:
595 if err != nil {
596 t.Fatalf("Close failed: %v", err)
597 }
598 case <-time.After(2 * time.Second):
599 t.Fatal("timed out waiting for Close to return")
600 }
601
602 select {
603 case <-destroyCalled:
604 case <-time.After(2 * time.Second):
605 t.Fatal("timed out waiting for DestroyMainLoop after loopDone")
606 }
607}
608
609func TestPlayerCloseWaitsForMainLoopExitBeforeDestroyingStream(t *testing.T) {
610 mockOps := mocks.NewMockStreamOps(t)
611 fakeLoop := opaqueTestPtr()
612 fakeStream := opaqueTestPtr()
613 loopDone := make(chan struct{})
614 quitCalled := make(chan struct{})
615 streamDestroyed := make(chan struct{})
616 closeReturned := make(chan error, 1)
617
618 mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
619 mockOps.EXPECT().QuitMainLoop(fakeLoop).Run(func(unsafe.Pointer) {
620 close(quitCalled)
621 }).Return()
622 mockOps.EXPECT().DestroyStream(fakeStream).Run(func(unsafe.Pointer) {
623 close(streamDestroyed)
624 }).Return()
625 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
626
627 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
628 p.streamOps = mockOps
629 p.streamPtr = fakeStream
630 p.loopPtr = fakeLoop
631 p.loopDone = loopDone
632 p.setState(PlayerStatePlaying)
633
634 go func() {
635 closeReturned <- p.Close()
636 }()
637
638 select {
639 case <-quitCalled:
640 case <-time.After(2 * time.Second):
641 t.Fatal("timed out waiting for QuitMainLoop")
642 }
643
644 select {
645 case <-streamDestroyed:
646 t.Fatal("DestroyStream called before loopDone was closed")
647 case <-time.After(100 * time.Millisecond):
648 }
649
650 close(loopDone)
651
652 select {
653 case err := <-closeReturned:
654 if err != nil {
655 t.Fatalf("Close failed: %v", err)
656 }
657 case <-time.After(2 * time.Second):
658 t.Fatal("timed out waiting for Close to return")
659 }
660
661 select {
662 case <-streamDestroyed:
663 case <-time.After(2 * time.Second):
664 t.Fatal("timed out waiting for DestroyStream after loopDone")
665 }
666}
667
668// TestPlayerTeardownCallsDisconnectStream verifies that teardown calls
669// DisconnectStream before DestroyStream when a stream exists.
670func TestPlayerTeardownCallsDisconnectStream(t *testing.T) {
671 mockOps := mocks.NewMockStreamOps(t)
672 fakeStream := opaqueTestPtr()
673 fakeLoop := opaqueTestPtr()
674
675 // Expect disconnect before destroy, and stream destroy only after loop shutdown begins.
676 mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
677 mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
678 mockOps.EXPECT().DestroyStream(fakeStream).Return()
679 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
680
681 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
682 p.streamOps = mockOps
683 p.streamPtr = fakeStream
684 p.loopPtr = fakeLoop
685 p.setState(PlayerStatePlaying)
686
687 if err := p.Close(); err != nil {
688 t.Fatalf("Close failed: %v", err)
689 }
690}
691
692// TestPlayerTeardownContinuesOnDisconnectError verifies that teardown
693// continues cleanup even if DisconnectStream returns an error.
694func TestPlayerTeardownContinuesOnDisconnectError(t *testing.T) {
695 mockOps := mocks.NewMockStreamOps(t)
696 fakeStream := opaqueTestPtr()
697 fakeLoop := opaqueTestPtr()
698 disconnectErr := errors.New("disconnect failed")
699
700 // Disconnect fails, but teardown should still quit the loop and destroy the stream.
701 mockOps.EXPECT().DisconnectStream(fakeStream).Return(disconnectErr)
702 mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
703 mockOps.EXPECT().DestroyStream(fakeStream).Return()
704 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
705
706 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
707 p.streamOps = mockOps
708 p.streamPtr = fakeStream
709 p.loopPtr = fakeLoop
710 p.setState(PlayerStatePlaying)
711
712 // Close should succeed even though disconnect failed (best-effort cleanup)
713 if err := p.Close(); err != nil {
714 t.Fatalf("Close should succeed despite disconnect error: %v", err)
715 }
716}
717
718// TestPlayerRestartActivationFailureCleansUpResources verifies that when
719// SetStreamActive(true) fails during a restart (after Stop), owned stream
720// and loop resources are cleaned up and no stale pointers remain.
721func TestPlayerRestartActivationFailureCleansUpResources(t *testing.T) {
722 mockOps := mocks.NewMockStreamOps(t)
723 fakeLoop := opaqueTestPtr()
724 fakeStream := opaqueTestPtr()
725 cfg := defaultTestConfig()
726 activateErr := errors.New("activate failed on restart")
727
728 // First start: create resources
729 wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
730 // Stop: deactivate
731 mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(nil)
732 // Restart: activation fails → teardown cleans up
733 mockOps.EXPECT().SetStreamActive(fakeStream, true).Return(activateErr)
734 // teardown should call DisconnectStream, QuitMainLoop, DestroyStream, DestroyMainLoop
735 mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
736 mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
737 mockOps.EXPECT().DestroyStream(fakeStream).Return()
738 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
739
740 p := newPlayer(cfg, PlayerCallbacks{})
741 p.streamOps = mockOps
742
743 // Start
744 if err := p.Start(); err != nil {
745 t.Fatalf("Start failed: %v", err)
746 }
747 waitOnMainLoop(t, wg)
748
749 // Stop
750 if err := p.Stop(); err != nil {
751 t.Fatalf("Stop failed: %v", err)
752 }
753
754 // Restart should fail because SetStreamActive fails
755 err := p.Start()
756 if err == nil {
757 t.Fatal("expected Start to fail on restart activation failure")
758 }
759 if !errors.Is(err, activateErr) {
760 t.Fatalf("expected activateErr, got %v", err)
761 }
762
763 // Player should be in Error state
764 if p.State() != PlayerStateError {
765 t.Errorf("expected Error state after failed restart activation, got %v", p.State())
766 }
767
768 // Stale pointers must be cleared
769 p.mu.Lock()
770 loopPtr := p.loopPtr
771 streamPtr := p.streamPtr
772 p.mu.Unlock()
773
774 if loopPtr != nil {
775 t.Error("expected loopPtr to be nil after failed restart activation")
776 }
777 if streamPtr != nil {
778 t.Error("expected streamPtr to be nil after failed restart activation")
779 }
780}
781
782// TestPlayerRestartDoesNotStartSecondMainLoop verifies that when restarting
783// after Stop (reusing existing resources), no second RunMainLoop call is
784// made — only SetStreamActive(true) is called.
785func TestPlayerRestartDoesNotStartSecondMainLoop(t *testing.T) {
786 mockOps := mocks.NewMockStreamOps(t)
787 fakeLoop := opaqueTestPtr()
788 fakeStream := opaqueTestPtr()
789 cfg := defaultTestConfig()
790
791 // First start: full resource creation + RunMainLoop
792 wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
793 // Stop: deactivate
794 mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(nil)
795 // Restart: only SetStreamActive(true) — NO CreateMainLoop, NO RunMainLoop
796 mockOps.EXPECT().SetStreamActive(fakeStream, true).Return(nil)
797
798 p := newPlayer(cfg, PlayerCallbacks{})
799 p.streamOps = mockOps
800
801 // Start
802 if err := p.Start(); err != nil {
803 t.Fatalf("Start failed: %v", err)
804 }
805 waitOnMainLoop(t, wg)
806
807 // Stop
808 if err := p.Stop(); err != nil {
809 t.Fatalf("Stop failed: %v", err)
810 }
811
812 // Restart — mock will fail if any extra calls are made
813 if err := p.Start(); err != nil {
814 t.Fatalf("Restart failed: %v", err)
815 }
816
817 if p.State() != PlayerStatePlaying {
818 t.Fatalf("expected Playing state after restart, got %v", p.State())
819 }
820}