You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
270 lines
10 KiB
270 lines
10 KiB
import Testing
|
|
import Foundation
|
|
@testable import Music
|
|
|
|
@MainActor
|
|
struct PlayerViewModelTests {
|
|
private func makeTracks(_ count: Int) -> [Track] {
|
|
(0..<count).map { i in
|
|
Track.fixture(
|
|
id: Int64(i + 1),
|
|
fileURL: "/track\(i).mp3",
|
|
title: "Track \(i)"
|
|
)
|
|
}
|
|
}
|
|
|
|
// Sets the queue and plays a track, verifies current track and index are set.
|
|
@Test func playTrackSetsCurrentTrackAndIndex() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let tracks = makeTracks(5)
|
|
vm.setQueue(tracks)
|
|
vm.play(tracks[2])
|
|
|
|
#expect(vm.currentTrack?.id == 3)
|
|
#expect(vm.currentIndex == 2)
|
|
}
|
|
|
|
// Calls next() and verifies it advances to the next track.
|
|
@Test func nextAdvancesToNextTrack() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let tracks = makeTracks(5)
|
|
vm.setQueue(tracks)
|
|
vm.play(tracks[0])
|
|
vm.next()
|
|
|
|
#expect(vm.currentTrack?.id == 2)
|
|
#expect(vm.currentIndex == 1)
|
|
}
|
|
|
|
// Calls next() on the last track and verifies it stops (no wrap for v1).
|
|
@Test func nextAtEndStops() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let tracks = makeTracks(3)
|
|
vm.setQueue(tracks)
|
|
vm.play(tracks[2])
|
|
vm.next()
|
|
|
|
#expect(vm.currentTrack == nil)
|
|
}
|
|
|
|
// Calls previous() and verifies it goes to the previous track.
|
|
@Test func previousGoesToPreviousTrack() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let tracks = makeTracks(5)
|
|
vm.setQueue(tracks)
|
|
vm.play(tracks[3])
|
|
vm.previous()
|
|
|
|
#expect(vm.currentTrack?.id == 3)
|
|
}
|
|
|
|
// Calls previous() on the first track and verifies it stays at the first track.
|
|
@Test func previousAtStartStaysAtFirst() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let tracks = makeTracks(3)
|
|
vm.setQueue(tracks)
|
|
vm.play(tracks[0])
|
|
vm.previous()
|
|
|
|
#expect(vm.currentTrack?.id == 1)
|
|
#expect(vm.currentIndex == 0)
|
|
}
|
|
|
|
// Enables shuffle and verifies the shuffled queue contains all tracks
|
|
// and starts with the current track.
|
|
@Test func shuffleContainsAllTracksStartingWithCurrent() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let tracks = makeTracks(20)
|
|
vm.setQueue(tracks)
|
|
vm.play(tracks[5])
|
|
vm.toggleShuffle()
|
|
|
|
#expect(vm.isShuffled == true)
|
|
#expect(vm.currentTrack?.id == 6)
|
|
|
|
let shuffledIds = Set(vm.queue.map { $0.id })
|
|
let originalIds = Set(tracks.map { $0.id })
|
|
#expect(shuffledIds == originalIds)
|
|
}
|
|
|
|
// Disables shuffle and verifies the queue returns to original order
|
|
// and current track is preserved.
|
|
@Test func unshuffleRestoresOriginalOrder() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let tracks = makeTracks(10)
|
|
vm.setQueue(tracks)
|
|
vm.play(tracks[3])
|
|
vm.toggleShuffle()
|
|
vm.toggleShuffle()
|
|
|
|
#expect(vm.isShuffled == false)
|
|
#expect(vm.currentTrack?.id == 4)
|
|
#expect(vm.queue.map { $0.id } == tracks.map { $0.id })
|
|
}
|
|
|
|
// Step 1: context [1,2,3], track 1 playing.
|
|
// Step 2: addToQueue twice → manual queue holds those tracks in arrival order.
|
|
// Step 3: playNext jumps a track to the FRONT of the manual queue.
|
|
@Test func addToQueueAppendsAndPlayNextInsertsFront() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let tracks = makeTracks(6)
|
|
vm.setQueue(Array(tracks[0..<3]))
|
|
vm.play(tracks[0])
|
|
|
|
vm.addToQueue(tracks[3]) // id 4
|
|
vm.addToQueue(tracks[4]) // id 5
|
|
#expect(vm.manualQueue.map { $0.track.id } == [4, 5])
|
|
|
|
vm.playNext(tracks[5]) // id 6 to the front
|
|
#expect(vm.manualQueue.map { $0.track.id } == [6, 4, 5])
|
|
}
|
|
|
|
// Step 1: a view model with nothing playing (idle).
|
|
// Step 2: addToQueue should start playback immediately (queue-while-idle) and
|
|
// leave the manual queue empty because the track was consumed to play.
|
|
@Test func queueWhileIdleStartsPlayback() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let track = Track.fixture(id: 1, fileURL: "/a.mp3", title: "A")
|
|
|
|
vm.addToQueue(track)
|
|
|
|
#expect(vm.currentTrack?.id == 1)
|
|
#expect(vm.manualQueue.isEmpty)
|
|
}
|
|
|
|
// Step 1: context [1,2,3], track 1 playing; two tracks added to manual queue.
|
|
// Step 2: next() is called — must consume manualQueue before advancing context.
|
|
// Step 3: next() again — drains second manual-queue entry.
|
|
// Step 4: next() with empty manualQueue — falls through to context track 2.
|
|
@Test func nextDrainsManualQueueBeforeContext() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let tracks = makeTracks(5)
|
|
vm.setQueue(Array(tracks[0..<3]))
|
|
vm.play(tracks[0]) // context index 0 (id 1)
|
|
|
|
vm.addToQueue(tracks[3]) // id 4
|
|
vm.addToQueue(tracks[4]) // id 5
|
|
|
|
vm.next()
|
|
#expect(vm.currentTrack?.id == 4)
|
|
#expect(vm.manualQueue.map { $0.track.id } == [5])
|
|
|
|
vm.next()
|
|
#expect(vm.currentTrack?.id == 5)
|
|
#expect(vm.manualQueue.isEmpty)
|
|
|
|
vm.next() // manual queue empty → resume context at index 1
|
|
#expect(vm.currentTrack?.id == 2)
|
|
#expect(vm.currentIndex == 1)
|
|
}
|
|
|
|
// Step 1: context [1,2,3], track 1 playing; queue tracks 4,5,6.
|
|
// Step 2: removeFromQueue removes the middle entry → [4,6].
|
|
// Step 3: moveInQueue moves the last entry to the front → [6,4].
|
|
@Test func removeAndMoveMutateManualQueue() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let tracks = makeTracks(6)
|
|
vm.setQueue(Array(tracks[0..<3]))
|
|
vm.play(tracks[0])
|
|
vm.addToQueue(tracks[3]); vm.addToQueue(tracks[4]); vm.addToQueue(tracks[5])
|
|
#expect(vm.manualQueue.map { $0.track.id } == [4, 5, 6])
|
|
|
|
vm.removeFromQueue(at: IndexSet(integer: 1))
|
|
#expect(vm.manualQueue.map { $0.track.id } == [4, 6])
|
|
|
|
vm.moveInQueue(from: IndexSet(integer: 1), to: 0)
|
|
#expect(vm.manualQueue.map { $0.track.id } == [6, 4])
|
|
}
|
|
|
|
// Step 1: context [1,2,3,4], track 2 playing (currentIndex 1).
|
|
// Step 2: upcomingContext is the slice after the current position → [3,4].
|
|
@Test func upcomingContextReturnsTracksAfterCurrent() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let tracks = makeTracks(4)
|
|
vm.setQueue(tracks)
|
|
vm.play(tracks[1])
|
|
|
|
#expect(vm.upcomingContext.map { $0.id } == [3, 4])
|
|
}
|
|
|
|
// Step 1: 10-track context, one playing; queue tracks 11 and 12 in order.
|
|
// Step 2: toggling shuffle reorders only the context — the manual queue order
|
|
// must be left exactly as the user arranged it.
|
|
@Test func shuffleLeavesManualQueueIntact() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
let tracks = makeTracks(12)
|
|
vm.setQueue(Array(tracks[0..<10]))
|
|
vm.play(tracks[0])
|
|
vm.addToQueue(tracks[10]) // id 11
|
|
vm.addToQueue(tracks[11]) // id 12
|
|
|
|
vm.toggleShuffle()
|
|
|
|
#expect(vm.manualQueue.map { $0.track.id } == [11, 12])
|
|
}
|
|
|
|
// Step 1: setQueue accepts an optional context label for the panel header.
|
|
@Test func setQueueStoresContextName() {
|
|
let vm = PlayerViewModel(provider: AudioService(), db: nil)
|
|
vm.setQueue(makeTracks(2), contextName: "Synthwave")
|
|
#expect(vm.contextName == "Synthwave")
|
|
}
|
|
|
|
// Reproduces the streaming "0:00" bug: when the player cannot determine a
|
|
// track's total duration (as with a progressive HTTP MP3 stream), the view
|
|
// model must fall back to the duration already known from the library database.
|
|
@Test func streamingTrackUsesDatabaseDurationWhenPlayerCannotDetermineIt() {
|
|
// Step 1: Back the view model with a provider that mimics streaming —
|
|
// it reports playback state but never a valid duration (stays 0).
|
|
let provider = FakeStreamingProvider()
|
|
let vm = PlayerViewModel(provider: provider, db: nil)
|
|
|
|
// Step 2: Queue a track whose duration is known from the database (215s).
|
|
let track = Track.fixture(id: 1, title: "Streamed", duration: 215)
|
|
vm.setQueue([track])
|
|
|
|
// Step 3: Play it. The provider fires a playback-state change with
|
|
// duration 0, exactly as AVPlayer does for an unmeasurable
|
|
// progressive stream.
|
|
vm.play(track)
|
|
|
|
// Step 4: The displayed total duration must be the database value (215s),
|
|
// not 0 — otherwise the UI shows "0:00".
|
|
#expect(vm.duration == 215)
|
|
}
|
|
}
|
|
|
|
// A PlaybackProvider that mimics the streaming case: it "plays" from an HTTP URL
|
|
// but — like AVPlayer with a progressive MP3 stream — can never determine the
|
|
// total duration, so `duration` stays 0. Used to reproduce the "0:00" bug.
|
|
@MainActor
|
|
private final class FakeStreamingProvider: PlaybackProvider {
|
|
var isPlaying = false
|
|
var currentTime: Double = 0
|
|
var duration: Double = 0 // never resolves — the crux of the bug
|
|
var volume: Float = 0.65
|
|
private(set) var isScrubbing = false
|
|
var onTrackFinished: (() -> Void)?
|
|
var onPlaybackStateChanged: (() -> Void)?
|
|
|
|
func urlForTrack(_ track: Track) -> URL? {
|
|
URL(string: "http://host/file?id=\(track.id ?? 0)")
|
|
}
|
|
|
|
func play(url: URL) {
|
|
isPlaying = true
|
|
// Simulate the periodic time observer firing with no determinable duration.
|
|
onPlaybackStateChanged?()
|
|
}
|
|
|
|
func pause() { isPlaying = false; onPlaybackStateChanged?() }
|
|
func resume() { isPlaying = true; onPlaybackStateChanged?() }
|
|
func togglePlayPause() { if isPlaying { pause() } else { resume() } }
|
|
func seek(to position: Double) { currentTime = position }
|
|
func setVolume(_ level: Float) { volume = level }
|
|
func stop() { isPlaying = false; currentTime = 0; duration = 0 }
|
|
func beginScrubbing() { isScrubbing = true }
|
|
func scrub(to position: Double) { currentTime = position }
|
|
func endScrubbing(at position: Double) { currentTime = position; isScrubbing = false }
|
|
}
|
|
|