v0.2.0 4c97002

player_test.go RawHistoryFolder

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}

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 1

Tags 8

↑↓ moveenter openesc close