|
|
|
@@ -0,0 +1,368 @@
|
|
|
|
|
package playback
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"sync"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type fakePlaylistSnapshotSource struct {
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
snapshot PlaylistSnapshot
|
|
|
|
|
ok bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *fakePlaylistSnapshotSource) Snapshot() (PlaylistSnapshot, bool) {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
return s.snapshot, s.ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *fakePlaylistSnapshotSource) set(snapshot PlaylistSnapshot, ok bool) {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
s.snapshot = snapshot
|
|
|
|
|
s.ok = ok
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type fakeSessionSnapshotSource struct {
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
snapshot SessionSnapshot
|
|
|
|
|
ok bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *fakeSessionSnapshotSource) Snapshot() (SessionSnapshot, bool) {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
return s.snapshot, s.ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *fakeSessionSnapshotSource) set(snapshot SessionSnapshot, ok bool) {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
s.snapshot = snapshot
|
|
|
|
|
s.ok = ok
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type fakePlaybackStatusSnapshotSource struct {
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
snapshot PlaybackStatusSnapshot
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *fakePlaybackStatusSnapshotSource) SnapshotAll() PlaybackStatusSnapshot {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
return s.snapshot
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *fakePlaybackStatusSnapshotSource) set(snapshot PlaybackStatusSnapshot) {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
s.snapshot = snapshot
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type fakePlaylistReadinessTicker struct {
|
|
|
|
|
ch chan time.Time
|
|
|
|
|
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
stopped bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newFakePlaylistReadinessTicker() *fakePlaylistReadinessTicker {
|
|
|
|
|
return &fakePlaylistReadinessTicker{ch: make(chan time.Time, 16)}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t *fakePlaylistReadinessTicker) C() <-chan time.Time { return t.ch }
|
|
|
|
|
func (t *fakePlaylistReadinessTicker) Stop() {
|
|
|
|
|
t.mu.Lock()
|
|
|
|
|
t.stopped = true
|
|
|
|
|
t.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
func (t *fakePlaylistReadinessTicker) tick() { t.ch <- time.Now() }
|
|
|
|
|
func (t *fakePlaylistReadinessTicker) isStopped() bool {
|
|
|
|
|
t.mu.Lock()
|
|
|
|
|
defer t.mu.Unlock()
|
|
|
|
|
return t.stopped
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNewPlaylistReadinessCoordinatorValidatesDependencies(t *testing.T) {
|
|
|
|
|
playlist := &fakePlaylistSnapshotSource{}
|
|
|
|
|
session := &fakeSessionSnapshotSource{}
|
|
|
|
|
statuses := &fakePlaybackStatusSnapshotSource{}
|
|
|
|
|
output := make(chan PlaylistReadiness)
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
playlist PlaylistSnapshotSource
|
|
|
|
|
session SessionSnapshotSource
|
|
|
|
|
statuses PlaybackStatusSnapshotSource
|
|
|
|
|
output chan<- PlaylistReadiness
|
|
|
|
|
interval time.Duration
|
|
|
|
|
wantErr error
|
|
|
|
|
}{
|
|
|
|
|
{name: "playlist", session: session, statuses: statuses, output: output, interval: time.Millisecond, wantErr: ErrPlaylistSnapshotSourceRequired},
|
|
|
|
|
{name: "session", playlist: playlist, statuses: statuses, output: output, interval: time.Millisecond, wantErr: ErrSessionSnapshotSourceRequired},
|
|
|
|
|
{name: "statuses", playlist: playlist, session: session, output: output, interval: time.Millisecond, wantErr: ErrStatusSnapshotSourceRequired},
|
|
|
|
|
{name: "output", playlist: playlist, session: session, statuses: statuses, interval: time.Millisecond, wantErr: ErrPlaylistReadinessOutputRequired},
|
|
|
|
|
{name: "interval", playlist: playlist, session: session, statuses: statuses, output: output, wantErr: ErrPlaylistReadinessInterval},
|
|
|
|
|
{name: "valid", playlist: playlist, session: session, statuses: statuses, output: output, interval: time.Millisecond},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, test := range tests {
|
|
|
|
|
t.Run(test.name, func(t *testing.T) {
|
|
|
|
|
coordinator, err := NewPlaylistReadinessCoordinator(
|
|
|
|
|
test.playlist,
|
|
|
|
|
test.session,
|
|
|
|
|
test.statuses,
|
|
|
|
|
test.output,
|
|
|
|
|
test.interval,
|
|
|
|
|
)
|
|
|
|
|
if !errors.Is(err, test.wantErr) {
|
|
|
|
|
t.Fatalf("NewPlaylistReadinessCoordinator() error = %v, want %v", err, test.wantErr)
|
|
|
|
|
}
|
|
|
|
|
if test.wantErr != nil && coordinator != nil {
|
|
|
|
|
t.Fatalf("coordinator = %#v, want nil", coordinator)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPlaylistEntryMatchesSession(t *testing.T) {
|
|
|
|
|
entry := PlaylistEntry{
|
|
|
|
|
Video: PlaylistFeed{Domain: "video-domain", UUID: "video"},
|
|
|
|
|
Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"},
|
|
|
|
|
SyncRequested: true,
|
|
|
|
|
}
|
|
|
|
|
matching := entry.SessionConfig(validPlaylistRetryPolicy())
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
entry PlaylistEntry
|
|
|
|
|
session SessionConfig
|
|
|
|
|
want bool
|
|
|
|
|
}{
|
|
|
|
|
{name: "matching", entry: entry, session: matching, want: true},
|
|
|
|
|
{name: "retry ignored", entry: entry, session: func() SessionConfig { value := matching; value.Retry.MaxAttempts = 99; return value }(), want: true},
|
|
|
|
|
{name: "wrong video UUID", entry: entry, session: func() SessionConfig { value := matching; value.Video.UUID = "other"; return value }()},
|
|
|
|
|
{name: "wrong audio domain", entry: entry, session: func() SessionConfig { value := matching; value.Audio.Domain = "other"; return value }()},
|
|
|
|
|
{name: "inactive video", entry: entry, session: func() SessionConfig { value := matching; value.Video.Active = false; return value }()},
|
|
|
|
|
{name: "wrong sync request", entry: entry, session: func() SessionConfig { value := matching; value.SyncRequested = false; return value }()},
|
|
|
|
|
{
|
|
|
|
|
name: "absent audio matches unconfigured inactive",
|
|
|
|
|
entry: PlaylistEntry{Video: entry.Video},
|
|
|
|
|
session: PlaylistEntry{Video: entry.Video}.SessionConfig(validPlaylistRetryPolicy()),
|
|
|
|
|
want: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "absent audio rejects configured audio",
|
|
|
|
|
entry: PlaylistEntry{Video: entry.Video},
|
|
|
|
|
session: SessionConfig{
|
|
|
|
|
Video: matching.Video,
|
|
|
|
|
Audio: matching.Audio,
|
|
|
|
|
Retry: matching.Retry,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{name: "invalid entry", entry: PlaylistEntry{}, session: matching},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, test := range tests {
|
|
|
|
|
t.Run(test.name, func(t *testing.T) {
|
|
|
|
|
if got := PlaylistEntryMatchesSession(test.entry, test.session); got != test.want {
|
|
|
|
|
t.Fatalf("PlaylistEntryMatchesSession() = %v, want %v", got, test.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPlaylistReadinessCoordinatorEmitsOncePerRevision(t *testing.T) {
|
|
|
|
|
playlist, session, statuses := readyVideoSnapshots(1)
|
|
|
|
|
output := make(chan PlaylistReadiness, 4)
|
|
|
|
|
coordinator, ticker, cancel, result := startReadinessCoordinator(
|
|
|
|
|
t,
|
|
|
|
|
playlist,
|
|
|
|
|
session,
|
|
|
|
|
statuses,
|
|
|
|
|
output,
|
|
|
|
|
)
|
|
|
|
|
_ = coordinator
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
ticker.tick()
|
|
|
|
|
if got := receivePlaylistReadiness(t, output); got.Revision != 1 {
|
|
|
|
|
t.Fatalf("readiness revision = %d, want 1", got.Revision)
|
|
|
|
|
}
|
|
|
|
|
ticker.tick()
|
|
|
|
|
assertNoPlaylistReadiness(t, output)
|
|
|
|
|
|
|
|
|
|
next := playlistSnapshotForVideo(2)
|
|
|
|
|
playlist.set(next, true)
|
|
|
|
|
ticker.tick()
|
|
|
|
|
if got := receivePlaylistReadiness(t, output); got.Revision != 2 {
|
|
|
|
|
t.Fatalf("readiness revision = %d, want 2", got.Revision)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cancel()
|
|
|
|
|
if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) {
|
|
|
|
|
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
|
|
|
|
|
}
|
|
|
|
|
if !ticker.isStopped() {
|
|
|
|
|
t.Fatal("ticker was not stopped")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPlaylistReadinessCoordinatorWaitsForAllConditions(t *testing.T) {
|
|
|
|
|
playlist, session, statuses := readyVideoSnapshots(1)
|
|
|
|
|
output := make(chan PlaylistReadiness, 1)
|
|
|
|
|
_, ticker, cancel, result := startReadinessCoordinator(t, playlist, session, statuses, output)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
mutate func()
|
|
|
|
|
}{
|
|
|
|
|
{name: "no playlist snapshot", mutate: func() { playlist.set(PlaylistSnapshot{}, false) }},
|
|
|
|
|
{name: "no selection", mutate: func() {
|
|
|
|
|
value := playlistSnapshotForVideo(1)
|
|
|
|
|
value.State.HasSelection = false
|
|
|
|
|
playlist.set(value, true)
|
|
|
|
|
}},
|
|
|
|
|
{name: "zero revision", mutate: func() { value := playlistSnapshotForVideo(1); value.Revision = 0; playlist.set(value, true) }},
|
|
|
|
|
{name: "zero duration", mutate: func() { value := playlistSnapshotForVideo(1); value.Entry.Duration = 0; playlist.set(value, true) }},
|
|
|
|
|
{name: "already started", mutate: func() { value := playlistSnapshotForVideo(1); value.Timing.Started = true; playlist.set(value, true) }},
|
|
|
|
|
{name: "session mismatch", mutate: func() {
|
|
|
|
|
playlist.set(playlistSnapshotForVideo(1), true)
|
|
|
|
|
value, _ := session.Snapshot()
|
|
|
|
|
value.Desired.Video.UUID = "other"
|
|
|
|
|
session.set(value, true)
|
|
|
|
|
}},
|
|
|
|
|
{name: "stale statuses", mutate: func() {
|
|
|
|
|
playlist.set(playlistSnapshotForVideo(1), true)
|
|
|
|
|
_, validSession, _ := readyVideoSnapshots(1)
|
|
|
|
|
value, _ := validSession.Snapshot()
|
|
|
|
|
session.set(value, true)
|
|
|
|
|
current := statuses.SnapshotAll()
|
|
|
|
|
current.Generation = 2
|
|
|
|
|
current.Video.Generation = 2
|
|
|
|
|
statuses.set(current)
|
|
|
|
|
}},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, test := range tests {
|
|
|
|
|
t.Run(test.name, func(t *testing.T) {
|
|
|
|
|
validPlaylist, validSession, validStatuses := readyVideoSnapshots(1)
|
|
|
|
|
playlist.set(validPlaylist.snapshot, true)
|
|
|
|
|
session.set(validSession.snapshot, true)
|
|
|
|
|
statuses.set(validStatuses.snapshot)
|
|
|
|
|
test.mutate()
|
|
|
|
|
ticker.tick()
|
|
|
|
|
assertNoPlaylistReadiness(t, output)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cancel()
|
|
|
|
|
_ = waitForPlaylistResult(t, result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPlaylistReadinessCoordinatorCancellationWhileBlockedSending(t *testing.T) {
|
|
|
|
|
playlist, session, statuses := readyVideoSnapshots(1)
|
|
|
|
|
output := make(chan PlaylistReadiness)
|
|
|
|
|
_, ticker, cancel, result := startReadinessCoordinator(t, playlist, session, statuses, output)
|
|
|
|
|
|
|
|
|
|
ticker.tick()
|
|
|
|
|
time.Sleep(time.Millisecond)
|
|
|
|
|
cancel()
|
|
|
|
|
if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) {
|
|
|
|
|
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func readyVideoSnapshots(revision uint64) (
|
|
|
|
|
*fakePlaylistSnapshotSource,
|
|
|
|
|
*fakeSessionSnapshotSource,
|
|
|
|
|
*fakePlaybackStatusSnapshotSource,
|
|
|
|
|
) {
|
|
|
|
|
playlist := &fakePlaylistSnapshotSource{snapshot: playlistSnapshotForVideo(revision), ok: true}
|
|
|
|
|
entry := playlist.snapshot.Entry
|
|
|
|
|
desired := entry.SessionConfig(validPlaylistRetryPolicy())
|
|
|
|
|
session := &fakeSessionSnapshotSource{
|
|
|
|
|
snapshot: SessionSnapshot{
|
|
|
|
|
Desired: desired,
|
|
|
|
|
Plan: SessionPlan{Topology: TopologyIndependent, Video: desired.Video},
|
|
|
|
|
Generation: 5,
|
|
|
|
|
},
|
|
|
|
|
ok: true,
|
|
|
|
|
}
|
|
|
|
|
statuses := &fakePlaybackStatusSnapshotSource{
|
|
|
|
|
snapshot: PlaybackStatusSnapshot{
|
|
|
|
|
Generation: 5,
|
|
|
|
|
Video: Status{
|
|
|
|
|
Unit: UnitVideo,
|
|
|
|
|
State: StatePlaying,
|
|
|
|
|
Generation: 5,
|
|
|
|
|
Feed: desired.Video,
|
|
|
|
|
},
|
|
|
|
|
HasVideo: true,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
return playlist, session, statuses
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func playlistSnapshotForVideo(revision uint64) PlaylistSnapshot {
|
|
|
|
|
entry := PlaylistEntry{
|
|
|
|
|
Name: "video",
|
|
|
|
|
Video: PlaylistFeed{Domain: "domain", UUID: "video"},
|
|
|
|
|
Duration: 10 * time.Second,
|
|
|
|
|
}
|
|
|
|
|
return PlaylistSnapshot{
|
|
|
|
|
State: PlaylistState{CurrentIndex: 0, HasSelection: true},
|
|
|
|
|
Entry: entry,
|
|
|
|
|
Revision: revision,
|
|
|
|
|
Timing: NewPlaylistTiming(revision, entry.Duration),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func startReadinessCoordinator(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
playlist PlaylistSnapshotSource,
|
|
|
|
|
session SessionSnapshotSource,
|
|
|
|
|
statuses PlaybackStatusSnapshotSource,
|
|
|
|
|
output chan<- PlaylistReadiness,
|
|
|
|
|
) (*PlaylistReadinessCoordinator, *fakePlaylistReadinessTicker, context.CancelFunc, <-chan error) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
coordinator, err := NewPlaylistReadinessCoordinator(
|
|
|
|
|
playlist,
|
|
|
|
|
session,
|
|
|
|
|
statuses,
|
|
|
|
|
output,
|
|
|
|
|
time.Millisecond,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewPlaylistReadinessCoordinator() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
ticker := newFakePlaylistReadinessTicker()
|
|
|
|
|
coordinator.newTicker = func(time.Duration) playlistReadinessTicker { return ticker }
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
result := make(chan error, 1)
|
|
|
|
|
go func() { result <- coordinator.Run(ctx) }()
|
|
|
|
|
return coordinator, ticker, cancel, result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func receivePlaylistReadiness(t *testing.T, output <-chan PlaylistReadiness) PlaylistReadiness {
|
|
|
|
|
t.Helper()
|
|
|
|
|
select {
|
|
|
|
|
case readiness := <-output:
|
|
|
|
|
return readiness
|
|
|
|
|
case <-time.After(time.Second):
|
|
|
|
|
t.Fatal("timed out waiting for playlist readiness")
|
|
|
|
|
return PlaylistReadiness{}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func assertNoPlaylistReadiness(t *testing.T, output <-chan PlaylistReadiness) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
select {
|
|
|
|
|
case readiness := <-output:
|
|
|
|
|
t.Fatalf("unexpected playlist readiness: %#v", readiness)
|
|
|
|
|
case <-time.After(20 * time.Millisecond):
|
|
|
|
|
}
|
|
|
|
|
}
|