automatic playlist timing into PlaylistController
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
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):
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user