Implement incremental sync and offline recovery
This commit is contained in:
@@ -476,13 +476,15 @@ final class iPhoneLibraryViewModel {
|
||||
}
|
||||
)
|
||||
let store = Self.makeRemoteLibraryStore()
|
||||
let syncCursorStore = Self.makeRemoteLibrarySyncCursorStore()
|
||||
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
|
||||
let audioFileStore = Self.makeOfflineAudioFileStore()
|
||||
let artworkStore = Self.makeArtworkStore()
|
||||
let favoriteTrackStore = Self.makeFavoriteTrackStore()
|
||||
let repository = DefaultRemoteLibraryRepository(
|
||||
apiClient: apiClient,
|
||||
store: store
|
||||
store: store,
|
||||
syncCursorStore: syncCursorStore
|
||||
)
|
||||
let syncService = RemoteLibrarySyncService(
|
||||
repository: repository,
|
||||
@@ -726,6 +728,14 @@ final class iPhoneLibraryViewModel {
|
||||
return InMemoryRemoteTrackDownloadStateStore()
|
||||
}
|
||||
|
||||
private static func makeRemoteLibrarySyncCursorStore() -> any RemoteLibrarySyncCursorStore {
|
||||
if let store = try? FileRemoteLibrarySyncCursorStore() {
|
||||
return store
|
||||
}
|
||||
|
||||
return InMemoryRemoteLibrarySyncCursorStore()
|
||||
}
|
||||
|
||||
private static func makeOfflineAudioFileStore() -> any OfflineAudioFileStore {
|
||||
if let store = try? FileOfflineAudioFileStore() {
|
||||
return store
|
||||
|
||||
@@ -153,6 +153,36 @@ final class iPhoneLibraryViewModelFavoritesTests: XCTestCase {
|
||||
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",
|
||||
@@ -285,4 +315,41 @@ final class iPhoneLibraryViewModelFavoritesTests: XCTestCase {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import VelodyDomain
|
||||
import VelodyNetworking
|
||||
import VelodyPlayback
|
||||
import VelodyPersistence
|
||||
import VelodyUtilities
|
||||
@testable import VelodyiPhone
|
||||
|
||||
@MainActor
|
||||
@@ -449,9 +450,35 @@ final class iPhoneLibraryViewModelPolishTests: XCTestCase {
|
||||
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
|
||||
@@ -479,7 +506,8 @@ final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
|
||||
forKey: "velody.iphone.device-access-token"
|
||||
)
|
||||
|
||||
XCTAssertEqual(await counter.count, 1)
|
||||
let registerCount = await counter.currentCount()
|
||||
XCTAssertEqual(registerCount, 1)
|
||||
XCTAssertFalse((storedDeviceID ?? "").isEmpty)
|
||||
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
|
||||
}
|
||||
@@ -513,7 +541,8 @@ final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
|
||||
forKey: "velody.iphone.device-access-token"
|
||||
)
|
||||
|
||||
XCTAssertEqual(await counter.count, 1)
|
||||
let registerCount = await counter.currentCount()
|
||||
XCTAssertEqual(registerCount, 1)
|
||||
XCTAssertNotEqual(storedDeviceID, legacyDeviceID)
|
||||
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
|
||||
}
|
||||
@@ -525,6 +554,10 @@ private actor RegisterCallCounter {
|
||||
func increment() {
|
||||
count += 1
|
||||
}
|
||||
|
||||
func currentCount() -> Int {
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestRegisterAPIClient: VelodyAPIClient {
|
||||
@@ -563,6 +596,12 @@ private struct TestRegisterAPIClient: VelodyAPIClient {
|
||||
try await stubClient.fetchSyncBootstrap()
|
||||
}
|
||||
|
||||
func fetchSyncChanges(
|
||||
cursor: SyncCursor
|
||||
) async throws -> SyncChangesResponse {
|
||||
try await stubClient.fetchSyncChanges(cursor: cursor)
|
||||
}
|
||||
|
||||
func fetchRemoteLibrary(
|
||||
deviceId: String
|
||||
) async throws -> RemoteLibraryResponseDTO {
|
||||
|
||||
Reference in New Issue
Block a user