velody/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift
2026-06-02 08:59:44 +02:00

350 lines
9.7 KiB
Swift

import Foundation
import VelodyDomain
import VelodyNetworking
import VelodyPersistence
import VelodySync
import VelodyUtilities
@testable import VelodyiPhone
@MainActor
final class TestPlayer: iPhoneLocalAudioPlaying {
var onStateChange: ((iPhoneNowPlayingState) -> Void)?
private(set) var state: iPhoneNowPlayingState = .empty
var failingTrackIDs = Set<String>()
var missingTrackIDs = Set<String>()
private var catalogTracksByID: [String: LibraryTrack] = [:]
func setCatalogTracks(_ tracks: [LibraryTrack]) {
catalogTracksByID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) })
guard let trackID = state.trackID,
let currentTrack = catalogTracksByID[trackID]
else {
return
}
state = Self.makeState(
for: currentTrack,
playbackState: state.playbackState,
currentTime: state.currentTime,
errorMessage: state.errorMessage
)
onStateChange?(state)
}
func play(trackID: String) {
guard let track = catalogTracksByID[trackID] else {
return
}
let currentTime = state.trackID == trackID ? state.currentTime : 0
if missingTrackIDs.contains(trackID) ||
track.localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
state = Self.makeState(
for: track,
playbackState: .missingFile,
currentTime: currentTime,
errorMessage: "The local file could not be found: \(track.localFilePath)"
)
} else if failingTrackIDs.contains(trackID) {
state = Self.makeState(
for: track,
playbackState: .failed,
currentTime: currentTime,
errorMessage: "Playback could not be started."
)
} else {
state = Self.makeState(
for: track,
playbackState: .playing,
currentTime: currentTime,
errorMessage: nil
)
}
onStateChange?(state)
}
func pause() {
guard let currentTrack else {
return
}
state = Self.makeState(
for: currentTrack,
playbackState: .paused,
currentTime: state.currentTime,
errorMessage: nil
)
onStateChange?(state)
}
func stop() {
guard let currentTrack else {
state = .empty
onStateChange?(state)
return
}
state = Self.makeState(
for: currentTrack,
playbackState: .stopped,
currentTime: 0,
errorMessage: nil
)
onStateChange?(state)
}
func seek(to time: Double) {
guard let currentTrack else {
return
}
state = Self.makeState(
for: currentTrack,
playbackState: state.playbackState,
currentTime: time,
errorMessage: state.errorMessage
)
onStateChange?(state)
}
func advanceProgress(by timeDelta: Double) {
guard state.isPlaying,
let currentTrack
else {
return
}
let duration = max(currentTrack.durationSeconds ?? state.duration, 0)
let updatedTime = min(state.currentTime + timeDelta, duration)
let playbackState: iPhonePlaybackState
if duration > 0, updatedTime >= duration {
playbackState = .stopped
} else {
playbackState = .playing
}
state = Self.makeState(
for: currentTrack,
playbackState: playbackState,
currentTime: updatedTime,
errorMessage: nil
)
onStateChange?(state)
}
private var currentTrack: LibraryTrack? {
guard let trackID = state.trackID else {
return nil
}
return catalogTracksByID[trackID]
}
private static func makeState(
for track: LibraryTrack,
playbackState: iPhonePlaybackState,
currentTime: Double,
errorMessage: String?
) -> iPhoneNowPlayingState {
let duration = max(track.durationSeconds ?? 0, 0)
let clampedTime: Double
if duration > 0 {
clampedTime = min(max(currentTime, 0), duration)
} else {
clampedTime = max(currentTime, 0)
}
return iPhoneNowPlayingState(
trackID: track.id,
title: track.title,
artist: track.artist,
playbackState: playbackState,
currentTime: clampedTime,
duration: duration,
errorMessage: errorMessage
)
}
}
private actor TestRemoteLibraryRepository: RemoteLibraryRepository {
private let tracks: [RemoteTrack]
init(tracks: [RemoteTrack]) {
self.tracks = tracks
}
func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
tracks
}
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
_ = deviceId
return tracks
}
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data {
_ = assetId
_ = deviceId
throw TestRepositoryError.unexpectedDownload
}
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data {
_ = artworkId
_ = deviceId
throw TestRepositoryError.unexpectedDownload
}
}
private enum TestRepositoryError: Error {
case unexpectedDownload
}
@MainActor
func makeViewModel(
remoteTracks: [RemoteTrack],
downloadStates: [RemoteTrackDownloadState] = [],
favoriteTrackStore: any FavoriteTrackStore = InMemoryFavoriteTrackStore(),
audioFiles: [String: Data] = [:],
artworkPayloadsByArtworkID: [String: Data] = [:],
player: (any iPhoneLocalAudioPlaying)? = nil,
apiClient: (any VelodyAPIClient)? = nil,
keychainService: any KeychainService = MemoryKeychainService()
) -> iPhoneLibraryViewModel {
let repository = TestRemoteLibraryRepository(tracks: remoteTracks)
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: downloadStates)
let audioFileStore = InMemoryOfflineAudioFileStore(files: audioFiles)
let artworkStore = InMemoryArtworkStore(
files: makeArtworkFiles(
from: remoteTracks,
artworkPayloadsByArtworkID: artworkPayloadsByArtworkID
)
)
let syncService = RemoteLibrarySyncService(
repository: repository,
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
let offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
return iPhoneLibraryViewModel(
environment: ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "Tests"
),
apiClient: apiClient ?? URLSessionVelodyAPIClient(
environment: ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "Tests"
)
),
syncService: syncService,
offlineLibraryService: offlineLibraryService,
favoriteTrackStore: favoriteTrackStore,
player: player ?? TestPlayer(),
keychainService: keychainService
)
}
func makeRemoteTrack(
trackId: String,
assetId: String,
title: String,
artwork: RemoteArtwork? = nil
) -> RemoteTrack {
RemoteTrack(
trackId: trackId,
title: title,
artist: "Velody Artist",
durationSeconds: 245,
sha256: String(repeating: "a", count: 64),
assetId: assetId,
createdAt: "2026-05-30T08:00:00.000Z",
updatedAt: "2026-05-30T08:05:00.000Z",
artwork: artwork
)
}
func makeDownloadedState(for track: RemoteTrack) -> RemoteTrackDownloadState {
RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: localFilePath(for: track),
downloadedAt: Date(timeIntervalSince1970: 1_000),
downloadStatus: .downloaded
)
}
func localFilePath(for track: RemoteTrack) -> String {
"/in-memory/\(track.assetId).mp3"
}
func localArtworkFilePath(for artwork: RemoteArtwork) -> String {
let fileExtension: String
switch artwork.mimeType.lowercased() {
case "image/jpeg", "image/jpg":
fileExtension = "jpg"
case "image/png":
fileExtension = "png"
case "image/webp":
fileExtension = "webp"
case "image/heic":
fileExtension = "heic"
case "image/heif":
fileExtension = "heif"
case "image/gif":
fileExtension = "gif"
default:
fileExtension = "img"
}
return "/in-memory/\(artwork.artworkId).\(fileExtension)"
}
@MainActor
func remoteRow(
in viewModel: iPhoneLibraryViewModel,
trackID: String
) -> RemoteTrackRowViewData? {
viewModel.remoteTracks.first(where: { $0.id == trackID })
}
@MainActor
func offlineRow(
in viewModel: iPhoneLibraryViewModel,
trackID: String
) -> AvailableOfflineTrackRowViewData? {
viewModel.availableOfflineTracks.first(where: { $0.id == trackID })
}
private func makeArtworkFiles(
from remoteTracks: [RemoteTrack],
artworkPayloadsByArtworkID: [String: Data]
) -> [String: Data] {
var files: [String: Data] = [:]
for remoteTrack in remoteTracks {
guard let artwork = remoteTrack.artwork,
let data = artworkPayloadsByArtworkID[artwork.artworkId]
else {
continue
}
files[localArtworkFilePath(for: artwork)] = data
}
return files
}