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