package model import ( "strings" "testing" "time" tea "charm.land/bubbletea/v2" "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/ui" ) type playbackFakeEngine struct { playing bool gaplessAdvanced bool paused bool ytdlSeek bool position time.Duration playCalls []string seekYTDLCalls []time.Duration preloadCalls []string clearPreloadCalls int } func (f *playbackFakeEngine) Play(path string, _ time.Duration) error { f.playing = true f.paused = false f.playCalls = append(f.playCalls, path) return nil } func (f *playbackFakeEngine) PlayYTDL(string, time.Duration) error { return nil } func (f *playbackFakeEngine) Preload(path string, _ time.Duration) error { f.preloadCalls = append(f.preloadCalls, path) return nil } func (f *playbackFakeEngine) PreloadYTDL(string, time.Duration) error { return nil } func (f *playbackFakeEngine) ClearPreload() { f.clearPreloadCalls++ } func (f *playbackFakeEngine) Stop() { f.playing, f.paused = false, false } func (f *playbackFakeEngine) Close() {} func (f *playbackFakeEngine) TogglePause() { f.paused = !f.paused } func (f *playbackFakeEngine) Seek(time.Duration) error { return nil } func (f *playbackFakeEngine) SeekYTDL(d time.Duration) error { f.seekYTDLCalls = append(f.seekYTDLCalls, d) return nil } func (f *playbackFakeEngine) CancelSeekYTDL() {} func (f *playbackFakeEngine) IsPlaying() bool { return f.playing } func (f *playbackFakeEngine) IsPaused() bool { return f.paused } func (f *playbackFakeEngine) Drained() bool { return false } func (f *playbackFakeEngine) HasPreload() bool { return false } func (f *playbackFakeEngine) Seekable() bool { return false } func (f *playbackFakeEngine) IsStreamSeek() bool { return false } func (f *playbackFakeEngine) IsYTDLSeek() bool { return f.ytdlSeek } func (f *playbackFakeEngine) GaplessAdvanced() bool { if !f.gaplessAdvanced { return false } f.gaplessAdvanced = false return true } func (f *playbackFakeEngine) Position() time.Duration { return f.position } func (f *playbackFakeEngine) Duration() time.Duration { return 0 } func (f *playbackFakeEngine) PositionAndDuration() (time.Duration, time.Duration) { return 0, 0 } func (f *playbackFakeEngine) SetVolumeMin(float64) {} func (f *playbackFakeEngine) VolumeMin() float64 { return -50 } func (f *playbackFakeEngine) SetVolume(float64) {} func (f *playbackFakeEngine) Volume() float64 { return 0 } func (f *playbackFakeEngine) SetSpeed(float64) {} func (f *playbackFakeEngine) Speed() float64 { return 1 } func (f *playbackFakeEngine) ToggleMono() {} func (f *playbackFakeEngine) Mono() bool { return false } func (f *playbackFakeEngine) SetEQBand(int, float64) {} func (f *playbackFakeEngine) EQBands() [10]float64 { return [10]float64{} } func (f *playbackFakeEngine) StreamErr() error { return nil } func (f *playbackFakeEngine) StreamTitle() string { return "" } func (f *playbackFakeEngine) StreamBytes() (downloaded, total int64) { return 0, 0 } func (f *playbackFakeEngine) SamplesInto([]float64) int { return 0 } func (f *playbackFakeEngine) SampleRate() int { return 44100 } func TestNavTrackListQueueStartsQueuedTrackWhenStopped(t *testing.T) { player := &playbackFakeEngine{} p := playlist.New() p.Replace([]playlist.Track{ {Title: "Existing", Path: "https://example.com/existing", Stream: true}, {Title: "Other", Path: "https://example.com/other", Stream: true}, }) p.SetIndex(0) m := Model{ player: player, playlist: p, vis: ui.NewVisualizer(float64(player.SampleRate())), navBrowser: navBrowserState{ tracks: []playlist.Track{ {Title: "Queued", Path: "https://example.com/queued", Stream: true}, }, }, } cmd := m.handleNavTrackListKey(tea.KeyPressMsg{Text: "q"}) if cmd == nil { t.Fatal("handleNavTrackListKey(q) = nil, want command") } if current, idx := m.playlist.Current(); current.Title != "Queued" || idx != 2 { t.Fatalf("current = (%q,%d), want (\"Queued\",2)", current.Title, idx) } if m.plCursor != 2 { t.Fatalf("plCursor = %d, want 2", m.plCursor) } if p.QueueLen() != 0 { t.Fatalf("QueueLen() = %d, want 0 after starting queued track", p.QueueLen()) } } func TestTogglePlayPauseRestartsQueuedCurrentTrack(t *testing.T) { player := &playbackFakeEngine{} p := playlist.New() p.Replace([]playlist.Track{ {Title: "Base", Path: "base.mp3", DurationSecs: 180}, {Title: "Queued", Path: "queued.mp3", DurationSecs: 180}, }) p.SetIndex(0) p.Queue(1) if track, ok := p.Next(); !ok || track.Title != "Queued" { t.Fatalf("Next() = (%q,%t), want (\"Queued\",true)", track.Title, ok) } if !p.CurrentIsQueued() { t.Fatal("CurrentIsQueued() = false, want true") } m := Model{ player: player, playlist: p, vis: ui.NewVisualizer(float64(player.SampleRate())), } if cmd := m.togglePlayPause(); cmd != nil { _ = cmd() } if len(player.playCalls) != 1 || player.playCalls[0] != "queued.mp3" { t.Fatalf("playCalls = %v, want [queued.mp3]", player.playCalls) } if current, idx := m.playlist.Current(); current.Title != "Queued" || idx != 1 { t.Fatalf("current = (%q,%d), want (\"Queued\",1)", current.Title, idx) } } func TestTogglePlayPauseReconnectsLongPausedYTDLAtCurrentPosition(t *testing.T) { player := &playbackFakeEngine{ playing: true, paused: true, ytdlSeek: true, position: 90 * time.Second, } p := playlist.New() p.Replace([]playlist.Track{{ Title: "YouTube Music", Path: "https://music.youtube.com/watch?v=dQw4w9WgXcQ", Stream: true, DurationSecs: 180, }}) p.SetIndex(0) m := Model{ player: player, playlist: p, vis: ui.NewVisualizer(float64(player.SampleRate())), pausedAt: time.Now().Add(-ytdlReconnectPauseThreshold), } cmd := m.togglePlayPause() if cmd == nil { t.Fatal("togglePlayPause() = nil, want reconnect command") } msg := cmd() if reconnect, ok := msg.(ytdlUnpauseReconnectMsg); !ok || reconnect.err != nil { t.Fatalf("cmd() = %#v, want successful ytdlUnpauseReconnectMsg", msg) } if len(player.seekYTDLCalls) != 1 || player.seekYTDLCalls[0] != 0 { t.Fatalf("seekYTDLCalls = %v, want [0]", player.seekYTDLCalls) } if player.paused { t.Fatal("player stayed paused after reconnect command") } if !m.seek.active || m.seek.targetPos != 90*time.Second { t.Fatalf("seek state = active:%v target:%s, want active target 1m30s", m.seek.active, m.seek.targetPos) } } func TestPlayCurrentTrackUnplayableUsesSelectionOrder(t *testing.T) { player := &playbackFakeEngine{} p := playlist.New() p.Replace([]playlist.Track{ {Title: "Queued", Path: "https://example.com/queued", Stream: true}, {Title: "Missing", Unplayable: true}, {Title: "Replacement", Path: "https://example.com/replacement", Stream: true}, }) p.SetIndex(1) p.Queue(0) m := Model{ player: player, playlist: p, vis: ui.NewVisualizer(float64(player.SampleRate())), } cmd := m.playCurrentTrack() if cmd == nil { t.Fatal("playCurrentTrack() = nil, want command") } if idx := m.playlist.Index(); idx != 2 { t.Fatalf("playlist.Index() = %d, want 2", idx) } if m.plCursor != 2 { t.Fatalf("plCursor = %d, want 2", m.plCursor) } if m.status.text != "Track unavailable, skipping..." { t.Fatalf("status.text = %q, want %q", m.status.text, "Track unavailable, skipping...") } if p.QueueLen() != 1 { t.Fatalf("QueueLen() = %d, want 1", p.QueueLen()) } } func TestPlayCurrentTrackUnplayableStopsWhenNoReplacementExists(t *testing.T) { player := &playbackFakeEngine{playing: true} p := playlist.New() p.Replace([]playlist.Track{ {Title: "Playing", Path: "playing.mp3", DurationSecs: 2}, {Title: "Missing", Unplayable: true}, }) p.SetIndex(1) m := Model{ player: player, playlist: p, vis: ui.NewVisualizer(float64(player.SampleRate())), } if cmd := m.playCurrentTrack(); cmd != nil { t.Fatalf("playCurrentTrack() = %v, want nil", cmd) } if len(player.playCalls) != 0 { t.Fatalf("playCalls = %v, want none", player.playCalls) } if player.IsPlaying() { t.Fatal("player.IsPlaying() = true, want false") } if _, idx := m.playlist.Current(); idx != 1 { t.Fatalf("current index = %d, want 1", idx) } if m.status.text != "No available tracks" { t.Fatalf("status.text = %q, want %q", m.status.text, "No available tracks") } } func modelAfterProviderPlaylistLoadWhilePlaying(t *testing.T) (Model, *playbackFakeEngine) { t.Helper() player := &playbackFakeEngine{playing: true} p := playlist.New() p.Replace([]playlist.Track{ {Title: "Old", Path: "old.mp3", DurationSecs: 180}, }) p.SetIndex(0) m := Model{ player: player, playlist: p, vis: ui.NewVisualizer(float64(player.SampleRate())), } updated, _ := m.Update(tracksLoadedMsg{ tracks: []playlist.Track{ {Title: "New 1", Path: "new1.mp3", DurationSecs: 180}, {Title: "New 2", Path: "new2.mp3", DurationSecs: 180}, }, }) m = updated.(Model) return m, player } func TestProviderPlaylistLoadWhilePlayingKeepsNowPlayingTrack(t *testing.T) { m, player := modelAfterProviderPlaylistLoadWhilePlaying(t) track, idx := m.currentPlaybackTrack() if idx < 0 || track.Title != "Old" { t.Fatalf("currentPlaybackTrack() = (%q,%d), want old playing track", track.Title, idx) } if !m.playbackDetached { t.Fatal("playbackDetached = false, want true") } if player.clearPreloadCalls != 1 { t.Fatalf("ClearPreload calls = %d, want 1", player.clearPreloadCalls) } tracks := m.playlist.Tracks() if len(tracks) != 2 || tracks[0].Title != "New 1" || tracks[1].Title != "New 2" { t.Fatalf("playlist tracks = %#v, want new provider playlist only", tracks) } if info := m.renderTrackInfo(); !strings.Contains(info, "Old") { t.Fatalf("renderTrackInfo() = %q, want old playing track", info) } } func TestNextAfterProviderPlaylistLoadStartsFirstNewTrack(t *testing.T) { m, player := modelAfterProviderPlaylistLoadWhilePlaying(t) cmd := m.nextTrack() if cmd != nil { _ = cmd() } if len(player.playCalls) == 0 || player.playCalls[0] != "new1.mp3" { t.Fatalf("playCalls = %v, want first new track", player.playCalls) } if m.playbackDetached { t.Fatal("playbackDetached = true, want false") } track, _ := m.currentPlaybackTrack() if track.Title != "New 1" { t.Fatalf("currentPlaybackTrack() = %q, want New 1", track.Title) } } func TestPreloadAfterProviderPlaylistLoadUsesFirstNewTrack(t *testing.T) { m, player := modelAfterProviderPlaylistLoadWhilePlaying(t) cmd := m.preloadNext() if cmd == nil { t.Fatal("preloadNext() = nil, want preload command") } _ = cmd() if len(player.preloadCalls) != 1 || player.preloadCalls[0] != "new1.mp3" { t.Fatalf("preloadCalls = %v, want first new track", player.preloadCalls) } } func TestBeginPlaybackTrackFetchesEmbeddedLyricsWithoutNetworkMetadata(t *testing.T) { m := Model{lyrics: lyricsState{visible: true}} track := playlist.Track{Title: "Local", EmbeddedLyrics: "Line one\nLine two"} _, cmd := m.beginPlaybackTrack(track) if cmd == nil { t.Fatal("beginPlaybackTrack() command = nil, want embedded lyrics command") } if !m.lyrics.loading { t.Fatal("lyrics.loading = false, want true") } msg, ok := cmd().(lyricsLoadedMsg) if !ok { t.Fatalf("lyrics command returned %T, want lyricsLoadedMsg", msg) } if msg.err != nil { t.Fatalf("lyrics command error = %v", msg.err) } if len(msg.lines) != 2 || msg.lines[0].Text != "Line one" || msg.lines[1].Text != "Line two" { t.Fatalf("lyrics lines = %+v, want embedded plain text", msg.lines) } } func TestGaplessAdvanceRefreshesLyricsAndArtwork(t *testing.T) { player := &playbackFakeEngine{playing: true, gaplessAdvanced: true} p := playlist.New() p.Replace([]playlist.Track{ {Title: "Old", Artist: "Artist", Path: "old.mp3", DurationSecs: 180, EmbeddedLyrics: "old lyric", AlbumArtURL: "file:///old.jpg"}, {Title: "New", Artist: "Artist", Path: "new.mp3", DurationSecs: 180, EmbeddedLyrics: "[00:01.00]new lyric", AlbumArtURL: "file:///new.jpg"}, }) p.SetIndex(0) m := Model{ player: player, playlist: p, vis: ui.NewVisualizer(float64(player.SampleRate())), lyrics: lyricsState{ visible: true, query: "Artist\nOld", }, } m.setPlaybackTrack(p.Tracks()[0]) m.lyrics.lines = nil next, cmd := m.Update(tickMsg(time.Now())) m2 := next.(Model) if cmd == nil { t.Fatal("Update() command = nil, want lyric/preload/tick batch") } track, _ := m2.currentPlaybackTrack() if track.Title != "New" { t.Fatalf("current track = %q, want New", track.Title) } if track.AlbumArtURL != "file:///new.jpg" { t.Fatalf("AlbumArtURL = %q, want new artwork", track.AlbumArtURL) } if m2.lyrics.query != "Artist\nNew" { t.Fatalf("lyrics.query = %q, want new track query", m2.lyrics.query) } if !m2.lyrics.loading { t.Fatal("lyrics.loading = false, want true for new track fetch") } }