package main import ( "context" "errors" "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.Readiness == 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}}, } 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: "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 fraction != test.wantFraction || 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 } }