Files
suixinkan_ios_new/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift
汉秋 d7fec35715 移除 Core 层 OTG 相机实现,保留旅拍有线传图 UI 与本地照片上传能力。
将本地存储与上传编排下沉到 Features/TravelAlbum,删除 USB/PTP 相关代码与测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 10:55:40 +08:00

545 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// WiredCameraTransferViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/30.
//
import XCTest
import UIKit
@testable import suixinkan
@MainActor
/// 线 ViewModel
final class WiredCameraTransferViewModelTests: XCTestCase {
/// start detach UI
func testStartCancellationDetachesFromUI() async {
let context = WiredTransferContext(
albumId: 6603,
albumName: "测试相册",
phone: "",
orderNumber: ""
)
let viewModel = WiredCameraTransferViewModel(context: context)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
let startTask = Task {
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
}
try? await Task.sleep(nanoseconds: 100_000_000)
startTask.cancel()
try? await Task.sleep(nanoseconds: 200_000_000)
XCTAssertEqual(viewModel.connectionState, .disconnected)
}
///
func testBatchUploadButtonThreeStateFlow() async {
let viewModel = makeViewModelWithPendingPhotos()
let api = MockTravelAlbumAPIForWiredTransfer()
let oss = WiredTransferMockOSSUploadService()
XCTAssertFalse(viewModel.selectUploadMode)
await viewModel.onBatchUploadButtonClick(api: api, ossService: oss, scenicID: 1)
XCTAssertTrue(viewModel.selectUploadMode)
XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty)
viewModel.togglePhotoSelection(id: "pending-1")
XCTAssertEqual(viewModel.selectedPhotoIDs, ["pending-1"])
await viewModel.onBatchUploadButtonClick(api: api, ossService: oss, scenicID: 1)
XCTAssertFalse(viewModel.selectUploadMode)
XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty)
}
/// pending
func testToggleSelectAllPendingInVisible() {
let viewModel = makeViewModelWithPendingPhotos()
viewModel.selectUploadMode = true
viewModel.toggleSelectAllPendingInVisible()
XCTAssertEqual(viewModel.selectedPhotoIDs, ["pending-1", "pending-2"])
viewModel.toggleSelectAllPendingInVisible()
XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty)
}
///
func testUploadAllTodayCapturedFiltersByDate() async {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.locale = Locale(identifier: "zh_CN")
let today = formatter.string(from: Date())
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "")
)
viewModel.replacePhotos([
makePendingPhoto(id: "today", capturedAt: today),
makePendingPhoto(id: "old", capturedAt: "2020-01-01 09:00:00"),
])
await viewModel.onSpecifyUploadOptionSelected(
WiredCameraTransferViewModel.specifyUploadTodayCaptured,
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1
)
XCTAssertNil(viewModel.errorMessage)
}
///
func testRetryPhotoInPostShootModeUploadsPersistedFile() async throws {
let context = WiredTransferContext(
albumId: 1,
albumName: "测试相册",
phone: "",
orderNumber: ""
)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
let scope = downloadScope(account: account, albumID: context.albumId)
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_failed.JPG", scope: scope)
try Data(repeating: 0xCD, count: 32).write(to: fileURL)
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
let photoID = "persist_failed_001"
let record = WiredTransferPhotoRecord(
id: photoID,
sourceId: photoID,
fileName: "retry_failed.JPG",
localPath: CameraDownloadStorage.relativePath(for: fileURL),
thumbnailPath: "",
capturedAt: "2026-05-20 12:05:18",
fileSizeBytes: 32,
status: WiredTransferUploadStatus.failed.rawValue,
progress: 0,
errorMessage: "upload failed",
albumId: 1,
userId: "101",
remoteURL: "",
updatedAt: Date().timeIntervalSince1970
)
let store = WiredTransferPhotoStore(
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
userIDProvider: { "101" }
)
store.save(albumID: 1, records: [record])
let viewModel = WiredCameraTransferViewModel(context: context)
viewModel.transferModeOption = WiredCameraTransferViewModel.modePostShootTransfer
let api = MockTravelAlbumAPIForWiredTransfer()
let oss = WiredTransferMockOSSUploadService()
await viewModel.start(api: api, ossService: oss, scenicID: 1, accountContext: account)
XCTAssertEqual(viewModel.photos.first?.id, photoID)
XCTAssertEqual(viewModel.photos.first?.status, .failed)
await viewModel.retryPhoto(id: photoID, api: api, ossService: oss, scenicID: 1)
try await Task.sleep(nanoseconds: 500_000_000)
XCTAssertEqual(viewModel.photos.first?.status, .uploaded)
}
///
func testBatchUploadAllUploadsPersistedPendingFiles() async throws {
let albumID = 7711
let userID = "batch-persist-user"
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: userID, displayName: "测试"))
let fileURLs = try (1 ... 5).map { index in
let scope = downloadScope(account: account, albumID: albumID)
let url = CameraDownloadStorage.uniqueLocalURL(for: "batch_persist_\(index).JPG", scope: scope)
try Data(repeating: UInt8(0xA0 + index), count: 32).write(to: url)
return url
}
defer {
CameraDownloadStorage.removeScopeDirectory(for: downloadScope(account: account, albumID: albumID))
}
let records = fileURLs.enumerated().map { offset, url in
let index = offset + 1
return makePhotoRecord(
id: "batch_persist_\(index)",
fileName: "batch_persist_\(index).JPG",
localPath: CameraDownloadStorage.relativePath(for: url),
albumID: albumID,
userID: userID
)
}
let store = WiredTransferPhotoStore(
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
userIDProvider: { userID }
)
store.save(albumID: albumID, records: records)
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: albumID, albumName: "批量上传", phone: "", orderNumber: "")
)
let oss = WiredTransferMockOSSUploadService()
oss.uploadDelayNanoseconds = 150_000_000
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: oss,
scenicID: 1,
accountContext: account
)
XCTAssertEqual(viewModel.photos.filter(\.isNotUploaded).count, 5)
await viewModel.batchUploadAll(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: oss,
scenicID: 1
)
XCTAssertEqual(Set(oss.uploadedFileNames), Set((1 ... 5).map { "batch_persist_\($0).JPG" }))
XCTAssertGreaterThan(oss.maxActiveUploads, 1)
XCTAssertLessThanOrEqual(oss.maxActiveUploads, 3)
XCTAssertTrue(viewModel.photos.allSatisfy { $0.status == .uploaded })
}
///
func testSelectTransferModePersistsOption() async {
let account = makeTransferModeTestAccount()
let store = makeTransferModeTestStore(account: account)
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "")
)
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
viewModel.selectTransferMode(
WiredCameraTransferViewModel.modePostShootTransfer,
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1
)
XCTAssertEqual(store.loadTransferModeOption(), WiredCameraTransferViewModel.modePostShootTransfer)
}
///
func testStartRestoresCachedTransferModeOption() async {
let account = makeTransferModeTestAccount()
let store = makeTransferModeTestStore(account: account)
store.saveTransferModeOption(WiredCameraTransferViewModel.modePostShootTransfer)
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "")
)
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
XCTAssertEqual(viewModel.transferModeOption, WiredCameraTransferViewModel.modePostShootTransfer)
}
///
func testLegacyRecordWithoutThumbnailKeepsPreviewURL() async throws {
let albumID = 6602
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "legacy-user", displayName: "测试"))
let scope = downloadScope(account: account, albumID: albumID)
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "legacy_only.JPG", scope: scope)
try makeJPEGData().write(to: fileURL)
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
let record = WiredTransferPhotoRecord(
id: "legacy_photo_001",
sourceId: "legacy_photo_001",
fileName: "legacy_only.JPG",
localPath: CameraDownloadStorage.relativePath(for: fileURL),
thumbnailPath: "",
capturedAt: "2026-05-20 12:05:18",
fileSizeBytes: 128,
status: WiredTransferUploadStatus.pending.rawValue,
progress: 0,
errorMessage: nil,
albumId: albumID,
userId: "legacy-user",
remoteURL: "",
updatedAt: Date().timeIntervalSince1970
)
let store = WiredTransferPhotoStore(
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
userIDProvider: { "legacy-user" }
)
store.save(albumID: albumID, records: [record])
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: albumID, albumName: "旧记录", phone: "", orderNumber: "")
)
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let photo = try XCTUnwrap(viewModel.photos.first)
XCTAssertEqual(photo.thumbnailURL, "")
XCTAssertFalse(photo.previewURL.isEmpty)
}
///
func testClearAlbumRemovesOnlyCurrentAlbumScopedDirectory() throws {
let userID = "clear-scope-user"
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: userID, displayName: "测试"), accountType: "store")
let albumID = 8801
let otherAlbumID = 8802
let scope = downloadScope(account: account, albumID: albumID)
let otherScope = downloadScope(account: account, albumID: otherAlbumID)
let scopeDirectoryPath = CameraDownloadStorage.scopeDirectory(for: scope).path
let otherScopeDirectoryPath = CameraDownloadStorage.scopeDirectory(for: otherScope).path
defer {
CameraDownloadStorage.removeScopeDirectory(for: scope)
CameraDownloadStorage.removeScopeDirectory(for: otherScope)
}
let storeURL = CameraDownloadStorage.uniqueLocalURL(for: "store_photo.JPG", scope: scope)
let otherURL = CameraDownloadStorage.uniqueLocalURL(for: "other_photo.JPG", scope: otherScope)
try makeJPEGData().write(to: storeURL)
try makeJPEGData().write(to: otherURL)
let store = WiredTransferPhotoStore(
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
userIDProvider: { userID }
)
store.save(albumID: albumID, records: [
makePhotoRecord(
id: "store_photo",
fileName: "store_photo.JPG",
localPath: CameraDownloadStorage.relativePath(for: storeURL),
albumID: albumID,
userID: userID
)
])
store.clearAlbum(albumID: albumID)
XCTAssertFalse(FileManager.default.fileExists(atPath: scopeDirectoryPath))
XCTAssertTrue(FileManager.default.fileExists(atPath: otherScopeDirectoryPath))
}
///
func testSameUserIDDifferentAccountTypesAreIsolated() throws {
let userID = "same-user-id"
let albumID = 9901
let storeAccount = AccountContext()
storeAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "门店"), accountType: "store")
let photographerAccount = AccountContext()
photographerAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "摄影师"), accountType: "photographer")
let storeScope = downloadScope(account: storeAccount, albumID: albumID)
let photographerScope = downloadScope(account: photographerAccount, albumID: albumID)
defer {
CameraDownloadStorage.removeScopeDirectory(for: storeScope)
CameraDownloadStorage.removeScopeDirectory(for: photographerScope)
}
let storeURL = CameraDownloadStorage.uniqueLocalURL(for: "store_photo.JPG", scope: storeScope)
let photographerURL = CameraDownloadStorage.uniqueLocalURL(for: "photographer_photo.JPG", scope: photographerScope)
try makeJPEGData().write(to: storeURL)
try makeJPEGData().write(to: photographerURL)
let storeStore = WiredTransferPhotoStore(
accountPrefixProvider: { storeAccount.accountCachePrefix ?? "guest_" },
userIDProvider: { userID }
)
let photographerStore = WiredTransferPhotoStore(
accountPrefixProvider: { photographerAccount.accountCachePrefix ?? "guest_" },
userIDProvider: { userID }
)
storeStore.save(albumID: albumID, records: [
makePhotoRecord(
id: "store_photo",
fileName: "store_photo.JPG",
localPath: CameraDownloadStorage.relativePath(for: storeURL),
albumID: albumID,
userID: userID
)
])
photographerStore.save(albumID: albumID, records: [
makePhotoRecord(
id: "photographer_photo",
fileName: "photographer_photo.JPG",
localPath: CameraDownloadStorage.relativePath(for: photographerURL),
albumID: albumID,
userID: userID
)
])
XCTAssertEqual(storeStore.load(albumID: albumID).map(\.id), ["store_photo"])
XCTAssertEqual(photographerStore.load(albumID: albumID).map(\.id), ["photographer_photo"])
XCTAssertNotEqual(
CameraDownloadStorage.scopeDirectory(for: storeScope).path,
CameraDownloadStorage.scopeDirectory(for: photographerScope).path
)
}
private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel {
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "")
)
viewModel.replacePhotos([
makePendingPhoto(id: "pending-1", capturedAt: "2026-05-20 12:05:18"),
makePendingPhoto(id: "pending-2", capturedAt: "2026-05-20 12:18:06"),
WiredTransferPhotoItem(
id: "uploaded-1",
fileName: "uploaded.JPG",
thumbnailURL: "",
previewURL: "",
capturedAt: "2026-05-20 12:20:00",
fileSizeText: "1 MB",
status: .uploaded
),
])
return viewModel
}
private func makePendingPhoto(id: String, capturedAt: String) -> WiredTransferPhotoItem {
WiredTransferPhotoItem(
id: id,
fileName: "\(id).JPG",
thumbnailURL: "",
capturedAt: capturedAt,
fileSizeText: "1 MB",
status: .pending
)
}
private func makePhotoRecord(
id: String,
fileName: String,
localPath: String,
albumID: Int,
userID: String,
status: WiredTransferUploadStatus = .pending
) -> WiredTransferPhotoRecord {
WiredTransferPhotoRecord(
id: id,
sourceId: id,
fileName: fileName,
localPath: localPath,
thumbnailPath: "",
capturedAt: "2026-05-20 12:05:18",
fileSizeBytes: 32,
status: status.rawValue,
progress: 0,
errorMessage: nil,
albumId: albumID,
userId: userID,
remoteURL: "",
updatedAt: Date().timeIntervalSince1970
)
}
private func makeJPEGData() -> Data {
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8))
return renderer.jpegData(withCompressionQuality: 0.9) { context in
UIColor.systemBlue.setFill()
context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
}
}
private func downloadScope(account: AccountContext, albumID: Int) -> CameraDownloadStorage.Scope {
CameraDownloadStorage.Scope(accountKey: account.accountCachePrefix ?? "guest_", albumID: albumID)
}
}
@MainActor
private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing {
func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] }
func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
TravelAlbumCreateResponse(id: 1)
}
func albumList(page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumItem> {
ListPayload(total: 0, list: [])
}
func albumInfo(id: Int) async throws -> TravelAlbumItem { throw APIError.emptyData }
func editAlbum(_ request: TravelAlbumEditRequest) async throws {}
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {}
func materialList(
userEquityTravelId: Int,
page: Int,
pageSize: Int,
orderBy: Int,
isPurchased: Int?
) async throws -> ListPayload<TravelAlbumMaterial> {
ListPayload(total: 0, list: [])
}
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
let data = Data(
#"{"code":100000,"msg":"success","data":{"id":"11","user_equity_travel_id":"8","status":"1","order_number":"","user_id":"100","file_name":"DSC_001.JPG","file_type":"2","file_url":"https://cdn/a.jpg","file_size":"2048","cover_url":"","is_purchased":"0","created_at":"2026","updated_at":"2026"}}"#.utf8
)
let envelope = try JSONDecoder().decode(APIEnvelope<TravelAlbumMaterial>.self, from: data)
return try XCTUnwrap(envelope.data)
}
func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws {}
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
TravelAlbumMpCodeResponse(mpCodeOssURL: "")
}
}
@MainActor
private final class WiredTransferMockOSSUploadService: OSSUploadServing {
private(set) var uploadedFileNames: [String] = []
var uploadDelayNanoseconds: UInt64 = 0
private(set) var activeUploads = 0
private(set) var maxActiveUploads = 0
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
activeUploads += 1
maxActiveUploads = max(maxActiveUploads, activeUploads)
if uploadDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: uploadDelayNanoseconds)
}
activeUploads -= 1
uploadedFileNames.append(fileName)
onProgress(100)
return "https://cdn/mock/\(fileName)"
}
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
}
@MainActor
private func makeTransferModeTestAccount() -> AccountContext {
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "transfer-mode-user", displayName: "测试"))
return account
}
@MainActor
private func makeTransferModeTestStore(account: AccountContext) -> WiredTransferPhotoStore {
WiredTransferPhotoStore(
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
userIDProvider: { account.profile?.userId ?? "" }
)
}