import Foundation #if canImport(SwiftUI) import SwiftUI #endif #if canImport(UIKit) import UIKit #endif import XCTest import VelodyDomain import VelodyNetworking import VelodyPlayback import VelodyPersistence import VelodyUtilities @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 testNowPlayingControlsLayoutFitsCommonIPhoneCardWidths() { let cases: [(name: String, availableWidth: CGFloat, expectedStyle: NowPlayingCardLayoutMetrics.ControlsLayout.Style)] = [ ("iPhone 16e", 311, .regular), ("iPhone 17", 321, .regular), ("iPhone 17 Pro Max", 358, .regular), ] for testCase in cases { let layout = NowPlayingCardLayoutMetrics.controlsLayout(for: testCase.availableWidth) XCTAssertLessThanOrEqual( layout.firstRowWidth, testCase.availableWidth, "\(testCase.name) control row overflowed the card width" ) XCTAssertGreaterThan(layout.primaryButtonSize, layout.secondaryButtonSize) XCTAssertEqual(layout.style, testCase.expectedStyle) } } #if canImport(UIKit) func testNowPlayingViewRendersCommonIPhoneLayouts() async throws { let captures: [(name: String, size: CGSize)] = [ ("iphone-16e", CGSize(width: 375, height: 812)), ("iphone-17", CGSize(width: 393, height: 852)), ("iphone-17-pro-max", CGSize(width: 430, height: 932)), ] let outputDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("velody-now-playing-layout", isDirectory: true) try? FileManager.default.removeItem(at: outputDirectory) try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) for capture in captures { let viewModel = try await makeNowPlayingScreenshotViewModel() let image = try XCTUnwrap( renderNowPlayingLayoutScreenshot(viewModel: viewModel, size: capture.size) ) XCTAssertEqual(image.size.width, capture.size.width) XCTAssertEqual(image.size.height, capture.size.height) let url = outputDirectory.appendingPathComponent("\(capture.name).png") let pngData = try XCTUnwrap(image.pngData()) try pngData.write(to: url) print("Saved now playing screenshot to \(url.path)") } } private func makeNowPlayingScreenshotViewModel() async throws -> iPhoneLibraryViewModel { let firstTrack = makeRemoteTrack( trackId: "remote-layout-first", assetId: "asset-layout-first", title: "Northern Lights" ) let secondTrack = makeRemoteTrack( trackId: "remote-layout-second", assetId: "asset-layout-second", title: "Horizons" ) let thirdTrack = makeRemoteTrack( trackId: "remote-layout-third", assetId: "asset-layout-third", title: "Coastline" ) let favoriteStore = InMemoryFavoriteTrackStore( tracks: [ FavoriteTrackRecord( remoteTrackId: secondTrack.trackId, favoritedAt: Date(timeIntervalSince1970: 3_000) ), ] ) let viewModel = makeViewModel( remoteTracks: [firstTrack, secondTrack, thirdTrack], downloadStates: [ makeDownloadedState(for: firstTrack), makeDownloadedState(for: secondTrack), makeDownloadedState(for: thirdTrack), ], favoriteTrackStore: favoriteStore, audioFiles: [ localFilePath(for: firstTrack): Data([0x1]), localFilePath(for: secondTrack): Data([0x2]), localFilePath(for: thirdTrack): Data([0x3]), ] ) await viewModel.loadIfNeeded() viewModel.togglePlayback(trackID: secondTrack.trackId) viewModel.cycleRepeatMode() return viewModel } private func renderNowPlayingLayoutScreenshot( viewModel: iPhoneLibraryViewModel, size: CGSize ) -> UIImage? { let rootView = iPhoneLibraryView(viewModel: viewModel) .frame(width: size.width, height: size.height) let hostingController = UIHostingController(rootView: rootView) let window = UIWindow(frame: CGRect(origin: .zero, size: size)) window.rootViewController = hostingController window.isHidden = false hostingController.view.frame = window.bounds hostingController.view.backgroundColor = .systemGroupedBackground hostingController.view.setNeedsLayout() hostingController.view.layoutIfNeeded() window.layoutIfNeeded() RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) let renderer = UIGraphicsImageRenderer(size: size) let image = renderer.image { _ in hostingController.view.drawHierarchy(in: hostingController.view.bounds, afterScreenUpdates: true) } window.isHidden = true return image } #endif 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 testNextTrackUpdatesNowPlayingMetadata() async throws { let firstTrack = makeRemoteTrack( trackId: "remote-next-first", assetId: "asset-next-first", title: "Arrival" ) let secondTrack = makeRemoteTrack( trackId: "remote-next-second", assetId: "asset-next-second", title: "Departure" ) 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.nextTrack() let card = try XCTUnwrap(viewModel.nowPlayingCard) XCTAssertEqual(viewModel.nowPlaying.trackID, secondTrack.trackId) XCTAssertEqual(card.trackID, secondTrack.trackId) XCTAssertEqual(card.title, secondTrack.title) XCTAssertEqual(card.artist, secondTrack.artist) XCTAssertEqual(viewModel.nowPlaying.playbackState, .playing) XCTAssertTrue(viewModel.canGoPrevious) XCTAssertFalse(viewModel.canGoNext) } func testPreviousTrackUpdatesNowPlayingMetadata() async throws { let firstTrack = makeRemoteTrack( trackId: "remote-previous-first", assetId: "asset-previous-first", title: "Early Bird" ) let secondTrack = makeRemoteTrack( trackId: "remote-previous-second", assetId: "asset-previous-second", title: "Night Owl" ) 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.nextTrack() viewModel.previousTrack() let card = try XCTUnwrap(viewModel.nowPlayingCard) XCTAssertEqual(viewModel.nowPlaying.trackID, firstTrack.trackId) XCTAssertEqual(card.trackID, firstTrack.trackId) XCTAssertEqual(card.title, firstTrack.title) XCTAssertEqual(card.artist, firstTrack.artist) XCTAssertEqual(viewModel.nowPlaying.playbackState, .playing) XCTAssertFalse(viewModel.canGoPrevious) XCTAssertTrue(viewModel.canGoNext) } 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 testShufflePreservesCurrentTrackAndUpdatesState() async throws { let firstTrack = makeRemoteTrack( trackId: "remote-shuffle-first", assetId: "asset-shuffle-first", title: "Northbound" ) let secondTrack = makeRemoteTrack( trackId: "remote-shuffle-second", assetId: "asset-shuffle-second", title: "Southbound" ) let thirdTrack = makeRemoteTrack( trackId: "remote-shuffle-third", assetId: "asset-shuffle-third", title: "Westbound" ) let player = TestPlayer() let viewModel = makeViewModel( remoteTracks: [firstTrack, secondTrack, thirdTrack], downloadStates: [ makeDownloadedState(for: firstTrack), makeDownloadedState(for: secondTrack), makeDownloadedState(for: thirdTrack), ], audioFiles: [ localFilePath(for: firstTrack): Data([0x1]), localFilePath(for: secondTrack): Data([0x2]), localFilePath(for: thirdTrack): Data([0x3]), ], player: player ) await viewModel.loadIfNeeded() viewModel.togglePlayback(trackID: secondTrack.trackId) let originalTrackIDs = Set([firstTrack.trackId, secondTrack.trackId, thirdTrack.trackId]) viewModel.toggleShuffle() XCTAssertTrue(viewModel.isShuffleEnabled) XCTAssertEqual(viewModel.nowPlaying.trackID, secondTrack.trackId) XCTAssertEqual(viewModel.nowPlaying.queueTrackIDs.first, secondTrack.trackId) XCTAssertEqual(Set(viewModel.nowPlaying.queueTrackIDs), originalTrackIDs) XCTAssertEqual(Set(viewModel.nowPlaying.queueTrackIDs).count, originalTrackIDs.count) } func testRepeatCyclesCorrectly() async throws { let track = makeRemoteTrack( trackId: "remote-repeat-cycle", assetId: "asset-repeat-cycle", title: "Loop Study" ) let viewModel = makeViewModel( remoteTracks: [track], downloadStates: [makeDownloadedState(for: track)], audioFiles: [localFilePath(for: track): Data([0x1])] ) await viewModel.loadIfNeeded() viewModel.togglePlayback(trackID: track.trackId) XCTAssertEqual(viewModel.repeatMode, .off) viewModel.cycleRepeatMode() XCTAssertEqual(viewModel.repeatMode, .one) viewModel.cycleRepeatMode() XCTAssertEqual(viewModel.repeatMode, .all) viewModel.cycleRepeatMode() XCTAssertEqual(viewModel.repeatMode, .off) } func testNextTrackRespectsRepeatOne() async throws { let firstTrack = makeRemoteTrack( trackId: "remote-repeat-one-first", assetId: "asset-repeat-one-first", title: "Single Orbit" ) let secondTrack = makeRemoteTrack( trackId: "remote-repeat-one-second", assetId: "asset-repeat-one-second", title: "Outer Ring" ) 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.seekPlayback(to: 37) viewModel.cycleRepeatMode() viewModel.nextTrack() XCTAssertEqual(viewModel.repeatMode, .one) XCTAssertEqual(viewModel.nowPlaying.trackID, firstTrack.trackId) XCTAssertEqual(viewModel.nowPlaying.title, firstTrack.title) XCTAssertEqual(viewModel.nowPlaying.currentTime, 0) XCTAssertEqual(viewModel.nowPlaying.playbackState, .playing) } func testQueueControlsPreserveFavoriteState() async throws { let firstTrack = makeRemoteTrack( trackId: "remote-favorite-queue-first", assetId: "asset-favorite-queue-first", title: "Anchor Point" ) let secondTrack = makeRemoteTrack( trackId: "remote-favorite-queue-second", assetId: "asset-favorite-queue-second", title: "Signal Path" ) let thirdTrack = makeRemoteTrack( trackId: "remote-favorite-queue-third", assetId: "asset-favorite-queue-third", title: "Safe Harbor" ) let favoriteStore = InMemoryFavoriteTrackStore( tracks: [ FavoriteTrackRecord( remoteTrackId: firstTrack.trackId, favoritedAt: Date(timeIntervalSince1970: 1_000) ), FavoriteTrackRecord( remoteTrackId: thirdTrack.trackId, favoritedAt: Date(timeIntervalSince1970: 2_000) ), ] ) let viewModel = makeViewModel( remoteTracks: [firstTrack, secondTrack, thirdTrack], downloadStates: [ makeDownloadedState(for: firstTrack), makeDownloadedState(for: secondTrack), makeDownloadedState(for: thirdTrack), ], favoriteTrackStore: favoriteStore, audioFiles: [ localFilePath(for: firstTrack): Data([0x1]), localFilePath(for: secondTrack): Data([0x2]), localFilePath(for: thirdTrack): Data([0x3]), ] ) await viewModel.loadIfNeeded() viewModel.togglePlayback(trackID: secondTrack.trackId) viewModel.toggleShuffle() viewModel.nextTrack() viewModel.previousTrack() XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: firstTrack.trackId)).isFavorite) XCTAssertFalse(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite) XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: thirdTrack.trackId)).isFavorite) XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: firstTrack.trackId)).isFavorite) XCTAssertFalse(try XCTUnwrap(offlineRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite) XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: thirdTrack.trackId)).isFavorite) } func testQueueControlsPreserveOfflineAndDownloadedState() async throws { let firstTrack = makeRemoteTrack( trackId: "remote-offline-first", assetId: "asset-offline-first", title: "Stored Echo" ) let secondTrack = makeRemoteTrack( trackId: "remote-offline-second", assetId: "asset-offline-second", title: "Airplane Mode" ) let viewModel = makeViewModel( remoteTracks: [firstTrack, secondTrack], downloadStates: [ makeDownloadedState(for: firstTrack), makeDownloadedState(for: secondTrack), ], audioFiles: [ localFilePath(for: firstTrack): Data([0x1, 0x2]), localFilePath(for: secondTrack): Data([0x3, 0x4]), ] ) await viewModel.loadIfNeeded() viewModel.togglePlayback(trackID: firstTrack.trackId) viewModel.toggleShuffle() viewModel.nextTrack() viewModel.previousTrack() XCTAssertEqual(try XCTUnwrap(remoteRow(in: viewModel, trackID: firstTrack.trackId)).status, .downloaded) XCTAssertEqual(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).status, .downloaded) XCTAssertEqual(try XCTUnwrap(offlineRow(in: viewModel, trackID: firstTrack.trackId)).statusBadgeTitle, "Downloaded") XCTAssertEqual(try XCTUnwrap(offlineRow(in: viewModel, trackID: secondTrack.trackId)).statusBadgeTitle, "Downloaded") XCTAssertEqual(viewModel.availableOfflineTracks.map(\.id), [firstTrack.trackId, secondTrack.trackId]) } func testRelaunchRestoresMetadataShuffleAndRepeatWithoutAutoplay() { 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 firstTrack = LibraryTrack( id: "remote-restore-first", title: "Restore Point", artist: "Velody Artist", durationSeconds: 245, localFilePath: "/in-memory/asset-restore-first.mp3", remoteTrackId: "remote-restore-first" ) let secondTrack = LibraryTrack( id: "remote-restore-second", title: "Resume Thread", artist: "Velody Artist", durationSeconds: 188, localFilePath: "/in-memory/asset-restore-second.mp3", remoteTrackId: "remote-restore-second" ) let firstEngine = FakePlaybackEngine() firstEngine.durationByPath[firstTrack.localFilePath] = 245 firstEngine.durationByPath[secondTrack.localFilePath] = 188 let firstPlayer = iPhonePlaybackControllerPlayer( controller: PlaybackController( engine: firstEngine, sessionStore: sessionStore ) ) firstPlayer.setCatalogTracks([firstTrack, secondTrack]) firstPlayer.play(trackID: secondTrack.id) firstPlayer.toggleShuffle() firstPlayer.cycleRepeatMode() firstPlayer.cycleRepeatMode() firstEngine.currentTime = 48 firstPlayer.pause() let storedSession = sessionStore.loadSession() XCTAssertEqual(storedSession?.currentTrackID, secondTrack.id) XCTAssertEqual(storedSession?.currentTime, 48) XCTAssertEqual(storedSession?.queueTrackIDs, [secondTrack.id, firstTrack.id]) XCTAssertEqual(storedSession?.isShuffleEnabled, true) XCTAssertEqual(storedSession?.repeatMode, .all) let secondEngine = FakePlaybackEngine() secondEngine.durationByPath[firstTrack.localFilePath] = 245 secondEngine.durationByPath[secondTrack.localFilePath] = 188 let relaunchedPlayer = iPhonePlaybackControllerPlayer( controller: PlaybackController( engine: secondEngine, sessionStore: sessionStore ) ) relaunchedPlayer.setCatalogTracks([firstTrack, secondTrack]) XCTAssertEqual(relaunchedPlayer.state.trackID, secondTrack.id) XCTAssertEqual(relaunchedPlayer.state.title, secondTrack.title) XCTAssertEqual(relaunchedPlayer.state.artist, secondTrack.artist) XCTAssertEqual(relaunchedPlayer.state.queueTrackIDs, [secondTrack.id, firstTrack.id]) XCTAssertTrue(relaunchedPlayer.state.isShuffleEnabled) XCTAssertEqual(relaunchedPlayer.state.repeatMode, .all) 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(remoteTrack.canDownload) XCTAssertEqual(remoteTrack.downloadButtonTitle, "Re-download") XCTAssertFalse(remoteTrack.canPlay) XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty) XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (0)") } func testDownloadedTrackAppearsInAvailableOfflineState() async throws { let track = makeRemoteTrack( trackId: "remote-recovered-download", assetId: "asset-recovered-download", title: "Recovered Download" ) let viewModel = makeViewModel( remoteTracks: [track], downloadStates: [makeDownloadedState(for: track)], audioFiles: [localFilePath(for: track): Data([0x1, 0x2, 0x3])] ) await viewModel.loadIfNeeded() let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)) let offlineTrack = try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)) XCTAssertEqual(remoteTrack.status, .downloaded) XCTAssertEqual(remoteTrack.statusBadgeTitle, "Downloaded") XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded") XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (1)") } } @MainActor final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase { func testRefreshSyncStoresDeviceAccessTokenWhenRegistrationRuns() async throws { let keychain = MemoryKeychainService() let counter = RegisterCallCounter() let viewModel = makeViewModel( remoteTracks: [ makeRemoteTrack( trackId: "remote-auth-store", assetId: "asset-auth-store", title: "Store Token" ), ], apiClient: TestRegisterAPIClient(counter: counter), keychainService: keychain ) await viewModel.loadIfNeeded() await viewModel.refreshSync() let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id") let storedDeviceAccessToken = try await keychain.loadValue( forKey: "velody.iphone.device-access-token" ) let registerCount = await counter.currentCount() XCTAssertEqual(registerCount, 1) XCTAssertFalse((storedDeviceID ?? "").isEmpty) XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty) } func testExistingDeviceWithoutAccessTokenReregistersCleanly() async throws { let keychain = MemoryKeychainService() let legacyDeviceID = "legacy-device-id" try await keychain.save(legacyDeviceID, forKey: "velody.iphone.device-id") try await keychain.save( "legacy-bootstrap-token", forKey: "velody.iphone.bootstrap-token" ) let counter = RegisterCallCounter() let viewModel = makeViewModel( remoteTracks: [ makeRemoteTrack( trackId: "remote-auth-reregister", assetId: "asset-auth-reregister", title: "Re-register Token" ), ], apiClient: TestRegisterAPIClient(counter: counter), keychainService: keychain ) await viewModel.loadIfNeeded() await viewModel.refreshSync() let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id") let storedDeviceAccessToken = try await keychain.loadValue( forKey: "velody.iphone.device-access-token" ) let registerCount = await counter.currentCount() XCTAssertEqual(registerCount, 1) XCTAssertNotEqual(storedDeviceID, legacyDeviceID) XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty) } } private actor RegisterCallCounter { private(set) var count = 0 func increment() { count += 1 } func currentCount() -> Int { count } } 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 fetchSyncChanges( cursor: SyncCursor ) async throws -> SyncChangesResponse { try await stubClient.fetchSyncChanges(cursor: cursor) } 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) } }