import Foundation import XCTest import VelodyDomain import VelodyNetworking import VelodyPlayback import VelodyPersistence @testable import VelodyiPhone @MainActor final class iPhoneLibraryViewModelPlaybackTests: XCTestCase { func testNowPlayingArtworkHeightStaysWithinRequestedRange() { let contentWidths: [CGFloat] = [280, 343, 361, 398, 520] for width in contentWidths { let artworkHeight = NowPlayingCardLayoutMetrics.artworkHeight(for: width) XCTAssertGreaterThanOrEqual(artworkHeight, 140) XCTAssertLessThanOrEqual(artworkHeight, 180) } XCTAssertEqual(NowPlayingCardLayoutMetrics.artworkHeight(for: 280), 148) XCTAssertEqual(NowPlayingCardLayoutMetrics.artworkHeight(for: 520), 176) } 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, "This downloaded file is missing. Re-download the track to play it again." ) } 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 final class iPhoneLibraryViewModelPolishTests: XCTestCase { func testSyncingStatePreventsDuplicateRefreshCalls() async { let track = makeRemoteTrack( trackId: "remote-sync-dedupe", assetId: "asset-sync-dedupe", title: "Sync Once" ) let counter = RegisterCallCounter() let apiClient = TestRegisterAPIClient( counter: counter, delayNanoseconds: 200_000_000 ) let viewModel = makeViewModel( remoteTracks: [track], apiClient: apiClient ) await viewModel.loadIfNeeded() async let firstRefresh: Void = viewModel.refreshSync() async let secondRefresh: Void = viewModel.refreshSync() _ = await (firstRefresh, secondRefresh) let syncCallCount = await counter.count XCTAssertEqual(syncCallCount, 1) XCTAssertFalse(viewModel.isSyncing) } func testUserFacingConnectionErrorDoesNotExposeRawExceptionDetails() async { let rawErrorText = "socket closed for 10.0.0.8:3017" let apiClient = TestRegisterAPIClient( counter: RegisterCallCounter(), registerError: VelodyAPIError.requestFailed(rawErrorText) ) let viewModel = makeViewModel( remoteTracks: [], apiClient: apiClient ) await viewModel.loadIfNeeded() await viewModel.refreshSync() XCTAssertEqual( viewModel.syncStatus, "Could not reach the backend. Check that the server is running and try again." ) XCTAssertEqual(viewModel.remoteEmptyStateMessage?.title, "Connection failed") XCTAssertEqual( viewModel.remoteEmptyStateMessage?.body, "Could not reach the backend. Check that the server is running and try again." ) XCTAssertFalse(viewModel.syncStatus.contains(rawErrorText)) guard case let .networkError(debugMessage) = viewModel.state else { return XCTFail("Expected network error state.") } XCTAssertTrue(debugMessage.contains(rawErrorText)) } func testSectionTitlesReflectFilteredAndUnfilteredCounts() async { let firstTrack = makeRemoteTrack( trackId: "remote-counts-first", assetId: "asset-counts-first", title: "Trap Door" ) let secondTrack = makeRemoteTrack( trackId: "remote-counts-second", assetId: "asset-counts-second", title: "Harbor Lights" ) let viewModel = makeViewModel( remoteTracks: [firstTrack, secondTrack], downloadStates: [ makeDownloadedState(for: firstTrack), makeDownloadedState(for: secondTrack), ], audioFiles: [ localFilePath(for: firstTrack): Data([0x1]), localFilePath(for: secondTrack): Data([0x2]), ] ) await viewModel.loadIfNeeded() XCTAssertEqual(viewModel.remoteSectionTitle, "Remote Library (2)") XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (2)") viewModel.searchText = "Trap" XCTAssertEqual(viewModel.remoteSectionTitle, "Remote Library Results (1)") XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Offline Results (1)") viewModel.searchText = "zzz" XCTAssertEqual(viewModel.remoteSectionTitle, "Remote Library Results (0)") XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Offline Results (0)") } func testSearchEmptyStateUsesFriendlyCopyAndKeepsNowPlaying() async throws { let firstTrack = makeRemoteTrack( trackId: "remote-empty-search-first", assetId: "asset-empty-search-first", title: "Light Trap" ) let secondTrack = makeRemoteTrack( trackId: "remote-empty-search-second", assetId: "asset-empty-search-second", title: "Harbor Lights" ) 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 = "zzz" XCTAssertEqual(viewModel.remoteEmptyStateMessage?.title, "No matching tracks") XCTAssertEqual(viewModel.remoteEmptyStateMessage?.body, "Try a different title or artist.") XCTAssertEqual(viewModel.availableOfflineEmptyStateMessage?.title, "No matching tracks") XCTAssertEqual(try XCTUnwrap(viewModel.nowPlayingCard).trackID, firstTrack.trackId) } func testDisplayRowsDoNotExposeDebugIdentifiers() async throws { let track = makeRemoteTrack( trackId: "remote-polish-row", assetId: "asset-polish-row", title: "Clean Display" ) let viewModel = makeViewModel( remoteTracks: [track], downloadStates: [makeDownloadedState(for: track)], audioFiles: [localFilePath(for: track): Data([0x1])] ) await viewModel.loadIfNeeded() let remoteLabels = Set(Mirror(reflecting: try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))).children.compactMap(\.label)) let offlineLabels = Set(Mirror(reflecting: try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId))).children.compactMap(\.label)) XCTAssertFalse(remoteLabels.contains("remoteTrackID")) XCTAssertFalse(remoteLabels.contains("assetID")) XCTAssertFalse(remoteLabels.contains("lastDownloadError")) XCTAssertFalse(offlineLabels.contains("remoteTrackID")) XCTAssertFalse(offlineLabels.contains("assetID")) } func testMissingTracksStayOutOfAvailableOffline() async throws { let track = makeRemoteTrack( trackId: "remote-missing-offline", assetId: "asset-missing-offline", title: "Lost File" ) let viewModel = makeViewModel( remoteTracks: [track], downloadStates: [makeDownloadedState(for: track)] ) await viewModel.loadIfNeeded() let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)) XCTAssertEqual(remoteTrack.status, .missing) XCTAssertEqual(remoteTrack.statusBadgeTitle, "Missing") XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty) XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (0)") } } private actor RegisterCallCounter { private(set) var count = 0 func increment() { count += 1 } } private struct TestRegisterAPIClient: VelodyAPIClient { let counter: RegisterCallCounter var delayNanoseconds: UInt64 = 0 var registerError: VelodyAPIError? private let environment = ServerEnvironment( baseURL: ServerEnvironment.defaultLocalBaseURL, appVersion: "Tests" ) func registerDevice( _ payload: DeviceRegistrationPayload ) async throws -> DeviceRegistrationResponse { await counter.increment() if delayNanoseconds > 0 { try? await Task.sleep(nanoseconds: delayNanoseconds) } if let registerError { throw registerError } return try await stubClient.registerDevice(payload) } func sendHeartbeat( _ payload: DeviceHeartbeatPayload ) async throws -> DeviceHeartbeatResponse { try await stubClient.sendHeartbeat(payload) } func fetchSyncBootstrap() async throws -> SyncBootstrapResponse { try await stubClient.fetchSyncBootstrap() } func fetchRemoteLibrary( deviceId: String ) async throws -> RemoteLibraryResponseDTO { try await stubClient.fetchRemoteLibrary(deviceId: deviceId) } func downloadAudioAsset( assetId: String, deviceId: String ) async throws -> Data { try await stubClient.downloadAudioAsset(assetId: assetId, deviceId: deviceId) } func downloadArtwork( artworkId: String, deviceId: String ) async throws -> Data { try await stubClient.downloadArtwork(artworkId: artworkId, deviceId: deviceId) } func prepareUpload( _ payload: UploadPrepareRequest ) async throws -> UploadPrepareResponse { try await stubClient.prepareUpload(payload) } func fetchUploadStatus( uploadId: String ) async throws -> UploadSessionStatusResponse { try await stubClient.fetchUploadStatus(uploadId: uploadId) } func uploadFile( uploadId: String, fileURL: URL, mimeType: String ) async throws -> UploadSessionStatusResponse { try await stubClient.uploadFile( uploadId: uploadId, fileURL: fileURL, mimeType: mimeType ) } func finalizeUpload( uploadId: String, payload: UploadFinalizeRequest ) async throws -> UploadFinalizeResponse { try await stubClient.finalizeUpload(uploadId: uploadId, payload: payload) } private var stubClient: StubVelodyAPIClient { StubVelodyAPIClient(environment: environment) } } @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) } }