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

693 lines
16 KiB
Go

package playlist
import (
"testing"
)
// helper builds a playlist with n tracks named "A", "B", "C", ...
func makePlaylist(n int, shuffle bool) *Playlist {
tracks := make([]Track, n)
for i := range tracks {
tracks[i] = Track{Title: string(rune('A' + i))}
}
p := New()
if shuffle {
p.shuffle = true
}
p.Replace(tracks)
return p
}
func titles(p *Playlist) []string {
out := make([]string, p.Len())
for i, t := range p.Tracks() {
out[i] = t.Title
}
return out
}
func sliceEq(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestMoveDown(t *testing.T) {
p := makePlaylist(5, false) // A B C D E
p.SetIndex(0) // playing A
if !p.Move(1, 2) {
t.Fatal("Move returned false")
}
// Visual order: A C B D E
got := titles(p)
want := []string{"A", "C", "B", "D", "E"}
if !sliceEq(got, want) {
t.Errorf("tracks = %v, want %v", got, want)
}
// Still playing A
if _, idx := p.Current(); idx != 0 {
t.Errorf("current index = %d, want 0", idx)
}
}
func TestMoveUp(t *testing.T) {
p := makePlaylist(5, false) // A B C D E
p.SetIndex(3) // playing D
if !p.Move(3, 2) {
t.Fatal("Move returned false")
}
// Visual order: A B D C E
got := titles(p)
want := []string{"A", "B", "D", "C", "E"}
if !sliceEq(got, want) {
t.Errorf("tracks = %v, want %v", got, want)
}
// Still playing D, now at index 2
if _, idx := p.Current(); idx != 2 {
t.Errorf("current index = %d, want 2", idx)
}
}
func TestMoveCurrentTrack(t *testing.T) {
p := makePlaylist(4, false) // A B C D
p.SetIndex(1) // playing B
// Move B down (from 1 to 2)
if !p.Move(1, 2) {
t.Fatal("Move returned false")
}
// Visual: A C B D
got := titles(p)
want := []string{"A", "C", "B", "D"}
if !sliceEq(got, want) {
t.Errorf("tracks = %v, want %v", got, want)
}
// Still playing B, now at index 2
if _, idx := p.Current(); idx != 2 {
t.Errorf("current index = %d, want 2", idx)
}
}
func TestMoveBoundary(t *testing.T) {
p := makePlaylist(3, false)
// Can't move first track up
if p.Move(0, -1) {
t.Error("Move(0, -1) should return false")
}
// Can't move last track down
if p.Move(2, 3) {
t.Error("Move(2, 3) should return false")
}
// Same position is a no-op
if p.Move(1, 1) {
t.Error("Move(1, 1) should return false")
}
}
func TestMovePreservesPlaybackOrder_NoShuffle(t *testing.T) {
p := makePlaylist(5, false) // A B C D E
p.SetIndex(0)
// Move C (2) up to (1): A C B D E
p.Move(2, 1)
// Playback should follow new visual order: A C B D E
var playback []string
track, _ := p.Current()
playback = append(playback, track.Title) // A
for i := range 4 {
track, ok := p.Next()
if !ok {
t.Fatalf("Next() returned false at step %d", i)
}
playback = append(playback, track.Title)
}
want := []string{"A", "C", "B", "D", "E"}
if !sliceEq(playback, want) {
t.Errorf("playback = %v, want %v", playback, want)
}
}
func TestMoveWithQueue(t *testing.T) {
p := makePlaylist(4, false) // A B C D
p.Queue(2) // queue C (index 2)
// Move C (2) up to (1): A C B D, queue should now reference index 1
p.Move(2, 1)
if pos := p.QueuePosition(1); pos != 1 {
t.Errorf("QueuePosition(1) = %d, want 1", pos)
}
if pos := p.QueuePosition(2); pos != 0 {
t.Errorf("QueuePosition(2) = %d, want 0 (not queued)", pos)
}
}
func TestMoveShuffle(t *testing.T) {
p := makePlaylist(5, true) // shuffled
// Record the current shuffle playback order
p.SetIndex(p.order[0])
// Snapshot the order
orderBefore := make([]int, len(p.order))
copy(orderBefore, p.order)
// Move tracks[1] to tracks[0]
t0 := p.tracks[0].Title
t1 := p.tracks[1].Title
p.Move(1, 0)
// Visual order should be swapped at 0,1
if p.tracks[0].Title != t1 || p.tracks[1].Title != t0 {
t.Errorf("tracks[0]=%s tracks[1]=%s, want %s %s",
p.tracks[0].Title, p.tracks[1].Title, t1, t0)
}
// The shuffle order should still reference the same tracks
for i, idx := range p.order {
got := p.tracks[idx].Title
want := ""
oldIdx := orderBefore[i]
// The old index pointed to a track; after swap, find where it went
if oldIdx == 0 {
want = t0
} else if oldIdx == 1 {
want = t1
} else {
want = string(rune('A' + oldIdx))
}
if got != want {
t.Errorf("order[%d]: track=%s, want=%s", i, got, want)
}
}
}
func TestAddShufflesNewTracksWhenShuffleEnabled(t *testing.T) {
p := makePlaylist(10, true)
p.SetIndex(p.order[0]) // ensure pos is valid and stable
cur, curIdx := p.Current()
start := p.Len()
var added []Track
for i := range 30 {
added = append(added, Track{Title: string(rune('K' + i))})
}
p.Add(added...)
// Current track should be unchanged.
cur2, curIdx2 := p.Current()
if cur2.Title != cur.Title || curIdx2 != curIdx {
t.Fatalf("current = (%q,%d), want (%q,%d)", cur2.Title, curIdx2, cur.Title, curIdx)
}
// Verify that added tracks are interleaved with existing upcoming tracks,
// not just shuffled among themselves at the tail.
upcoming := p.order[p.pos+1:]
isNew := func(idx int) bool { return idx >= start }
// Find the last new-track position and check that at least one
// old track appears after some new track in the upcoming order.
lastNew := -1
foundOldAfterNew := false
for i, idx := range upcoming {
if isNew(idx) {
lastNew = i
} else if lastNew >= 0 {
foundOldAfterNew = true
break
}
}
if lastNew < 0 {
t.Fatal("no added track found in upcoming order")
}
if !foundOldAfterNew && lastNew < len(upcoming)-1 {
t.Fatalf("added tracks are not interleaved with existing tracks in upcoming order: %v", upcoming)
}
}
func TestAddDoesNotShuffleWhenShuffleDisabled(t *testing.T) {
p := makePlaylist(5, false)
p.SetIndex(2)
cur, curIdx := p.Current()
p.Add(Track{Title: "F"}, Track{Title: "G"})
cur2, curIdx2 := p.Current()
if cur2.Title != cur.Title || curIdx2 != curIdx {
t.Fatalf("current = (%q,%d), want (%q,%d)", cur2.Title, curIdx2, cur.Title, curIdx)
}
wantOrder := []int{0, 1, 2, 3, 4, 5, 6}
if len(p.order) != len(wantOrder) {
t.Fatalf("order len = %d, want %d", len(p.order), len(wantOrder))
}
for i := range wantOrder {
if p.order[i] != wantOrder[i] {
t.Fatalf("order[%d] = %d, want %d (order=%v)", i, p.order[i], wantOrder[i], p.order)
}
}
}
func TestMoveQueue(t *testing.T) {
p := makePlaylist(5, false) // A B C D E
p.Queue(3) // D
p.Queue(1) // B
p.Queue(4) // E
// Queue order: D, B, E
// Move B (pos 1) up to pos 0
if !p.MoveQueue(1, 0) {
t.Fatal("MoveQueue returned false")
}
// Queue order should be: B, D, E
qt := p.QueueTracks()
if len(qt) != 3 {
t.Fatalf("queue len = %d, want 3", len(qt))
}
if qt[0].Title != "B" || qt[1].Title != "D" || qt[2].Title != "E" {
t.Errorf("queue = [%s %s %s], want [B D E]", qt[0].Title, qt[1].Title, qt[2].Title)
}
// Move B (pos 0) down to pos 1
if !p.MoveQueue(0, 1) {
t.Fatal("MoveQueue returned false")
}
// Queue order should be: D, B, E
qt = p.QueueTracks()
if qt[0].Title != "D" || qt[1].Title != "B" || qt[2].Title != "E" {
t.Errorf("queue = [%s %s %s], want [D B E]", qt[0].Title, qt[1].Title, qt[2].Title)
}
}
func TestRemoveShiftsHigherIndices(t *testing.T) {
p := makePlaylist(5, false) // A B C D E
p.SetIndex(3) // playing D
p.Queue(4) // queue E
if !p.Remove(1) { // remove B
t.Fatal("Remove returned false")
}
got := titles(p)
want := []string{"A", "C", "D", "E"}
if !sliceEq(got, want) {
t.Errorf("tracks = %v, want %v", got, want)
}
cur, idx := p.Current()
if cur.Title != "D" || idx != 2 {
t.Errorf("current = (%s, %d), want (D, 2)", cur.Title, idx)
}
qt := p.QueueTracks()
if len(qt) != 1 || qt[0].Title != "E" {
t.Errorf("queue = %v, want [E]", qt)
}
}
func TestRemoveCurrentTrack(t *testing.T) {
p := makePlaylist(4, false) // A B C D
p.SetIndex(1) // playing B
if !p.Remove(1) {
t.Fatal("Remove returned false")
}
got := titles(p)
want := []string{"A", "C", "D"}
if !sliceEq(got, want) {
t.Errorf("tracks = %v, want %v", got, want)
}
// Position stays at order slot 1 so Next/PeekNext sees the next track.
cur, idx := p.Current()
if cur.Title != "C" || idx != 1 {
t.Errorf("current = (%s, %d), want (C, 1)", cur.Title, idx)
}
}
func TestRemoveLastTrack(t *testing.T) {
p := makePlaylist(2, false) // A B
p.SetIndex(1) // playing B
if !p.Remove(1) {
t.Fatal("Remove returned false")
}
if p.Len() != 1 {
t.Fatalf("len = %d, want 1", p.Len())
}
cur, idx := p.Current()
if cur.Title != "A" || idx != 0 {
t.Errorf("current = (%s, %d), want (A, 0)", cur.Title, idx)
}
}
func TestRemoveOutOfBounds(t *testing.T) {
p := makePlaylist(3, false)
if p.Remove(-1) {
t.Error("Remove(-1) should return false")
}
if p.Remove(3) {
t.Error("Remove(3) should return false")
}
if p.Len() != 3 {
t.Errorf("len changed to %d", p.Len())
}
}
func TestMoveQueueBoundary(t *testing.T) {
p := makePlaylist(3, false)
p.Queue(0)
p.Queue(1)
// Can't move beyond bounds
if p.MoveQueue(0, -1) {
t.Error("MoveQueue(0, -1) should return false")
}
if p.MoveQueue(1, 2) {
t.Error("MoveQueue(1, 2) should return false")
}
if p.MoveQueue(0, 0) {
t.Error("MoveQueue(0, 0) should return false")
}
}
func TestNextPreservesCurrentOnUnplayableTail(t *testing.T) {
p := New()
p.Replace([]Track{
{Title: "A"},
{Title: "B", Unplayable: true},
{Title: "C", Unplayable: true},
})
p.SetIndex(0)
if _, ok := p.Next(); ok {
t.Fatal("Next() = true, want false")
}
track, idx := p.Current()
if track.Title != "A" || idx != 0 {
t.Fatalf("current = (%q,%d), want (\"A\",0)", track.Title, idx)
}
}
func TestPrevPreservesCurrentOnUnplayableHead(t *testing.T) {
p := New()
p.Replace([]Track{
{Title: "A", Unplayable: true},
{Title: "B", Unplayable: true},
{Title: "C"},
})
p.SetIndex(2)
if _, ok := p.Prev(); ok {
t.Fatal("Prev() = true, want false")
}
track, idx := p.Current()
if track.Title != "C" || idx != 2 {
t.Fatalf("current = (%q,%d), want (\"C\",2)", track.Title, idx)
}
}
func TestPeekNextMatchesNext(t *testing.T) {
p := New()
p.Replace([]Track{
{Title: "A"},
{Title: "B", Unplayable: true},
{Title: "C"},
})
p.SetIndex(0)
p.Queue(1)
p.Queue(2)
peek, ok := p.PeekNext()
if !ok {
t.Fatal("PeekNext() = false, want true")
}
if peek.Title != "C" {
t.Fatalf("peek = %q, want %q", peek.Title, "C")
}
cur, idx := p.Current()
if cur.Title != "A" || idx != 0 {
t.Fatalf("current after peek = (%q,%d), want (\"A\",0)", cur.Title, idx)
}
if p.QueueLen() != 2 {
t.Fatalf("QueueLen() after peek = %d, want 2", p.QueueLen())
}
next, ok := p.Next()
if !ok {
t.Fatal("Next() = false, want true")
}
if next.Title != peek.Title {
t.Fatalf("next = %q, want %q", next.Title, peek.Title)
}
}
func TestNextConsumesUnplayableQueuedItemsOnFailure(t *testing.T) {
p := New()
p.Replace([]Track{
{Title: "A"},
{Title: "B", Unplayable: true},
})
p.SetIndex(0)
p.Queue(1)
if _, ok := p.Next(); ok {
t.Fatal("Next() = true, want false")
}
cur, idx := p.Current()
if cur.Title != "A" || idx != 0 {
t.Fatalf("current = (%q,%d), want (\"A\",0)", cur.Title, idx)
}
if p.QueueLen() != 0 {
t.Fatalf("QueueLen() = %d, want 0", p.QueueLen())
}
}
func TestNextFailurePreservesQueuedCurrentTrack(t *testing.T) {
p := New()
p.Replace([]Track{
{Title: "A"},
{Title: "B"},
})
p.SetIndex(1)
p.Queue(0)
track, ok := p.Next()
if !ok || track.Title != "A" {
t.Fatalf("first Next() = (%q,%v), want (A,true)", track.Title, ok)
}
if _, ok := p.Next(); ok {
t.Fatal("second Next() = true, want false")
}
cur, idx := p.Current()
if cur.Title != "A" || idx != 0 {
t.Fatalf("current after failed Next() = (%q,%d), want (A,0)", cur.Title, idx)
}
}
func TestNextRepeatAllShuffleWrapSkipsCurrentTrack(t *testing.T) {
p := New()
p.shuffle = true
p.repeat = RepeatAll
p.Replace([]Track{
{Title: "A"},
{Title: "B"},
{Title: "C", Unplayable: true},
})
p.order = []int{1, 2, 0}
p.pos = 2
track, ok := p.Next()
if !ok {
t.Fatal("Next() = false, want true")
}
if track.Title != "B" {
t.Fatalf("next = %q, want %q", track.Title, "B")
}
if _, idx := p.Current(); idx != 1 {
t.Fatalf("current index = %d, want 1", idx)
}
}
func TestNextRepeatAllShuffleWrapFailureKeepsCurrentTrack(t *testing.T) {
p := New()
p.shuffle = true
p.repeat = RepeatAll
p.Replace([]Track{
{Title: "A", Unplayable: true},
{Title: "B", Unplayable: true},
{Title: "C", Unplayable: true},
})
p.pos = len(p.order) - 1
before, beforeIdx := p.Current()
if _, ok := p.Next(); ok {
t.Fatal("Next() = true, want false")
}
after, afterIdx := p.Current()
if after.Title != before.Title || afterIdx != beforeIdx {
t.Fatalf("current = (%q,%d), want (%q,%d)", after.Title, afterIdx, before.Title, beforeIdx)
}
}
func TestNextRepeatOneUnplayableCurrentReturnsFalse(t *testing.T) {
p := New()
p.repeat = RepeatOne
p.Replace([]Track{
{Title: "A", Unplayable: true},
{Title: "B"},
{Title: "C"},
})
p.SetIndex(0)
if _, ok := p.Next(); ok {
t.Fatal("Next() = true, want false")
}
if track, idx := p.Current(); track.Title != "A" || idx != 0 {
t.Fatalf("current = (%q,%d), want (\"A\",0)", track.Title, idx)
}
}
func TestActivateSelectedUsesSelectedOrderEvenWhenQueueChangesCurrent(t *testing.T) {
tests := []struct {
name string
prepare func(t *testing.T, p *Playlist)
wantQueueLen int
wantQueueTrack int
}{
{
name: "pending queued track",
prepare: func(_ *testing.T, p *Playlist) {
p.Queue(0)
},
wantQueueLen: 1,
wantQueueTrack: 0,
},
{
name: "active queued current",
prepare: func(t *testing.T, p *Playlist) {
p.Queue(0)
if track, ok := p.Next(); !ok || track.Title != "Queued" {
t.Fatalf("Next() = (%q,%t), want (\"Queued\",true)", track.Title, ok)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := New()
p.Replace([]Track{
{Title: "Queued"},
{Title: "Missing", Unplayable: true},
{Title: "Replacement"},
})
p.SetIndex(1)
tt.prepare(t, p)
activation, ok := p.ActivateSelected()
if !ok {
t.Fatal("ActivateSelected() = false, want true")
}
if activation.Track.Title != "Replacement" || activation.Index != 2 || !activation.Skipped {
t.Fatalf("activation = (%q,%d,%t), want (\"Replacement\",2,true)", activation.Track.Title, activation.Index, activation.Skipped)
}
if current, idx := p.Current(); current.Title != "Replacement" || idx != 2 {
t.Fatalf("current = (%q,%d), want (\"Replacement\",2)", current.Title, idx)
}
if p.QueueLen() != tt.wantQueueLen {
t.Fatalf("QueueLen() = %d, want %d", p.QueueLen(), tt.wantQueueLen)
}
if tt.wantQueueLen > 0 && p.QueuePosition(tt.wantQueueTrack) != 1 {
t.Fatalf("QueuePosition(%d) = %d, want 1", tt.wantQueueTrack, p.QueuePosition(tt.wantQueueTrack))
}
})
}
}
func TestActivateSelectedWrapsWithRepeatAll(t *testing.T) {
p := New()
p.repeat = RepeatAll
p.Replace([]Track{
{Title: "A"},
{Title: "B"},
{Title: "C", Unplayable: true},
})
p.SetIndex(2)
activation, ok := p.ActivateSelected()
if !ok {
t.Fatal("ActivateSelected() = false, want true")
}
if activation.Track.Title != "A" || activation.Index != 0 || !activation.Skipped {
t.Fatalf("activation = (%q,%d,%t), want (\"A\",0,true)", activation.Track.Title, activation.Index, activation.Skipped)
}
}
func TestActivateSelectedFailureKeepsQueuedCurrentTrack(t *testing.T) {
p := New()
p.Replace([]Track{
{Title: "Queued"},
{Title: "Missing", Unplayable: true},
{Title: "Still Missing", Unplayable: true},
})
p.SetIndex(1)
p.Queue(0)
p.Queue(2)
if track, ok := p.Next(); !ok || track.Title != "Queued" {
t.Fatalf("Next() = (%q,%t), want (\"Queued\",true)", track.Title, ok)
}
if _, ok := p.ActivateSelected(); ok {
t.Fatal("ActivateSelected() = true, want false")
}
if current, idx := p.Current(); current.Title != "Queued" || idx != 0 {
t.Fatalf("current = (%q,%d), want (\"Queued\",0)", current.Title, idx)
}
if p.QueueLen() != 1 {
t.Fatalf("QueueLen() = %d, want 1", p.QueueLen())
}
if p.QueuePosition(2) != 1 {
t.Fatalf("QueuePosition(2) = %d, want 1", p.QueuePosition(2))
}
}
func TestTotalDurationSecs(t *testing.T) {
tracks := []Track{
{DurationSecs: 100},
{DurationSecs: 0}, // unknown — skipped
{DurationSecs: 200},
}
if got := TotalDurationSecs(tracks); got != 300 {
t.Errorf("TotalDurationSecs = %d, want 300", got)
}
if got := TotalDurationSecs(nil); got != 0 {
t.Errorf("TotalDurationSecs(nil) = %d, want 0", got)
}
}