package playback import ( "context" "errors" "sync" "testing" "time" ) type fakePlaylistTimer struct { ch chan time.Time mu sync.Mutex stopped bool } func newFakePlaylistTimer() *fakePlaylistTimer { return &fakePlaylistTimer{ch: make(chan time.Time, 1)} } func (t *fakePlaylistTimer) C() <-chan time.Time { return t.ch } func (t *fakePlaylistTimer) Stop() bool { t.mu.Lock() defer t.mu.Unlock() alreadyStopped := t.stopped t.stopped = true return !alreadyStopped } func (t *fakePlaylistTimer) isStopped() bool { t.mu.Lock() defer t.mu.Unlock() return t.stopped } func (t *fakePlaylistTimer) fire(at time.Time) { t.ch <- at } func timedPlaylist(loop bool) Playlist { return Playlist{ Entries: []PlaylistEntry{ { Name: "first", Video: PlaylistFeed{Domain: "domain", UUID: "video-1"}, Duration: 10 * time.Second, }, { Name: "second", Audio: PlaylistFeed{Domain: "domain", UUID: "audio-2"}, Duration: 20 * time.Second, }, }, Loop: loop, } } func TestPlaylistControllerStartsOneTimerForMatchingReadiness(t *testing.T) { controller, commands, readiness, sessions, timers, now, cancel, result := startTimedPlaylistController(t, timedPlaylist(false)) defer cancel() commands <- PlaylistCommand{Kind: PlaylistNext} _ = receivePlaylistSession(t, sessions) waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { return snapshot.Revision == 1 }) readiness <- PlaylistReadiness{Revision: 0} assertNoPlaylistTimer(t, timers) readiness <- PlaylistReadiness{Revision: 1} timer := receiveFakePlaylistTimer(t, timers) snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { return snapshot.Timing.Started }) if snapshot.Timing.Deadline != now.Add(10*time.Second) { t.Fatalf("deadline = %v, want %v", snapshot.Timing.Deadline, now.Add(10*time.Second)) } readiness <- PlaylistReadiness{Revision: 1} assertNoPlaylistTimer(t, timers) if timer.isStopped() { t.Fatal("timer stopped after duplicate readiness") } close(commands) if err := waitForPlaylistResult(t, result); err != nil { t.Fatalf("Run() error = %v", err) } if !timer.isStopped() { t.Fatal("timer was not stopped when commands closed") } } func TestPlaylistControllerTimerExpiryAdvances(t *testing.T) { controller, commands, readiness, sessions, timers, now, cancel, result := startTimedPlaylistController(t, timedPlaylist(false)) defer cancel() commands <- PlaylistCommand{Kind: PlaylistNext} _ = receivePlaylistSession(t, sessions) readiness <- PlaylistReadiness{Revision: 1} timer := receiveFakePlaylistTimer(t, timers) timer.fire(now.Add(10 * time.Second)) session := receivePlaylistSession(t, sessions) if session.Session.Audio.UUID != "audio-2" || session.Session.Video.IsConfigured() { t.Fatalf("advanced session = %#v, want audio-only second entry", session.Session) } snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { return snapshot.Revision == 2 }) if snapshot.State.CurrentIndex != 1 || snapshot.Timing.Started { t.Fatalf("advanced snapshot = %#v", snapshot) } if snapshot.Timing.Duration != 20*time.Second { t.Fatalf("next duration = %v, want %v", snapshot.Timing.Duration, 20*time.Second) } close(commands) if err := waitForPlaylistResult(t, result); err != nil { t.Fatalf("Run() error = %v", err) } } func TestPlaylistControllerLoopingTimerExpiryWraps(t *testing.T) { controller, commands, readiness, sessions, timers, now, cancel, result := startTimedPlaylistController(t, timedPlaylist(true)) defer cancel() commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} _ = receivePlaylistSession(t, sessions) readiness <- PlaylistReadiness{Revision: 1} timer := receiveFakePlaylistTimer(t, timers) waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { return snapshot.Timing.Started }) timer.fire(now.Add(20 * time.Second)) session := receivePlaylistSession(t, sessions) if session.Session.Video.UUID != "video-1" { t.Fatalf("wrapped session = %#v, want first entry", session.Session) } waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { return snapshot.Revision == 2 && snapshot.State.CurrentIndex == 0 }) close(commands) if err := waitForPlaylistResult(t, result); err != nil { t.Fatalf("Run() error = %v", err) } } func TestPlaylistControllerFinalExpiryStopsWithoutCommand(t *testing.T) { controller, commands, readiness, sessions, timers, now, cancel, result := startTimedPlaylistController(t, timedPlaylist(false)) defer cancel() commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} _ = receivePlaylistSession(t, sessions) readiness <- PlaylistReadiness{Revision: 1} timer := receiveFakePlaylistTimer(t, timers) waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { return snapshot.Timing.Started }) timer.fire(now.Add(20 * time.Second)) snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { return snapshot.Revision == 1 && !snapshot.Timing.Started }) if snapshot.State.CurrentIndex != 1 { t.Fatalf("final snapshot state = %#v, want final entry", snapshot.State) } select { case command := <-sessions: t.Fatalf("unexpected session command: %#v", command) case <-time.After(20 * time.Millisecond): } close(commands) if err := waitForPlaylistResult(t, result); err != nil { t.Fatalf("Run() error = %v", err) } } func TestPlaylistControllerManualSelectionStopsOldTimer(t *testing.T) { controller, commands, readiness, sessions, timers, now, cancel, result := startTimedPlaylistController(t, timedPlaylist(false)) defer cancel() commands <- PlaylistCommand{Kind: PlaylistNext} _ = receivePlaylistSession(t, sessions) readiness <- PlaylistReadiness{Revision: 1} oldTimer := receiveFakePlaylistTimer(t, timers) commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} _ = receivePlaylistSession(t, sessions) waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { return snapshot.Revision == 2 }) if !oldTimer.isStopped() { t.Fatal("old timer was not stopped by manual selection") } oldTimer.fire(now.Add(10 * time.Second)) select { case command := <-sessions: t.Fatalf("stale timer produced session command: %#v", command) case <-time.After(20 * time.Millisecond): } snapshot, _ := controller.Snapshot() if snapshot.Revision != 2 || snapshot.State.CurrentIndex != 1 { t.Fatalf("stale timer changed snapshot: %#v", snapshot) } close(commands) if err := waitForPlaylistResult(t, result); err != nil { t.Fatalf("Run() error = %v", err) } } func TestPlaylistControllerZeroDurationDoesNotCreateTimer(t *testing.T) { playlist := timedPlaylist(false) playlist.Entries[0].Duration = 0 _, commands, readiness, sessions, timers, _, cancel, result := startTimedPlaylistController(t, playlist) defer cancel() commands <- PlaylistCommand{Kind: PlaylistNext} _ = receivePlaylistSession(t, sessions) readiness <- PlaylistReadiness{Revision: 1} assertNoPlaylistTimer(t, timers) close(commands) if err := waitForPlaylistResult(t, result); err != nil { t.Fatalf("Run() error = %v", err) } } func TestPlaylistControllerCancellationStopsTimer(t *testing.T) { _, commands, readiness, sessions, timers, _, cancel, result := startTimedPlaylistController(t, timedPlaylist(false)) commands <- PlaylistCommand{Kind: PlaylistNext} _ = receivePlaylistSession(t, sessions) readiness <- PlaylistReadiness{Revision: 1} timer := receiveFakePlaylistTimer(t, timers) cancel() if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) { t.Fatalf("Run() error = %v, want %v", err, context.Canceled) } if !timer.isStopped() { t.Fatal("timer was not stopped on cancellation") } } func startTimedPlaylistController( t *testing.T, playlist Playlist, ) ( *PlaylistController, chan PlaylistCommand, chan PlaylistReadiness, chan SessionCommand, chan *fakePlaylistTimer, time.Time, context.CancelFunc, <-chan error, ) { t.Helper() sessions := make(chan SessionCommand, 16) controller, err := NewPlaylistController(playlist, validPlaylistRetryPolicy(), sessions) if err != nil { t.Fatalf("NewPlaylistController() error = %v", err) } now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) controller.now = func() time.Time { return now } timers := make(chan *fakePlaylistTimer, 16) controller.newTimer = func(time.Duration) playlistTimer { timer := newFakePlaylistTimer() timers <- timer return timer } commands := make(chan PlaylistCommand, 16) readiness := make(chan PlaylistReadiness, 16) ctx, cancel := context.WithCancel(context.Background()) result := make(chan error, 1) go func() { result <- controller.Run(ctx, commands, readiness) }() return controller, commands, readiness, sessions, timers, now, cancel, result } func receiveFakePlaylistTimer(t *testing.T, timers <-chan *fakePlaylistTimer) *fakePlaylistTimer { t.Helper() select { case timer := <-timers: return timer case <-time.After(time.Second): t.Fatal("timed out waiting for playlist timer") return nil } } func assertNoPlaylistTimer(t *testing.T, timers <-chan *fakePlaylistTimer) { t.Helper() select { case timer := <-timers: t.Fatalf("unexpected playlist timer: %#v", timer) case <-time.After(20 * time.Millisecond): } }