Files
go-mxl-player/cmd/mxl-player/playlist_runtime_test.go
T
2026-09-02 00:08:53 +03:00

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
}
}