velody/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift
2026-06-09 12:05:15 +02:00

653 lines
22 KiB
Swift

import Foundation
import XCTest
import VelodyDomain
import VelodyNetworking
import VelodyPlayback
import VelodyPersistence
@testable import VelodyiPhone
@MainActor
final class iPhoneLibraryViewModelPlaybackTests: XCTestCase {
func testNowPlayingArtworkHeightStaysWithinRequestedRange() {
let contentWidths: [CGFloat] = [280, 343, 361, 398, 520]
for width in contentWidths {
let artworkHeight = NowPlayingCardLayoutMetrics.artworkHeight(for: width)
XCTAssertGreaterThanOrEqual(artworkHeight, 140)
XCTAssertLessThanOrEqual(artworkHeight, 180)
}
XCTAssertEqual(NowPlayingCardLayoutMetrics.artworkHeight(for: 280), 148)
XCTAssertEqual(NowPlayingCardLayoutMetrics.artworkHeight(for: 520), 176)
}
func testNowPlayingCardShowsTitleArtistAndArtwork() async throws {
let artwork = RemoteArtwork(
artworkId: "artwork-midnight",
sha256: String(repeating: "b", count: 64),
mimeType: "image/png"
)
let track = makeRemoteTrack(
trackId: "remote-midnight-city",
assetId: "asset-midnight-city",
title: "Midnight City",
artwork: artwork
)
let player = TestPlayer()
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1, 0x2])],
artworkPayloadsByArtworkID: [artwork.artworkId: Data([0x9, 0x8])],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
let card = try XCTUnwrap(viewModel.nowPlayingCard)
XCTAssertEqual(card.title, track.title)
XCTAssertEqual(card.artist, track.artist)
XCTAssertEqual(card.artworkLocalFilePath, localArtworkFilePath(for: artwork))
XCTAssertEqual(card.downloadBadge, .downloaded)
XCTAssertEqual(card.playbackStateText, "Playing")
}
func testMissingLocalFilePreventsPlayingAndDisablesSeek() async throws {
let track = makeRemoteTrack(
trackId: "remote-missing-file",
assetId: "asset-missing-file",
title: "Lost Signal"
)
let player = TestPlayer()
player.missingTrackIDs.insert(track.trackId)
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
let card = try XCTUnwrap(viewModel.nowPlayingCard)
XCTAssertEqual(viewModel.nowPlaying.playbackState, .missingFile)
XCTAssertFalse(viewModel.nowPlaying.isPlaying)
XCTAssertFalse(card.canSeek)
XCTAssertEqual(card.downloadBadge, .missing)
XCTAssertEqual(card.title, track.title)
XCTAssertEqual(card.artist, track.artist)
XCTAssertEqual(card.playbackStateText, "Missing file")
XCTAssertEqual(
card.errorMessage,
"This downloaded file is missing. Re-download the track to play it again."
)
}
func testPauseStopsProgressUpdates() async throws {
let track = makeRemoteTrack(
trackId: "remote-pause",
assetId: "asset-pause",
title: "Pause Study"
)
let player = TestPlayer()
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1])],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
player.advanceProgress(by: 12)
XCTAssertEqual(viewModel.nowPlaying.currentTime, 12)
viewModel.togglePlayback(trackID: track.trackId)
player.advanceProgress(by: 8)
XCTAssertEqual(viewModel.nowPlaying.playbackState, .paused)
XCTAssertEqual(viewModel.nowPlaying.currentTime, 12)
}
func testStopResetsProgressSafely() async throws {
let track = makeRemoteTrack(
trackId: "remote-stop",
assetId: "asset-stop",
title: "Stop Motion"
)
let player = TestPlayer()
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1])],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
player.advanceProgress(by: 33)
viewModel.stopPlayback()
XCTAssertEqual(viewModel.nowPlaying.playbackState, .stopped)
XCTAssertEqual(viewModel.nowPlaying.currentTime, 0)
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
}
func testSearchDoesNotClearNowPlaying() async throws {
let firstTrack = makeRemoteTrack(
trackId: "remote-first-search",
assetId: "asset-first-search",
title: "Blue Avenue"
)
let secondTrack = makeRemoteTrack(
trackId: "remote-second-search",
assetId: "asset-second-search",
title: "Golden Hour"
)
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 = secondTrack.title
let card = try XCTUnwrap(viewModel.nowPlayingCard)
XCTAssertEqual(card.trackID, firstTrack.trackId)
XCTAssertNil(remoteRow(in: viewModel, trackID: firstTrack.trackId))
XCTAssertEqual(viewModel.remoteTracks.count, 1)
}
func testPlaybackErrorDoesNotLeaveStateAsPlaying() async throws {
let track = makeRemoteTrack(
trackId: "remote-failure",
assetId: "asset-failure",
title: "Hard Stop"
)
let player = TestPlayer()
player.failingTrackIDs.insert(track.trackId)
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1])],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
XCTAssertEqual(viewModel.nowPlaying.playbackState, .failed)
XCTAssertFalse(viewModel.nowPlaying.isPlaying)
XCTAssertEqual(viewModel.nowPlaying.errorMessage, "Playback could not be started.")
}
func testSeekUpdatesCurrentTimeWhenTrackIsLoaded() async throws {
let track = makeRemoteTrack(
trackId: "remote-seek",
assetId: "asset-seek",
title: "Seeklight"
)
let player = TestPlayer()
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1])],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
viewModel.seekPlayback(to: 90)
XCTAssertEqual(viewModel.nowPlaying.currentTime, 90)
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
}
func testRelaunchRestoresMetadataButDoesNotAutoplay() {
let suiteName = "de.diyaa.velody.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let sessionStore = iPhonePlaybackSessionStore(
userDefaults: defaults,
storageKey: "playback"
)
let track = LibraryTrack(
id: "remote-restore",
title: "Restore Point",
artist: "Velody Artist",
durationSeconds: 245,
localFilePath: "/in-memory/asset-restore.mp3",
remoteTrackId: "remote-restore"
)
let firstEngine = FakePlaybackEngine()
firstEngine.durationByPath[track.localFilePath] = 245
let firstPlayer = iPhonePlaybackControllerPlayer(
controller: PlaybackController(
engine: firstEngine,
sessionStore: sessionStore
)
)
firstPlayer.setCatalogTracks([track])
firstPlayer.play(trackID: track.id)
firstEngine.currentTime = 48
firstPlayer.pause()
let storedSession = sessionStore.loadSession()
XCTAssertEqual(storedSession?.currentTrackID, track.id)
XCTAssertEqual(storedSession?.currentTime, 48)
XCTAssertEqual(storedSession?.queueTrackIDs, [])
let secondEngine = FakePlaybackEngine()
secondEngine.durationByPath[track.localFilePath] = 245
let relaunchedPlayer = iPhonePlaybackControllerPlayer(
controller: PlaybackController(
engine: secondEngine,
sessionStore: sessionStore
)
)
relaunchedPlayer.setCatalogTracks([track])
XCTAssertEqual(relaunchedPlayer.state.trackID, track.id)
XCTAssertEqual(relaunchedPlayer.state.title, track.title)
XCTAssertEqual(relaunchedPlayer.state.artist, track.artist)
XCTAssertEqual(relaunchedPlayer.state.currentTime, 48)
XCTAssertEqual(relaunchedPlayer.state.playbackState, .paused)
XCTAssertFalse(relaunchedPlayer.state.isPlaying)
XCTAssertNil(relaunchedPlayer.state.errorMessage)
}
}
@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)")
}
}
@MainActor
final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
func testRefreshSyncStoresDeviceAccessTokenWhenRegistrationRuns() async throws {
let keychain = MemoryKeychainService()
let counter = RegisterCallCounter()
let viewModel = makeViewModel(
remoteTracks: [
makeRemoteTrack(
trackId: "remote-auth-store",
assetId: "asset-auth-store",
title: "Store Token"
),
],
apiClient: TestRegisterAPIClient(counter: counter),
keychainService: keychain
)
await viewModel.loadIfNeeded()
await viewModel.refreshSync()
let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id")
let storedDeviceAccessToken = try await keychain.loadValue(
forKey: "velody.iphone.device-access-token"
)
XCTAssertEqual(await counter.count, 1)
XCTAssertFalse((storedDeviceID ?? "").isEmpty)
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
}
func testExistingDeviceWithoutAccessTokenReregistersCleanly() async throws {
let keychain = MemoryKeychainService()
let legacyDeviceID = "legacy-device-id"
try await keychain.save(legacyDeviceID, forKey: "velody.iphone.device-id")
try await keychain.save(
"legacy-bootstrap-token",
forKey: "velody.iphone.bootstrap-token"
)
let counter = RegisterCallCounter()
let viewModel = makeViewModel(
remoteTracks: [
makeRemoteTrack(
trackId: "remote-auth-reregister",
assetId: "asset-auth-reregister",
title: "Re-register Token"
),
],
apiClient: TestRegisterAPIClient(counter: counter),
keychainService: keychain
)
await viewModel.loadIfNeeded()
await viewModel.refreshSync()
let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id")
let storedDeviceAccessToken = try await keychain.loadValue(
forKey: "velody.iphone.device-access-token"
)
XCTAssertEqual(await counter.count, 1)
XCTAssertNotEqual(storedDeviceID, legacyDeviceID)
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
}
}
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)?
var currentTime: Double = 0
var duration: Double = 0
var isPlaying = false
var durationByPath: [String: Double] = [:]
func loadTrack(at fileURL: URL, startTime: Double) throws {
currentTime = startTime
duration = durationByPath[fileURL.path] ?? 0
isPlaying = false
}
func play() throws {
isPlaying = true
}
func pause() {
isPlaying = false
}
func stop() {
isPlaying = false
currentTime = 0
}
func seek(to time: Double) throws {
currentTime = min(max(time, 0), duration)
}
}