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().DestroyStream(fakeStream).Return()
79 mockOps.EXPECT().QuitMainLoop(fakeLoop).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().DestroyStream(fakeStream).Return()
306 mockOps.EXPECT().QuitMainLoop(fakeLoop).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, DestroyStream, QuitMainLoop, DestroyMainLoop.
437 mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
438 mockOps.EXPECT().DestroyStream(fakeStream).Return()
439 mockOps.EXPECT().QuitMainLoop(fakeLoop).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().DestroyStream(fakeStream).Return()
491 mockOps.EXPECT().QuitMainLoop(fakeLoop).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
554// TestPlayerTeardownCallsDisconnectStream verifies that teardown calls
555// DisconnectStream before DestroyStream when a stream exists.
556func TestPlayerTeardownCallsDisconnectStream(t *testing.T) {
557 mockOps := mocks.NewMockStreamOps(t)
558 fakeStream := opaqueTestPtr()
559 fakeLoop := opaqueTestPtr()
560
561 // Expect disconnect before destroy
562 mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
563 mockOps.EXPECT().DestroyStream(fakeStream).Return()
564 mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
565 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
566
567 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
568 p.streamOps = mockOps
569 p.streamPtr = fakeStream
570 p.loopPtr = fakeLoop
571 p.setState(PlayerStatePlaying)
572
573 if err := p.Close(); err != nil {
574 t.Fatalf("Close failed: %v", err)
575 }
576}
577
578// TestPlayerTeardownContinuesOnDisconnectError verifies that teardown
579// continues cleanup even if DisconnectStream returns an error.
580func TestPlayerTeardownContinuesOnDisconnectError(t *testing.T) {
581 mockOps := mocks.NewMockStreamOps(t)
582 fakeStream := opaqueTestPtr()
583 fakeLoop := opaqueTestPtr()
584 disconnectErr := errors.New("disconnect failed")
585
586 // Disconnect fails, but destroy should still be called
587 mockOps.EXPECT().DisconnectStream(fakeStream).Return(disconnectErr)
588 mockOps.EXPECT().DestroyStream(fakeStream).Return()
589 mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
590 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
591
592 p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
593 p.streamOps = mockOps
594 p.streamPtr = fakeStream
595 p.loopPtr = fakeLoop
596 p.setState(PlayerStatePlaying)
597
598 // Close should succeed even though disconnect failed (best-effort cleanup)
599 if err := p.Close(); err != nil {
600 t.Fatalf("Close should succeed despite disconnect error: %v", err)
601 }
602}
603
604// TestPlayerRestartActivationFailureCleansUpResources verifies that when
605// SetStreamActive(true) fails during a restart (after Stop), owned stream
606// and loop resources are cleaned up and no stale pointers remain.
607func TestPlayerRestartActivationFailureCleansUpResources(t *testing.T) {
608 mockOps := mocks.NewMockStreamOps(t)
609 fakeLoop := opaqueTestPtr()
610 fakeStream := opaqueTestPtr()
611 cfg := defaultTestConfig()
612 activateErr := errors.New("activate failed on restart")
613
614 // First start: create resources
615 wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
616 // Stop: deactivate
617 mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(nil)
618 // Restart: activation fails → teardown cleans up
619 mockOps.EXPECT().SetStreamActive(fakeStream, true).Return(activateErr)
620 // teardown should call DisconnectStream, DestroyStream, QuitMainLoop, DestroyMainLoop
621 mockOps.EXPECT().DisconnectStream(fakeStream).Return(nil)
622 mockOps.EXPECT().DestroyStream(fakeStream).Return()
623 mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
624 mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
625
626 p := newPlayer(cfg, PlayerCallbacks{})
627 p.streamOps = mockOps
628
629 // Start
630 if err := p.Start(); err != nil {
631 t.Fatalf("Start failed: %v", err)
632 }
633 waitOnMainLoop(t, wg)
634
635 // Stop
636 if err := p.Stop(); err != nil {
637 t.Fatalf("Stop failed: %v", err)
638 }
639
640 // Restart should fail because SetStreamActive fails
641 err := p.Start()
642 if err == nil {
643 t.Fatal("expected Start to fail on restart activation failure")
644 }
645 if !errors.Is(err, activateErr) {
646 t.Fatalf("expected activateErr, got %v", err)
647 }
648
649 // Player should be in Error state
650 if p.State() != PlayerStateError {
651 t.Errorf("expected Error state after failed restart activation, got %v", p.State())
652 }
653
654 // Stale pointers must be cleared
655 p.mu.Lock()
656 loopPtr := p.loopPtr
657 streamPtr := p.streamPtr
658 p.mu.Unlock()
659
660 if loopPtr != nil {
661 t.Error("expected loopPtr to be nil after failed restart activation")
662 }
663 if streamPtr != nil {
664 t.Error("expected streamPtr to be nil after failed restart activation")
665 }
666}
667
668// TestPlayerRestartDoesNotStartSecondMainLoop verifies that when restarting
669// after Stop (reusing existing resources), no second RunMainLoop call is
670// made — only SetStreamActive(true) is called.
671func TestPlayerRestartDoesNotStartSecondMainLoop(t *testing.T) {
672 mockOps := mocks.NewMockStreamOps(t)
673 fakeLoop := opaqueTestPtr()
674 fakeStream := opaqueTestPtr()
675 cfg := defaultTestConfig()
676
677 // First start: full resource creation + RunMainLoop
678 wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg)
679 // Stop: deactivate
680 mockOps.EXPECT().SetStreamActive(fakeStream, false).Return(nil)
681 // Restart: only SetStreamActive(true) — NO CreateMainLoop, NO RunMainLoop
682 mockOps.EXPECT().SetStreamActive(fakeStream, true).Return(nil)
683
684 p := newPlayer(cfg, PlayerCallbacks{})
685 p.streamOps = mockOps
686
687 // Start
688 if err := p.Start(); err != nil {
689 t.Fatalf("Start failed: %v", err)
690 }
691 waitOnMainLoop(t, wg)
692
693 // Stop
694 if err := p.Stop(); err != nil {
695 t.Fatalf("Stop failed: %v", err)
696 }
697
698 // Restart — mock will fail if any extra calls are made
699 if err := p.Start(); err != nil {
700 t.Fatalf("Restart failed: %v", err)
701 }
702
703 if p.State() != PlayerStatePlaying {
704 t.Fatalf("expected Playing state after restart, got %v", p.State())
705 }
706}