Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

408 lines
13 KiB
Go

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