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