Polish iPhone library layout

This commit is contained in:
diyaa
2026-06-02 08:59:44 +02:00
parent a147e98b21
commit a4f83ef151
4 changed files with 944 additions and 319 deletions
@@ -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)?