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.
 
 
Music/MusicTests/PlayerViewModelTests.swift

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