From ce4ea3fa007db88fe54a72a05bd3e50540012880 Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Tue, 1 Sep 2026 19:53:01 +0300 Subject: [PATCH] playlist-to-session bridge --- internal/playback/playlist_navigation.go | 37 ++++ internal/playback/playlist_selection_test.go | 189 +++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 internal/playback/playlist_selection_test.go diff --git a/internal/playback/playlist_navigation.go b/internal/playback/playlist_navigation.go index 6586438..1edb78e 100644 --- a/internal/playback/playlist_navigation.go +++ b/internal/playback/playlist_navigation.go @@ -23,6 +23,7 @@ type PlaylistState struct { var ( ErrPlaylistEmpty = errors.New("playlist is empty") ErrPlaylistIndexOutOfRange = errors.New("playlist index is out of range") + ErrPlaylistNoSelection = errors.New("playlist has no selected entry") ErrUnknownPlaylistCommand = errors.New("unknown playlist command") ) @@ -91,3 +92,39 @@ func (s PlaylistState) Entry(playlist Playlist) (PlaylistEntry, bool) { } return playlist.Entries[s.CurrentIndex], true } + +func ApplyPlaylistSelection( + playlist Playlist, + current PlaylistState, + command PlaylistCommand, + retry RetryPolicy, +) ( + next PlaylistState, + sessionCommand SessionCommand, + apply bool, + err error, +) { + next, err = ApplyPlaylistCommand(playlist, current, command) + if err != nil { + return current, SessionCommand{}, false, err + } + + apply = command.Kind == PlaylistSelect || next != current + if !apply { + return next, SessionCommand{}, false, nil + } + + entry, ok := next.Entry(playlist) + if !ok { + return current, SessionCommand{}, false, ErrPlaylistNoSelection + } + session := entry.SessionConfig(retry) + if err := session.Validate(); err != nil { + return current, SessionCommand{}, false, err + } + + return next, SessionCommand{ + Kind: CommandSetSession, + Session: session, + }, true, nil +} diff --git a/internal/playback/playlist_selection_test.go b/internal/playback/playlist_selection_test.go new file mode 100644 index 0000000..635e0bc --- /dev/null +++ b/internal/playback/playlist_selection_test.go @@ -0,0 +1,189 @@ +package playback + +import ( + "errors" + "testing" + "time" +) + +func TestApplyPlaylistSelectionFirstNext(t *testing.T) { + retry := validPlaylistRetryPolicy() + + next, command, apply, err := ApplyPlaylistSelection( + navigationPlaylist(false), + PlaylistState{}, + PlaylistCommand{Kind: PlaylistNext}, + retry, + ) + if err != nil { + t.Fatalf("ApplyPlaylistSelection() error = %v", err) + } + if !apply { + t.Fatal("ApplyPlaylistSelection() apply = false, want true") + } + wantState := PlaylistState{CurrentIndex: 0, HasSelection: true} + if next != wantState { + t.Fatalf("ApplyPlaylistSelection() state = %#v, want %#v", next, wantState) + } + wantCommand := SessionCommand{ + Kind: CommandSetSession, + Session: SessionConfig{ + Video: FeedConfig{Domain: "domain", UUID: "video-1", Active: true}, + Retry: retry, + }, + } + if command != wantCommand { + t.Fatalf("ApplyPlaylistSelection() command = %#v, want %#v", command, wantCommand) + } +} + +func TestApplyPlaylistSelectionUsesCompleteEntrySession(t *testing.T) { + retry := validPlaylistRetryPolicy() + playlist := Playlist{Entries: []PlaylistEntry{ + { + Video: PlaylistFeed{Domain: "video-domain", UUID: "video"}, + Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"}, + SyncRequested: true, + }, + {Video: PlaylistFeed{Domain: "next-domain", UUID: "next-video"}}, + }} + + _, command, apply, err := ApplyPlaylistSelection( + playlist, + PlaylistState{CurrentIndex: 1, HasSelection: true}, + PlaylistCommand{Kind: PlaylistSelect, Index: 0}, + retry, + ) + if err != nil { + t.Fatalf("ApplyPlaylistSelection() error = %v", err) + } + if !apply { + t.Fatal("ApplyPlaylistSelection() apply = false, want true") + } + want := SessionConfig{ + Video: FeedConfig{Domain: "video-domain", UUID: "video", Active: true}, + Audio: FeedConfig{Domain: "audio-domain", UUID: "audio", Active: true}, + SyncRequested: true, + Retry: retry, + } + if command.Kind != CommandSetSession || command.Session != want { + t.Fatalf("ApplyPlaylistSelection() command = %#v, want session %#v", command, want) + } +} + +func TestApplyPlaylistSelectionVideoOnlyClearsAudio(t *testing.T) { + _, command, apply, err := ApplyPlaylistSelection( + navigationPlaylist(false), + PlaylistState{CurrentIndex: 1, HasSelection: true}, + PlaylistCommand{Kind: PlaylistSelect, Index: 0}, + validPlaylistRetryPolicy(), + ) + if err != nil { + t.Fatalf("ApplyPlaylistSelection() error = %v", err) + } + if !apply { + t.Fatal("ApplyPlaylistSelection() apply = false, want true") + } + if command.Session.Audio != (FeedConfig{}) { + t.Fatalf("ApplyPlaylistSelection() audio = %#v, want zero value", command.Session.Audio) + } +} + +func TestApplyPlaylistSelectionApplicationDecision(t *testing.T) { + tests := []struct { + name string + loop bool + current int + command PlaylistCommand + want int + apply bool + }{ + {name: "reselect current", current: 1, command: PlaylistCommand{Kind: PlaylistSelect, Index: 1}, want: 1, apply: true}, + {name: "move next", current: 1, command: PlaylistCommand{Kind: PlaylistNext}, want: 2, apply: true}, + {name: "next stops at end", current: 2, command: PlaylistCommand{Kind: PlaylistNext}, want: 2}, + {name: "previous stops at beginning", current: 0, command: PlaylistCommand{Kind: PlaylistPrevious}, want: 0}, + {name: "next wraps", loop: true, current: 2, command: PlaylistCommand{Kind: PlaylistNext}, want: 0, apply: true}, + {name: "previous wraps", loop: true, current: 0, command: PlaylistCommand{Kind: PlaylistPrevious}, want: 2, apply: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + next, command, apply, err := ApplyPlaylistSelection( + navigationPlaylist(test.loop), + PlaylistState{CurrentIndex: test.current, HasSelection: true}, + test.command, + validPlaylistRetryPolicy(), + ) + if err != nil { + t.Fatalf("ApplyPlaylistSelection() error = %v", err) + } + wantState := PlaylistState{CurrentIndex: test.want, HasSelection: true} + if next != wantState { + t.Fatalf("ApplyPlaylistSelection() state = %#v, want %#v", next, wantState) + } + if apply != test.apply { + t.Fatalf("ApplyPlaylistSelection() apply = %v, want %v", apply, test.apply) + } + if apply && command.Kind != CommandSetSession { + t.Fatalf("ApplyPlaylistSelection() command kind = %v, want %v", command.Kind, CommandSetSession) + } + if !apply && command != (SessionCommand{}) { + t.Fatalf("ApplyPlaylistSelection() command = %#v, want zero value", command) + } + }) + } +} + +func TestApplyPlaylistSelectionErrorsDoNotApply(t *testing.T) { + current := PlaylistState{CurrentIndex: 1, HasSelection: true} + tests := []struct { + name string + command PlaylistCommand + retry RetryPolicy + wantErr error + }{ + { + name: "invalid command", + command: PlaylistCommand{}, + retry: validPlaylistRetryPolicy(), + wantErr: ErrUnknownPlaylistCommand, + }, + { + name: "invalid retry", + command: PlaylistCommand{Kind: PlaylistNext}, + retry: RetryPolicy{}, + wantErr: ErrInvalidRetryDelay, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + next, command, apply, err := ApplyPlaylistSelection( + navigationPlaylist(false), + current, + test.command, + test.retry, + ) + if !errors.Is(err, test.wantErr) { + t.Fatalf("ApplyPlaylistSelection() error = %v, want %v", err, test.wantErr) + } + if next != current { + t.Fatalf("ApplyPlaylistSelection() state = %#v, want unchanged %#v", next, current) + } + if apply { + t.Fatal("ApplyPlaylistSelection() apply = true, want false") + } + if command != (SessionCommand{}) { + t.Fatalf("ApplyPlaylistSelection() command = %#v, want zero value", command) + } + }) + } +} + +func validPlaylistRetryPolicy() RetryPolicy { + return RetryPolicy{ + MaxAttempts: 3, + InitialDelay: time.Millisecond, + MaxDelay: time.Second, + } +}