import Foundation import XCTest import VelodyDomain import VelodyNetworking import VelodyPersistence import VelodySync import VelodyUtilities @testable import VelodyiPhone @MainActor final class iPhoneLibraryViewModelFavoritesTests: XCTestCase { func testFavoritingTrackUpdatesRemoteOfflineAndNowPlayingState() async throws { let track = makeRemoteTrack( trackId: "remote-light-trap", assetId: "asset-light-trap", title: "Light Trap" ) let player = TestPlayer() let viewModel = makeViewModel( remoteTracks: [track], downloadStates: [makeDownloadedState(for: track)], audioFiles: [localFilePath(for: track): Data([0x1, 0x2, 0x3])], player: player ) await viewModel.loadIfNeeded() viewModel.togglePlayback(trackID: track.trackId) XCTAssertEqual(viewModel.nowPlayingFavoriteTrackID, track.trackId) XCTAssertFalse(viewModel.isNowPlayingTrackFavorite) await viewModel.toggleFavorite(trackID: track.trackId) XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite) XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite) XCTAssertEqual(viewModel.nowPlayingFavoriteTrackID, track.trackId) XCTAssertTrue(viewModel.isNowPlayingTrackFavorite) } func testUnfavoritingTrackRemovesFavoriteState() async throws { let track = makeRemoteTrack( trackId: "remote-harbor-lights", assetId: "asset-harbor-lights", title: "Harbor Lights" ) let favoriteStore = InMemoryFavoriteTrackStore(tracks: [ FavoriteTrackRecord( remoteTrackId: track.trackId, favoritedAt: Date(timeIntervalSince1970: 1_000) ), ]) let viewModel = makeViewModel( remoteTracks: [track], downloadStates: [makeDownloadedState(for: track)], favoriteTrackStore: favoriteStore, audioFiles: [localFilePath(for: track): Data([0x1])] ) await viewModel.loadIfNeeded() XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite) await viewModel.toggleFavorite(trackID: track.trackId) XCTAssertFalse(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite) XCTAssertFalse(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite) let savedFavorites = try await favoriteStore.loadFavoriteTracks() XCTAssertTrue(savedFavorites.isEmpty) } func testFavoritesPersistAcrossReload() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true ) let fileURL = tempDirectory.appendingPathComponent("favorite-tracks.json") let track = makeRemoteTrack( trackId: "remote-night-drive", assetId: "asset-night-drive", title: "Night Drive" ) defer { try? fileManager.removeItem(at: tempDirectory) } let firstViewModel = makeViewModel( remoteTracks: [track], downloadStates: [makeDownloadedState(for: track)], favoriteTrackStore: try FileFavoriteTrackStore(fileURL: fileURL), audioFiles: [localFilePath(for: track): Data([0x2])] ) await firstViewModel.loadIfNeeded() await firstViewModel.toggleFavorite(trackID: track.trackId) let relaunchedViewModel = makeViewModel( remoteTracks: [track], downloadStates: [makeDownloadedState(for: track)], favoriteTrackStore: try FileFavoriteTrackStore(fileURL: fileURL), audioFiles: [localFilePath(for: track): Data([0x2])] ) await relaunchedViewModel.loadIfNeeded() XCTAssertTrue(try XCTUnwrap(remoteRow(in: relaunchedViewModel, trackID: track.trackId)).isFavorite) await relaunchedViewModel.toggleFavorite(trackID: track.trackId) let secondRelaunchViewModel = makeViewModel( remoteTracks: [track], downloadStates: [makeDownloadedState(for: track)], favoriteTrackStore: try FileFavoriteTrackStore(fileURL: fileURL), audioFiles: [localFilePath(for: track): Data([0x2])] ) await secondRelaunchViewModel.loadIfNeeded() XCTAssertFalse(try XCTUnwrap(remoteRow(in: secondRelaunchViewModel, trackID: track.trackId)).isFavorite) } func testMultipleFavoritesAreTrackedIndependently() async throws { let firstTrack = makeRemoteTrack( trackId: "remote-first", assetId: "asset-first", title: "First Favorite" ) let secondTrack = makeRemoteTrack( trackId: "remote-second", assetId: "asset-second", title: "Second Favorite" ) let favoriteStore = InMemoryFavoriteTrackStore() let viewModel = makeViewModel( remoteTracks: [firstTrack, secondTrack], downloadStates: [ makeDownloadedState(for: firstTrack), makeDownloadedState(for: secondTrack), ], favoriteTrackStore: favoriteStore, audioFiles: [ localFilePath(for: firstTrack): Data([0x1]), localFilePath(for: secondTrack): Data([0x2]), ] ) await viewModel.loadIfNeeded() await viewModel.toggleFavorite(trackID: firstTrack.trackId) await viewModel.toggleFavorite(trackID: secondTrack.trackId) let savedIDs = try await favoriteStore.loadFavoriteTracks() .map(\.remoteTrackId) .sorted() XCTAssertEqual(savedIDs, [firstTrack.trackId, secondTrack.trackId].sorted()) XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: firstTrack.trackId)).isFavorite) XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite) } func testToggleFavoriteRepeatedlyLeavesSingleStableRecord() async throws { let track = makeRemoteTrack( trackId: "remote-repeat", assetId: "asset-repeat", title: "Repeat Toggle" ) let favoriteStore = InMemoryFavoriteTrackStore() let viewModel = makeViewModel( remoteTracks: [track], downloadStates: [makeDownloadedState(for: track)], favoriteTrackStore: favoriteStore, audioFiles: [localFilePath(for: track): Data([0x3])] ) await viewModel.loadIfNeeded() for _ in 0..<5 { await viewModel.toggleFavorite(trackID: track.trackId) } XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite) let savedFavorites = try await favoriteStore.loadFavoriteTracks() XCTAssertEqual(savedFavorites.count, 1) } func testFavoriteStateDerivesCorrectlyInRemoteLibrary() async throws { let track = makeRemoteTrack( trackId: "remote-only", assetId: "asset-only", title: "Remote Only" ) let viewModel = makeViewModel( remoteTracks: [track], favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [ FavoriteTrackRecord( remoteTrackId: track.trackId, favoritedAt: Date(timeIntervalSince1970: 4_000) ), ]) ) await viewModel.loadIfNeeded() XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite) XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty) } func testFavoriteStateDerivesCorrectlyInOfflineLibrary() async throws { let track = makeRemoteTrack( trackId: "offline-favorite", assetId: "asset-offline-favorite", title: "Offline Favorite" ) let viewModel = makeViewModel( remoteTracks: [track], downloadStates: [makeDownloadedState(for: track)], favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [ FavoriteTrackRecord( remoteTrackId: track.trackId, favoritedAt: Date(timeIntervalSince1970: 5_000) ), ]), audioFiles: [localFilePath(for: track): Data([0x4])] ) await viewModel.loadIfNeeded() XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite) } func testSearchDoesNotModifyFavoriteState() async throws { let favoriteTrack = makeRemoteTrack( trackId: "remote-search-favorite", assetId: "asset-search-favorite", title: "Northern Lights" ) let otherTrack = makeRemoteTrack( trackId: "remote-search-other", assetId: "asset-search-other", title: "Quiet Harbor" ) let favoriteStore = InMemoryFavoriteTrackStore() let viewModel = makeViewModel( remoteTracks: [favoriteTrack, otherTrack], downloadStates: [ makeDownloadedState(for: favoriteTrack), makeDownloadedState(for: otherTrack), ], favoriteTrackStore: favoriteStore, audioFiles: [ localFilePath(for: favoriteTrack): Data([0x5]), localFilePath(for: otherTrack): Data([0x6]), ] ) await viewModel.loadIfNeeded() await viewModel.toggleFavorite(trackID: favoriteTrack.trackId) viewModel.searchText = otherTrack.title XCTAssertNil(remoteRow(in: viewModel, trackID: favoriteTrack.trackId)) viewModel.searchText = favoriteTrack.title XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: favoriteTrack.trackId)).isFavorite) let savedFavoriteIDs = try await favoriteStore.loadFavoriteTracks().map(\.remoteTrackId) XCTAssertEqual(savedFavoriteIDs, [favoriteTrack.trackId]) } func testMissingFileStateDoesNotRemoveFavorite() async throws { let track = makeRemoteTrack( trackId: "remote-missing", assetId: "asset-missing", title: "Missing Favorite" ) let viewModel = makeViewModel( remoteTracks: [track], downloadStates: [makeDownloadedState(for: track)], favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [ FavoriteTrackRecord( remoteTrackId: track.trackId, favoritedAt: Date(timeIntervalSince1970: 6_000) ), ]) ) await viewModel.loadIfNeeded() let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)) XCTAssertEqual(remoteTrack.status, .missing) XCTAssertTrue(remoteTrack.isFavorite) XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty) } } @MainActor private final class TestPlayer: iPhoneLocalAudioPlaying { var onStateChange: ((iPhoneNowPlayingState) -> Void)? private(set) var state = iPhoneNowPlayingState( trackID: nil, title: nil, artist: nil, isPlaying: false, errorMessage: nil ) func play( trackID: String, title: String, artist: String, fileURL: URL ) throws { _ = fileURL state = iPhoneNowPlayingState( trackID: trackID, title: title, artist: artist, isPlaying: true, errorMessage: nil ) onStateChange?(state) } func resume() throws { state.isPlaying = true state.errorMessage = nil onStateChange?(state) } func pause() { state.isPlaying = false onStateChange?(state) } } private actor TestRemoteLibraryRepository: RemoteLibraryRepository { private let tracks: [RemoteTrack] init(tracks: [RemoteTrack]) { self.tracks = tracks } func loadCachedRemoteTracks() async throws -> [RemoteTrack] { tracks } func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] { _ = deviceId return tracks } func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data { _ = assetId _ = deviceId throw TestRepositoryError.unexpectedDownload } func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data { _ = artworkId _ = deviceId throw TestRepositoryError.unexpectedDownload } } private enum TestRepositoryError: Error { case unexpectedDownload } @MainActor private func makeViewModel( remoteTracks: [RemoteTrack], downloadStates: [RemoteTrackDownloadState] = [], favoriteTrackStore: any FavoriteTrackStore = InMemoryFavoriteTrackStore(), audioFiles: [String: Data] = [:], player: (any iPhoneLocalAudioPlaying)? = nil ) -> iPhoneLibraryViewModel { let repository = TestRemoteLibraryRepository(tracks: remoteTracks) let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: downloadStates) let audioFileStore = InMemoryOfflineAudioFileStore(files: audioFiles) let artworkStore = InMemoryArtworkStore() let syncService = RemoteLibrarySyncService( repository: repository, downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, artworkStore: artworkStore ) let offlineLibraryService = OfflineLibraryService( syncService: syncService, audioFileStore: audioFileStore, artworkStore: artworkStore ) return iPhoneLibraryViewModel( environment: ServerEnvironment( baseURL: ServerEnvironment.defaultLocalBaseURL, appVersion: "Tests" ), apiClient: URLSessionVelodyAPIClient( environment: ServerEnvironment( baseURL: ServerEnvironment.defaultLocalBaseURL, appVersion: "Tests" ) ), syncService: syncService, offlineLibraryService: offlineLibraryService, favoriteTrackStore: favoriteTrackStore, player: player ?? TestPlayer(), keychainService: MemoryKeychainService() ) } private func makeRemoteTrack( trackId: String, assetId: String, title: String ) -> RemoteTrack { RemoteTrack( trackId: trackId, title: title, artist: "Velody Artist", durationSeconds: 245, sha256: String(repeating: "a", count: 64), assetId: assetId, createdAt: "2026-05-30T08:00:00.000Z", updatedAt: "2026-05-30T08:05:00.000Z" ) } private func makeDownloadedState(for track: RemoteTrack) -> RemoteTrackDownloadState { RemoteTrackDownloadState( remoteTrackId: track.trackId, assetId: track.assetId, localFilePath: localFilePath(for: track), downloadedAt: Date(timeIntervalSince1970: 1_000), downloadStatus: .downloaded ) } private func localFilePath(for track: RemoteTrack) -> String { "/in-memory/\(track.assetId).mp3" } @MainActor private func remoteRow( in viewModel: iPhoneLibraryViewModel, trackID: String ) -> RemoteTrackRowViewData? { viewModel.remoteTracks.first(where: { $0.id == trackID }) } @MainActor private func offlineRow( in viewModel: iPhoneLibraryViewModel, trackID: String ) -> AvailableOfflineTrackRowViewData? { viewModel.availableOfflineTracks.first(where: { $0.id == trackID }) }