291 lines
10 KiB
Swift
291 lines
10 KiB
Swift
import Foundation
|
|
import XCTest
|
|
import VelodyDomain
|
|
import VelodyPlayback
|
|
import VelodyPersistence
|
|
@testable import VelodyiPhone
|
|
|
|
@MainActor
|
|
final class iPhoneLibraryViewModelPlaybackTests: XCTestCase {
|
|
func testNowPlayingCardShowsTitleArtistAndArtwork() async throws {
|
|
let artwork = RemoteArtwork(
|
|
artworkId: "artwork-midnight",
|
|
sha256: String(repeating: "b", count: 64),
|
|
mimeType: "image/png"
|
|
)
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-midnight-city",
|
|
assetId: "asset-midnight-city",
|
|
title: "Midnight City",
|
|
artwork: artwork
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1, 0x2])],
|
|
artworkPayloadsByArtworkID: [artwork.artworkId: Data([0x9, 0x8])],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
|
|
let card = try XCTUnwrap(viewModel.nowPlayingCard)
|
|
XCTAssertEqual(card.title, track.title)
|
|
XCTAssertEqual(card.artist, track.artist)
|
|
XCTAssertEqual(card.artworkLocalFilePath, localArtworkFilePath(for: artwork))
|
|
XCTAssertEqual(card.downloadBadge, .downloaded)
|
|
XCTAssertEqual(card.playbackStateText, "Playing")
|
|
}
|
|
|
|
func testMissingLocalFilePreventsPlayingAndDisablesSeek() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-missing-file",
|
|
assetId: "asset-missing-file",
|
|
title: "Lost Signal"
|
|
)
|
|
let player = TestPlayer()
|
|
player.missingTrackIDs.insert(track.trackId)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
|
|
let card = try XCTUnwrap(viewModel.nowPlayingCard)
|
|
XCTAssertEqual(viewModel.nowPlaying.playbackState, .missingFile)
|
|
XCTAssertFalse(viewModel.nowPlaying.isPlaying)
|
|
XCTAssertFalse(card.canSeek)
|
|
XCTAssertEqual(card.downloadBadge, .missing)
|
|
XCTAssertEqual(card.title, track.title)
|
|
XCTAssertEqual(card.artist, track.artist)
|
|
XCTAssertEqual(card.playbackStateText, "Missing file")
|
|
XCTAssertEqual(card.errorMessage, "The local file could not be found: \(localFilePath(for: track))")
|
|
}
|
|
|
|
func testPauseStopsProgressUpdates() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-pause",
|
|
assetId: "asset-pause",
|
|
title: "Pause Study"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1])],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
player.advanceProgress(by: 12)
|
|
|
|
XCTAssertEqual(viewModel.nowPlaying.currentTime, 12)
|
|
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
player.advanceProgress(by: 8)
|
|
|
|
XCTAssertEqual(viewModel.nowPlaying.playbackState, .paused)
|
|
XCTAssertEqual(viewModel.nowPlaying.currentTime, 12)
|
|
}
|
|
|
|
func testStopResetsProgressSafely() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-stop",
|
|
assetId: "asset-stop",
|
|
title: "Stop Motion"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1])],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
player.advanceProgress(by: 33)
|
|
|
|
viewModel.stopPlayback()
|
|
|
|
XCTAssertEqual(viewModel.nowPlaying.playbackState, .stopped)
|
|
XCTAssertEqual(viewModel.nowPlaying.currentTime, 0)
|
|
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
|
|
}
|
|
|
|
func testSearchDoesNotClearNowPlaying() async throws {
|
|
let firstTrack = makeRemoteTrack(
|
|
trackId: "remote-first-search",
|
|
assetId: "asset-first-search",
|
|
title: "Blue Avenue"
|
|
)
|
|
let secondTrack = makeRemoteTrack(
|
|
trackId: "remote-second-search",
|
|
assetId: "asset-second-search",
|
|
title: "Golden Hour"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [firstTrack, secondTrack],
|
|
downloadStates: [
|
|
makeDownloadedState(for: firstTrack),
|
|
makeDownloadedState(for: secondTrack),
|
|
],
|
|
audioFiles: [
|
|
localFilePath(for: firstTrack): Data([0x1]),
|
|
localFilePath(for: secondTrack): Data([0x2]),
|
|
],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: firstTrack.trackId)
|
|
viewModel.searchText = secondTrack.title
|
|
|
|
let card = try XCTUnwrap(viewModel.nowPlayingCard)
|
|
XCTAssertEqual(card.trackID, firstTrack.trackId)
|
|
XCTAssertNil(remoteRow(in: viewModel, trackID: firstTrack.trackId))
|
|
XCTAssertEqual(viewModel.remoteTracks.count, 1)
|
|
}
|
|
|
|
func testPlaybackErrorDoesNotLeaveStateAsPlaying() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-failure",
|
|
assetId: "asset-failure",
|
|
title: "Hard Stop"
|
|
)
|
|
let player = TestPlayer()
|
|
player.failingTrackIDs.insert(track.trackId)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1])],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
|
|
XCTAssertEqual(viewModel.nowPlaying.playbackState, .failed)
|
|
XCTAssertFalse(viewModel.nowPlaying.isPlaying)
|
|
XCTAssertEqual(viewModel.nowPlaying.errorMessage, "Playback could not be started.")
|
|
}
|
|
|
|
func testSeekUpdatesCurrentTimeWhenTrackIsLoaded() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-seek",
|
|
assetId: "asset-seek",
|
|
title: "Seeklight"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1])],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
viewModel.seekPlayback(to: 90)
|
|
|
|
XCTAssertEqual(viewModel.nowPlaying.currentTime, 90)
|
|
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
|
|
}
|
|
|
|
func testRelaunchRestoresMetadataButDoesNotAutoplay() {
|
|
let suiteName = "de.diyaa.velody.tests.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suiteName)!
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
let sessionStore = iPhonePlaybackSessionStore(
|
|
userDefaults: defaults,
|
|
storageKey: "playback"
|
|
)
|
|
let track = LibraryTrack(
|
|
id: "remote-restore",
|
|
title: "Restore Point",
|
|
artist: "Velody Artist",
|
|
durationSeconds: 245,
|
|
localFilePath: "/in-memory/asset-restore.mp3",
|
|
remoteTrackId: "remote-restore"
|
|
)
|
|
|
|
let firstEngine = FakePlaybackEngine()
|
|
firstEngine.durationByPath[track.localFilePath] = 245
|
|
let firstPlayer = iPhonePlaybackControllerPlayer(
|
|
controller: PlaybackController(
|
|
engine: firstEngine,
|
|
sessionStore: sessionStore
|
|
)
|
|
)
|
|
firstPlayer.setCatalogTracks([track])
|
|
firstPlayer.play(trackID: track.id)
|
|
firstEngine.currentTime = 48
|
|
firstPlayer.pause()
|
|
|
|
let storedSession = sessionStore.loadSession()
|
|
XCTAssertEqual(storedSession?.currentTrackID, track.id)
|
|
XCTAssertEqual(storedSession?.currentTime, 48)
|
|
XCTAssertEqual(storedSession?.queueTrackIDs, [])
|
|
|
|
let secondEngine = FakePlaybackEngine()
|
|
secondEngine.durationByPath[track.localFilePath] = 245
|
|
let relaunchedPlayer = iPhonePlaybackControllerPlayer(
|
|
controller: PlaybackController(
|
|
engine: secondEngine,
|
|
sessionStore: sessionStore
|
|
)
|
|
)
|
|
relaunchedPlayer.setCatalogTracks([track])
|
|
|
|
XCTAssertEqual(relaunchedPlayer.state.trackID, track.id)
|
|
XCTAssertEqual(relaunchedPlayer.state.title, track.title)
|
|
XCTAssertEqual(relaunchedPlayer.state.artist, track.artist)
|
|
XCTAssertEqual(relaunchedPlayer.state.currentTime, 48)
|
|
XCTAssertEqual(relaunchedPlayer.state.playbackState, .paused)
|
|
XCTAssertFalse(relaunchedPlayer.state.isPlaying)
|
|
XCTAssertNil(relaunchedPlayer.state.errorMessage)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class FakePlaybackEngine: PlaybackEngine {
|
|
var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)?
|
|
var currentTime: Double = 0
|
|
var duration: Double = 0
|
|
var isPlaying = false
|
|
var durationByPath: [String: Double] = [:]
|
|
|
|
func loadTrack(at fileURL: URL, startTime: Double) throws {
|
|
currentTime = startTime
|
|
duration = durationByPath[fileURL.path] ?? 0
|
|
isPlaying = false
|
|
}
|
|
|
|
func play() throws {
|
|
isPlaying = true
|
|
}
|
|
|
|
func pause() {
|
|
isPlaying = false
|
|
}
|
|
|
|
func stop() {
|
|
isPlaying = false
|
|
currentTime = 0
|
|
}
|
|
|
|
func seek(to time: Double) throws {
|
|
currentTime = min(max(time, 0), duration)
|
|
}
|
|
}
|