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