447 lines
11 KiB
Go
447 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
|
|
"mxl-player/internal/playback"
|
|
)
|
|
|
|
type playlistTestVideoSlot struct{}
|
|
|
|
func (playlistTestVideoSlot) Run(
|
|
ctx context.Context,
|
|
initial playback.FeedConfig,
|
|
commands <-chan playback.FeedConfig,
|
|
) error {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case _, ok := <-commands:
|
|
if !ok {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type playlistTestAudioSlot struct{}
|
|
|
|
func (playlistTestAudioSlot) Run(
|
|
ctx context.Context,
|
|
initial playback.FeedConfig,
|
|
commands <-chan playback.FeedConfig,
|
|
) error {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case _, ok := <-commands:
|
|
if !ok {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type playlistTestSyncSlot struct{}
|
|
|
|
func (playlistTestSyncSlot) Run(
|
|
ctx context.Context,
|
|
initial playback.SyncPairConfig,
|
|
commands <-chan playback.SyncPairConfig,
|
|
) error {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case _, ok := <-commands:
|
|
if !ok {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewPlayerPlaylistValidatesPlayer(t *testing.T) {
|
|
retry := playlistRuntimeRetry()
|
|
tests := []struct {
|
|
name string
|
|
player *playerPlayback
|
|
wantErr error
|
|
}{
|
|
{name: "nil player", wantErr: ErrPlayerPlaybackRequired},
|
|
{name: "nil controller", player: &playerPlayback{}, wantErr: ErrPlayerSessionControllerRequired},
|
|
{
|
|
name: "nil status store",
|
|
player: &playerPlayback{
|
|
Controller: newPlaylistTestSessionController(t),
|
|
},
|
|
wantErr: ErrPlayerStatusStoreRequired,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got, err := newPlayerPlaylist(playback.Playlist{}, retry, test.player)
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("newPlayerPlaylist() error = %v, want %v", err, test.wantErr)
|
|
}
|
|
if got != nil {
|
|
t.Fatalf("newPlayerPlaylist() = %#v, want nil", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewPlayerPlaylistWiresComponents(t *testing.T) {
|
|
player := newPlaylistTestPlayer(t)
|
|
runtime, err := newPlayerPlaylist(playback.Playlist{}, playlistRuntimeRetry(), player)
|
|
if err != nil {
|
|
t.Fatalf("newPlayerPlaylist() error = %v", err)
|
|
}
|
|
if runtime.Controller == nil || runtime.coordinator == nil {
|
|
t.Fatalf("runtime components = %#v", runtime)
|
|
}
|
|
if runtime.commands == nil || runtime.events == nil {
|
|
t.Fatalf("runtime channels = %#v", runtime)
|
|
}
|
|
}
|
|
|
|
func TestPlayerPlaylistNavigationHelpers(t *testing.T) {
|
|
runtime, err := newPlayerPlaylist(
|
|
playback.Playlist{},
|
|
playlistRuntimeRetry(),
|
|
newPlaylistTestPlayer(t),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("newPlayerPlaylist() error = %v", err)
|
|
}
|
|
tests := []struct {
|
|
send func() bool
|
|
want playback.PlaylistCommand
|
|
}{
|
|
{send: func() bool { return runtime.Select(4) }, want: playback.PlaylistCommand{Kind: playback.PlaylistSelect, Index: 4}},
|
|
{send: runtime.Next, want: playback.PlaylistCommand{Kind: playback.PlaylistNext}},
|
|
{send: runtime.Previous, want: playback.PlaylistCommand{Kind: playback.PlaylistPrevious}},
|
|
{send: runtime.Pause, want: playback.PlaylistCommand{Kind: playback.PlaylistPause}},
|
|
{send: runtime.Resume, want: playback.PlaylistCommand{Kind: playback.PlaylistResume}},
|
|
}
|
|
for _, test := range tests {
|
|
if !test.send() {
|
|
t.Fatal("navigation helper returned false")
|
|
}
|
|
if got := <-runtime.commands; got != test.want {
|
|
t.Fatalf("navigation command = %#v, want %#v", got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlayerPlaylistNavigationQueueFull(t *testing.T) {
|
|
runtime, err := newPlayerPlaylist(
|
|
playback.Playlist{},
|
|
playlistRuntimeRetry(),
|
|
newPlaylistTestPlayer(t),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("newPlayerPlaylist() error = %v", err)
|
|
}
|
|
for range cap(runtime.commands) {
|
|
if !runtime.Next() {
|
|
t.Fatal("queue filled before reaching capacity")
|
|
}
|
|
}
|
|
if runtime.Next() {
|
|
t.Fatal("Next() = true with full queue")
|
|
}
|
|
}
|
|
|
|
func TestPlayerPlaylistRunCancellationJoinsComponents(t *testing.T) {
|
|
runtime, err := newPlayerPlaylist(
|
|
playback.Playlist{},
|
|
playlistRuntimeRetry(),
|
|
newPlaylistTestPlayer(t),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("newPlayerPlaylist() error = %v", err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
result := make(chan error, 1)
|
|
go func() { result <- runtime.Run(ctx) }()
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-result:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for playlist runtime cancellation")
|
|
}
|
|
}
|
|
|
|
func TestPlayerPlaylistTimedEntryAdvances(t *testing.T) {
|
|
retry := playlistRuntimeRetry()
|
|
player := newPlaylistTestPlayer(t)
|
|
playlist := playback.Playlist{
|
|
Entries: []playback.PlaylistEntry{
|
|
{
|
|
Video: playback.PlaylistFeed{Domain: "domain", UUID: "video-1"},
|
|
Duration: 15 * time.Millisecond,
|
|
},
|
|
{
|
|
Video: playback.PlaylistFeed{Domain: "domain", UUID: "video-2"},
|
|
},
|
|
},
|
|
}
|
|
runtime, err := newPlayerPlaylist(playlist, retry, player)
|
|
if err != nil {
|
|
t.Fatalf("newPlayerPlaylist() error = %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
sessionResult := make(chan error, 1)
|
|
go func() {
|
|
sessionResult <- player.Controller.Run(
|
|
ctx,
|
|
playback.SessionConfig{Retry: retry},
|
|
player.Commands,
|
|
)
|
|
}()
|
|
playlistResult := make(chan error, 1)
|
|
go func() { playlistResult <- runtime.Run(ctx) }()
|
|
|
|
if !runtime.Next() {
|
|
t.Fatal("Next() = false")
|
|
}
|
|
first := waitForPlayerSession(t, player.Controller, func(snapshot playback.SessionSnapshot) bool {
|
|
return snapshot.Desired.Video.UUID == "video-1"
|
|
})
|
|
player.Status.Observe(playback.Status{
|
|
Unit: playback.UnitVideo,
|
|
State: playback.StatePlaying,
|
|
Generation: first.Generation,
|
|
Feed: first.Plan.Video,
|
|
})
|
|
|
|
second := waitForPlayerSession(t, player.Controller, func(snapshot playback.SessionSnapshot) bool {
|
|
return snapshot.Desired.Video.UUID == "video-2"
|
|
})
|
|
if second.Desired.Audio.IsConfigured() {
|
|
t.Fatalf("advanced session audio = %#v, want unconfigured", second.Desired.Audio)
|
|
}
|
|
|
|
cancel()
|
|
if err := waitForPlayerRuntimeResult(t, playlistResult); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("playlist Run() error = %v, want %v", err, context.Canceled)
|
|
}
|
|
if err := waitForPlayerRuntimeResult(t, sessionResult); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("session Run() error = %v, want %v", err, context.Canceled)
|
|
}
|
|
}
|
|
|
|
func TestShouldAutoStartPlaylist(t *testing.T) {
|
|
playlist := playback.Playlist{Entries: []playback.PlaylistEntry{
|
|
{Video: playback.PlaylistFeed{Domain: "domain", UUID: "video"}},
|
|
}}
|
|
tests := []struct {
|
|
name string
|
|
args appArgs
|
|
playlist playback.Playlist
|
|
want bool
|
|
}{
|
|
{
|
|
name: "playlist only",
|
|
args: appArgs{PlaylistPath: "playlist.json"},
|
|
playlist: playlist,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "direct video",
|
|
args: appArgs{PlaylistPath: "playlist.json", VideoFlowId: "video"},
|
|
playlist: playlist,
|
|
},
|
|
{
|
|
name: "direct audio",
|
|
args: appArgs{PlaylistPath: "playlist.json", AudioFlowId: "audio"},
|
|
playlist: playlist,
|
|
},
|
|
{
|
|
name: "both direct feeds",
|
|
args: appArgs{
|
|
PlaylistPath: "playlist.json",
|
|
VideoFlowId: "video",
|
|
AudioFlowId: "audio",
|
|
},
|
|
playlist: playlist,
|
|
},
|
|
{
|
|
name: "empty playlist",
|
|
args: appArgs{PlaylistPath: "playlist.json"},
|
|
playlist: playback.Playlist{},
|
|
},
|
|
{
|
|
name: "no playlist flag",
|
|
args: appArgs{},
|
|
playlist: playlist,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := shouldAutoStartPlaylist(test.args, test.playlist); got != test.want {
|
|
t.Fatalf("shouldAutoStartPlaylist() = %v, want %v", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlaylistEntryDisplayName(t *testing.T) {
|
|
tests := []struct {
|
|
entry playback.PlaylistEntry
|
|
index int
|
|
want string
|
|
}{
|
|
{entry: playback.PlaylistEntry{Name: "News"}, index: 0, want: "News"},
|
|
{entry: playback.PlaylistEntry{}, index: 0, want: "Entry 1"},
|
|
{entry: playback.PlaylistEntry{}, index: 4, want: "Entry 5"},
|
|
}
|
|
for _, test := range tests {
|
|
if got := playlistEntryDisplayName(test.entry, test.index); got != test.want {
|
|
t.Fatalf("playlistEntryDisplayName() = %q, want %q", got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlaylistTimingProgress(t *testing.T) {
|
|
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
|
|
tests := []struct {
|
|
name string
|
|
timing playback.PlaylistTimingState
|
|
wantFraction float32
|
|
wantRemaining time.Duration
|
|
}{
|
|
{name: "manual"},
|
|
{
|
|
name: "waiting",
|
|
timing: playback.PlaylistTimingState{Duration: 10 * time.Second},
|
|
wantRemaining: 10 * time.Second,
|
|
},
|
|
{
|
|
name: "half complete",
|
|
timing: playback.PlaylistTimingState{
|
|
Duration: 10 * time.Second,
|
|
Started: true,
|
|
Deadline: now.Add(5 * time.Second),
|
|
},
|
|
wantFraction: 0.5,
|
|
wantRemaining: 5 * time.Second,
|
|
},
|
|
{
|
|
name: "expired",
|
|
timing: playback.PlaylistTimingState{Duration: 10 * time.Second, Expired: true},
|
|
wantFraction: 1,
|
|
},
|
|
{
|
|
name: "paused",
|
|
timing: playback.PlaylistTimingState{
|
|
Duration: 10 * time.Second,
|
|
Paused: true,
|
|
Remaining: 6 * time.Second,
|
|
},
|
|
wantFraction: 0.4,
|
|
wantRemaining: 6 * time.Second,
|
|
},
|
|
{
|
|
name: "deadline passed",
|
|
timing: playback.PlaylistTimingState{
|
|
Duration: 10 * time.Second,
|
|
Started: true,
|
|
Deadline: now.Add(-time.Second),
|
|
},
|
|
wantFraction: 1,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
fraction, remaining := playlistTimingProgress(test.timing, now)
|
|
if math.Abs(float64(fraction-test.wantFraction)) > 0.000001 ||
|
|
remaining != test.wantRemaining {
|
|
t.Fatalf(
|
|
"playlistTimingProgress() = %v, %v; want %v, %v",
|
|
fraction,
|
|
remaining,
|
|
test.wantFraction,
|
|
test.wantRemaining,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newPlaylistTestPlayer(t *testing.T) *playerPlayback {
|
|
t.Helper()
|
|
return &playerPlayback{
|
|
Controller: newPlaylistTestSessionController(t),
|
|
Commands: make(chan playback.SessionCommand, 32),
|
|
Status: playback.NewStatusStore(),
|
|
}
|
|
}
|
|
|
|
func newPlaylistTestSessionController(t *testing.T) *playback.SessionController {
|
|
t.Helper()
|
|
controller, err := playback.NewSessionController(
|
|
playlistTestVideoSlot{},
|
|
playlistTestAudioSlot{},
|
|
playlistTestSyncSlot{},
|
|
func(video, audio playback.FeedConfig) bool { return video.Domain == audio.Domain },
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewSessionController() error = %v", err)
|
|
}
|
|
return controller
|
|
}
|
|
|
|
func playlistRuntimeRetry() playback.RetryPolicy {
|
|
return playback.RetryPolicy{
|
|
MaxAttempts: 1,
|
|
InitialDelay: time.Millisecond,
|
|
MaxDelay: time.Millisecond,
|
|
}
|
|
}
|
|
|
|
func waitForPlayerSession(
|
|
t *testing.T,
|
|
controller *playback.SessionController,
|
|
predicate func(playback.SessionSnapshot) bool,
|
|
) playback.SessionSnapshot {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if snapshot, ok := controller.Snapshot(); ok && predicate(snapshot) {
|
|
return snapshot
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
snapshot, _ := controller.Snapshot()
|
|
t.Fatalf("timed out waiting for session snapshot; latest = %#v", snapshot)
|
|
return playback.SessionSnapshot{}
|
|
}
|
|
|
|
func waitForPlayerRuntimeResult(t *testing.T, result <-chan error) error {
|
|
t.Helper()
|
|
select {
|
|
case err := <-result:
|
|
return err
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for runtime result")
|
|
return nil
|
|
}
|
|
}
|