Polish iPhone library layout
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
import VelodyDomain
|
||||
import VelodyNetworking
|
||||
import VelodyPlayback
|
||||
import VelodyPersistence
|
||||
@testable import VelodyiPhone
|
||||
@@ -64,7 +65,10 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase {
|
||||
XCTAssertEqual(card.title, track.title)
|
||||
XCTAssertEqual(card.artist, track.artist)
|
||||
XCTAssertEqual(card.playbackStateText, "Missing file")
|
||||
XCTAssertEqual(card.errorMessage, "The local file could not be found: \(localFilePath(for: track))")
|
||||
XCTAssertEqual(
|
||||
card.errorMessage,
|
||||
"This downloaded file is missing. Re-download the track to play it again."
|
||||
)
|
||||
}
|
||||
|
||||
func testPauseStopsProgressUpdates() async throws {
|
||||
@@ -257,6 +261,286 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
@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(viewModel.availableOfflineTracks.isEmpty)
|
||||
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (0)")
|
||||
}
|
||||
}
|
||||
|
||||
private actor RegisterCallCounter {
|
||||
private(set) var count = 0
|
||||
|
||||
func increment() {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
|
||||
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 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)?
|
||||
|
||||
Reference in New Issue
Block a user