356 lines
14 KiB
Swift
356 lines
14 KiB
Swift
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 testFavoritesRemainIntactAcrossLibrarySync() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-sync-favorite",
|
|
assetId: "asset-sync-favorite",
|
|
title: "Sync Favorite"
|
|
)
|
|
let favoriteStore = InMemoryFavoriteTrackStore()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
favoriteTrackStore: favoriteStore,
|
|
audioFiles: [localFilePath(for: track): Data([0x5])],
|
|
apiClient: StubVelodyAPIClient(
|
|
environment: ServerEnvironment(
|
|
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
|
appVersion: "Tests"
|
|
)
|
|
)
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
await viewModel.toggleFavorite(trackID: track.trackId)
|
|
await viewModel.refreshSync()
|
|
|
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
|
|
XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite)
|
|
let savedFavorites = try await favoriteStore.loadFavoriteTracks()
|
|
XCTAssertEqual(savedFavorites.map(\.remoteTrackId), [track.trackId])
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func testFailedDownloadKeepsFavoriteAndShowsRetry() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-interrupted-favorite",
|
|
assetId: "asset-interrupted-favorite",
|
|
title: "Interrupted Favorite"
|
|
)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [
|
|
RemoteTrackDownloadState(
|
|
remoteTrackId: track.trackId,
|
|
assetId: track.assetId,
|
|
localFilePath: "",
|
|
downloadedAt: nil,
|
|
downloadStatus: .failed,
|
|
lastDownloadError: "The previous download did not finish. Try again."
|
|
),
|
|
],
|
|
favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [
|
|
FavoriteTrackRecord(
|
|
remoteTrackId: track.trackId,
|
|
favoritedAt: Date(timeIntervalSince1970: 7_000)
|
|
),
|
|
])
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
|
|
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
|
|
|
|
XCTAssertEqual(remoteTrack.status, .failed)
|
|
XCTAssertEqual(remoteTrack.downloadButtonTitle, "Retry")
|
|
XCTAssertTrue(remoteTrack.canDownload)
|
|
XCTAssertTrue(remoteTrack.isFavorite)
|
|
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
|
|
}
|
|
}
|