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.
162 lines
5.9 KiB
162 lines
5.9 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 })
|
|
}
|
|
|
|
// 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 }
|
|
}
|
|
|