Files
go-mxl-player/internal/playback/playlist_controller_test.go
2026-09-01 20:16:07 +03:00

369 lines
11 KiB
Go

package playback
import (
"context"
"errors"
"sync"
"testing"
"time"
)
func TestNewPlaylistControllerValidatesConfiguration(t *testing.T) {
validPlaylist := navigationPlaylist(false)
validRetry := validPlaylistRetryPolicy()
validSessions := make(chan SessionCommand)
tests := []struct {
name string
playlist Playlist
retry RetryPolicy
sessions chan<- SessionCommand
wantErr error
}{
{
name: "invalid playlist",
playlist: Playlist{Entries: []PlaylistEntry{
{},
}},
retry: validRetry,
sessions: validSessions,
wantErr: ErrPlaylistEntryEmpty,
},
{
name: "invalid retry",
playlist: validPlaylist,
retry: RetryPolicy{},
sessions: validSessions,
wantErr: ErrInvalidRetryDelay,
},
{
name: "nil session commands",
playlist: validPlaylist,
retry: validRetry,
wantErr: ErrNilSessionCommandChannel,
},
{
name: "valid",
playlist: validPlaylist,
retry: validRetry,
sessions: validSessions,
},
{
name: "empty playlist is valid",
playlist: Playlist{},
retry: validRetry,
sessions: validSessions,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
controller, err := NewPlaylistController(test.playlist, test.retry, test.sessions)
if !errors.Is(err, test.wantErr) {
t.Fatalf("NewPlaylistController() error = %v, want %v", err, test.wantErr)
}
if test.wantErr != nil && controller != nil {
t.Fatalf("NewPlaylistController() controller = %#v, want nil", controller)
}
if test.wantErr == nil && controller == nil {
t.Fatal("NewPlaylistController() controller is nil")
}
})
}
}
func TestPlaylistControllerPublishesInitialSnapshot(t *testing.T) {
controller, commands, _, cancel, result := startPlaylistController(t, navigationPlaylist(false), 0)
defer cancel()
snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return !snapshot.State.HasSelection
})
if snapshot.Entry != (PlaylistEntry{}) {
t.Fatalf("initial entry = %#v, want zero value", snapshot.Entry)
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerForwardsManualCommands(t *testing.T) {
controller, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(true), 4)
defer cancel()
tests := []struct {
command PlaylistCommand
wantIndex int
wantUUID string
}{
{command: PlaylistCommand{Kind: PlaylistSelect, Index: 1}, wantIndex: 1, wantUUID: "audio-2"},
{command: PlaylistCommand{Kind: PlaylistNext}, wantIndex: 2, wantUUID: "video-3"},
{command: PlaylistCommand{Kind: PlaylistPrevious}, wantIndex: 1, wantUUID: "audio-2"},
{command: PlaylistCommand{Kind: PlaylistSelect, Index: 1}, wantIndex: 1, wantUUID: "audio-2"},
}
for _, test := range tests {
commands <- test.command
session := receivePlaylistSession(t, sessions)
if session.Kind != CommandSetSession {
t.Fatalf("session kind = %v, want %v", session.Kind, CommandSetSession)
}
gotUUID := session.Session.Video.UUID
if gotUUID == "" {
gotUUID = session.Session.Audio.UUID
}
if gotUUID != test.wantUUID {
t.Fatalf("session UUID = %q, want %q", gotUUID, test.wantUUID)
}
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.State.HasSelection && snapshot.State.CurrentIndex == test.wantIndex
})
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerNonLoopingBoundarySendsNothing(t *testing.T) {
_, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(false), 2)
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 2}
_ = receivePlaylistSession(t, sessions)
commands <- PlaylistCommand{Kind: PlaylistNext}
select {
case command := <-sessions:
t.Fatalf("unexpected session command at boundary: %#v", command)
case <-time.After(20 * time.Millisecond):
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerIgnoresInvalidCommand(t *testing.T) {
_, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(false), 2)
defer cancel()
commands <- PlaylistCommand{}
commands <- PlaylistCommand{Kind: PlaylistNext}
if session := receivePlaylistSession(t, sessions); session.Session.Video.UUID != "video-1" {
t.Fatalf("session after invalid command = %#v", session)
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerCommitsStateAfterSessionDelivery(t *testing.T) {
sessions := make(chan SessionCommand)
controller, err := NewPlaylistController(navigationPlaylist(false), validPlaylistRetryPolicy(), sessions)
if err != nil {
t.Fatalf("NewPlaylistController() error = %v", err)
}
commands := make(chan PlaylistCommand, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands, nil) }()
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return !snapshot.State.HasSelection
})
commands <- PlaylistCommand{Kind: PlaylistNext}
time.Sleep(time.Millisecond)
snapshot, ok := controller.Snapshot()
if !ok || snapshot.State.HasSelection {
t.Fatalf("snapshot before delivery = %#v, %v; want no selection", snapshot, ok)
}
_ = receivePlaylistSession(t, sessions)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.State.HasSelection && snapshot.State.CurrentIndex == 0
})
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerClosedCommandsReturnsNil(t *testing.T) {
_, commands, _, cancel, result := startPlaylistController(t, navigationPlaylist(false), 0)
defer cancel()
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
}
func TestPlaylistControllerCancellationWhileReceiving(t *testing.T) {
_, _, _, cancel, result := startPlaylistController(t, navigationPlaylist(false), 0)
cancel()
if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
}
}
func TestPlaylistControllerCancellationWhileSending(t *testing.T) {
sessions := make(chan SessionCommand)
controller, err := NewPlaylistController(navigationPlaylist(false), validPlaylistRetryPolicy(), sessions)
if err != nil {
t.Fatalf("NewPlaylistController() error = %v", err)
}
commands := make(chan PlaylistCommand, 1)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands, nil) }()
commands <- PlaylistCommand{Kind: PlaylistNext}
cancel()
if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
}
}
func TestPlaylistControllerSnapshotConcurrentReads(t *testing.T) {
controller, commands, _, cancel, result := startPlaylistController(t, navigationPlaylist(true), 64)
defer cancel()
var readers sync.WaitGroup
for range 8 {
readers.Add(1)
go func() {
defer readers.Done()
for range 100 {
_, _ = controller.Snapshot()
}
}()
}
for range 32 {
commands <- PlaylistCommand{Kind: PlaylistNext}
}
readers.Wait()
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerSnapshotRevision(t *testing.T) {
controller, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(false), 8)
defer cancel()
initial := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return !snapshot.State.HasSelection
})
if initial.Revision != 0 {
t.Fatalf("initial revision = %d, want 0", initial.Revision)
}
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
_ = receivePlaylistSession(t, sessions)
selected := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 1
})
if selected.State.CurrentIndex != 1 {
t.Fatalf("selected state = %#v, want index 1", selected.State)
}
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
_ = receivePlaylistSession(t, sessions)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 2
})
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 2}
_ = receivePlaylistSession(t, sessions)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 3
})
commands <- PlaylistCommand{Kind: PlaylistNext}
time.Sleep(time.Millisecond)
boundary, ok := controller.Snapshot()
if !ok || boundary.Revision != 3 {
t.Fatalf("boundary snapshot = %#v, %v; want revision 3", boundary, ok)
}
commands <- PlaylistCommand{}
time.Sleep(time.Millisecond)
invalid, ok := controller.Snapshot()
if !ok || invalid.Revision != 3 {
t.Fatalf("invalid-command snapshot = %#v, %v; want revision 3", invalid, ok)
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func startPlaylistController(
t *testing.T,
playlist Playlist,
sessionBuffer int,
) (*PlaylistController, chan PlaylistCommand, chan SessionCommand, context.CancelFunc, <-chan error) {
t.Helper()
sessions := make(chan SessionCommand, sessionBuffer)
controller, err := NewPlaylistController(playlist, validPlaylistRetryPolicy(), sessions)
if err != nil {
t.Fatalf("NewPlaylistController() error = %v", err)
}
commands := make(chan PlaylistCommand, 64)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands, nil) }()
return controller, commands, sessions, cancel, result
}
func waitForPlaylistSnapshot(
t *testing.T,
controller *PlaylistController,
predicate func(PlaylistSnapshot) bool,
) PlaylistSnapshot {
t.Helper()
deadline := time.Now().Add(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 playlist snapshot; latest = %#v", snapshot)
return PlaylistSnapshot{}
}
func receivePlaylistSession(t *testing.T, sessions <-chan SessionCommand) SessionCommand {
t.Helper()
select {
case command := <-sessions:
return command
case <-time.After(time.Second):
t.Fatal("timed out waiting for session command")
return SessionCommand{}
}
}
func waitForPlaylistResult(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 playlist controller")
return nil
}
}