// // WiredCameraTransferViewModelTests.swift // suixinkanTests // // Created by Codex on 2026/6/30. // import XCTest import UIKit @testable import suixinkan @MainActor /// 有线传图 ViewModel 测试。 final class WiredCameraTransferViewModelTests: XCTestCase { /// 测试相机管道回调的首张新照片应出现在列表中。 func testFirstNewPipelineTaskAppearsInPhotos() async throws { let camera = MockWiredCameraService() let context = WiredTransferContext( albumId: 6603, albumName: "测试相册", phone: "", orderNumber: "" ) let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) let account = AccountContext() account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) await viewModel.start( api: MockTravelAlbumAPIForWiredTransfer(), ossService: WiredTransferMockOSSUploadService(), scenicID: 1, accountContext: account ) let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_NEW.JPG") try Data(repeating: 0xFF, count: 16).write(to: tempURL) defer { try? FileManager.default.removeItem(at: tempURL) } let asset = CameraAsset(id: "ptp_new_001", filename: "DSC_NEW.JPG", fileSize: 16) camera.downloadURL = tempURL camera.onNewAsset?(asset) try await Task.sleep(nanoseconds: 500_000_000) XCTAssertEqual(viewModel.photos.count, 1) XCTAssertEqual(viewModel.photos.first?.id, "ptp_new_001") XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG") } /// 测试删除待上传照片后,边拍边传新片不会把已删照片带回列表。 func testDeletedPendingPhotoDoesNotReappearAfterLiveCapture() async throws { let camera = MockWiredCameraService() let context = WiredTransferContext( albumId: 7701, albumName: "删除回归测试", phone: "", orderNumber: "" ) let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) let account = AccountContext() account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) await viewModel.start( api: MockTravelAlbumAPIForWiredTransfer(), ossService: WiredTransferMockOSSUploadService(), scenicID: 1, accountContext: account ) let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD_DELETE.JPG") try Data(repeating: 0x11, count: 16).write(to: oldURL) defer { try? FileManager.default.removeItem(at: oldURL) } camera.downloadURL = oldURL camera.onNewAsset?(CameraAsset(id: "ptp_delete_me", filename: "OLD_DELETE.JPG", fileSize: 16)) try await Task.sleep(nanoseconds: 500_000_000) XCTAssertEqual(viewModel.photos.count, 1) viewModel.deletePhoto(id: "ptp_delete_me") XCTAssertTrue(viewModel.photos.isEmpty) viewModel.selectTransferMode( WiredCameraTransferViewModel.modeLiveCapture, api: MockTravelAlbumAPIForWiredTransfer(), ossService: WiredTransferMockOSSUploadService(), scenicID: 1 ) let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW_LIVE.JPG") try Data(repeating: 0x22, count: 16).write(to: newURL) defer { try? FileManager.default.removeItem(at: newURL) } camera.downloadURL = newURL camera.onNewAsset?(CameraAsset(id: "ptp_live_new", filename: "NEW_LIVE.JPG", fileSize: 16)) try await Task.sleep(nanoseconds: 500_000_000) XCTAssertEqual(viewModel.photos.count, 1) XCTAssertEqual(viewModel.photos.first?.id, "ptp_live_new") XCTAssertFalse(viewModel.photos.contains { $0.id == "ptp_delete_me" }) } /// 测试 start 被取消时 detach UI 回调,但不 shutdown 相机连接。 func testStartCancellationDetachesWithoutShutdown() async { let camera = DisconnectTrackingWiredCameraService() let context = WiredTransferContext( albumId: 6603, albumName: "测试相册", phone: "", orderNumber: "" ) let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) let account = AccountContext() account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) let startTask = Task { await viewModel.start( api: MockTravelAlbumAPIForWiredTransfer(), ossService: WiredTransferMockOSSUploadService(), scenicID: 1, accountContext: account ) } for _ in 0 ..< 100 { if camera.connectCount > 0 { break } try? await Task.sleep(nanoseconds: 10_000_000) } startTask.cancel() try? await Task.sleep(nanoseconds: 300_000_000) XCTAssertGreaterThanOrEqual(camera.detachCount, 1) XCTAssertEqual(camera.shutdownCount, 0) } /// 测试绑定到其他相册的照片不会出现在当前相册列表。 func testPhotoBoundToOtherAlbumIsFilteredOut() async throws { let bindingStore = WiredTransferPhotoStore( accountPrefixProvider: { "user_101" }, userIDProvider: { "101" } ) bindingStore.bindPhotoToAlbum(photoID: "ptp_other_album", albumID: 99) defer { bindingStore.bindPhotoToAlbum(photoID: "ptp_other_album", albumID: 1) } let camera = MockWiredCameraService() let context = WiredTransferContext( albumId: 1, albumName: "当前相册", phone: "", orderNumber: "" ) let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) let account = AccountContext() account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"), accountType: "user") await viewModel.start( api: MockTravelAlbumAPIForWiredTransfer(), ossService: WiredTransferMockOSSUploadService(), scenicID: 1, accountContext: account ) let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_OTHER.JPG") try Data(repeating: 0xAA, count: 8).write(to: tempURL) defer { try? FileManager.default.removeItem(at: tempURL) } camera.downloadURL = tempURL camera.onNewAsset?(CameraAsset(id: "ptp_other_album", filename: "DSC_OTHER.JPG", fileSize: 8)) try await Task.sleep(nanoseconds: 500_000_000) XCTAssertTrue(viewModel.photos.isEmpty) } /// 测试批量上传按钮三态流转。 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: ""), cameraService: MockWiredCameraService() ) 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) } /// 测试上传进度更新时保留 capturedAt,避免列表分组抖动。 func testPipelineProgressUpdatePreservesCapturedAt() async throws { let camera = MockWiredCameraService() let context = WiredTransferContext( albumId: 1, albumName: "测试相册", phone: "", orderNumber: "" ) let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) let account = AccountContext() account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) await viewModel.start( api: MockTravelAlbumAPIForWiredTransfer(), ossService: WiredTransferMockOSSUploadService(), scenicID: 1, accountContext: account ) let originalCapturedAt = "2026-05-20 12:05:18" viewModel.replacePhotos([ WiredTransferPhotoItem( id: "ptp_progress", fileName: "DSC_PROGRESS.JPG", thumbnailURL: "", previewURL: "", capturedAt: originalCapturedAt, fileSizeText: "2 MB", status: .uploading, progress: 10 ), ]) let scope = downloadScope(account: account, albumID: context.albumId) let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "DSC_PROGRESS.JPG", scope: scope) try Data(repeating: 0xAB, count: 16).write(to: fileURL) defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } camera.downloadURL = fileURL camera.onNewAsset?(CameraAsset(id: "ptp_progress", filename: "DSC_PROGRESS.JPG", fileSize: 16)) try await Task.sleep(nanoseconds: 300_000_000) XCTAssertEqual(viewModel.photos.first?.capturedAt, originalCapturedAt) } /// 测试较新拍摄时间的照片排在列表前面。 func testNewerCapturedPhotoAppearsFirst() async throws { let camera = MockWiredCameraService() let context = WiredTransferContext( albumId: 6604, albumName: "测试相册", phone: "", orderNumber: "" ) let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) let account = AccountContext() account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) await viewModel.start( api: MockTravelAlbumAPIForWiredTransfer(), ossService: WiredTransferMockOSSUploadService(), scenicID: 1, accountContext: account ) let fileURL = FileManager.default.temporaryDirectory let olderURL = fileURL.appendingPathComponent("OLDER.JPG") let newerURL = fileURL.appendingPathComponent("NEWER.JPG") try Data(repeating: 0xAA, count: 8).write(to: olderURL) try Data(repeating: 0xBB, count: 8).write(to: newerURL) defer { try? FileManager.default.removeItem(at: olderURL) try? FileManager.default.removeItem(at: newerURL) } camera.downloadURL = olderURL camera.onNewAsset?( CameraAsset( id: "ptp_old", filename: "OLDER.JPG", fileSize: 8, creationDate: Date(timeIntervalSince1970: 1_700_000_000) ) ) try await Task.sleep(nanoseconds: 400_000_000) camera.downloadURL = newerURL camera.onNewAsset?( CameraAsset( id: "ptp_new", filename: "NEWER.JPG", fileSize: 8, creationDate: Date(timeIntervalSince1970: 1_800_000_000) ) ) try await Task.sleep(nanoseconds: 400_000_000) XCTAssertEqual(viewModel.photos.count, 2) XCTAssertEqual(viewModel.photos[0].id, "ptp_new") XCTAssertEqual(viewModel.photos[1].id, "ptp_old") } /// 测试拍后传输模式下重传会从持久化记录恢复上传。 func testRetryPhotoInPostShootModeUploadsPersistedFile() async throws { let camera = MockWiredCameraService() 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, cameraService: camera) 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: ""), cameraService: MockWiredCameraService() ) 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 context = WiredTransferContext(albumId: 7701, albumName: "模式缓存", phone: "", orderNumber: "") let viewModel = WiredCameraTransferViewModel(context: context, cameraService: MockWiredCameraService()) let api = MockTravelAlbumAPIForWiredTransfer() let oss = WiredTransferMockOSSUploadService() await viewModel.start(api: api, ossService: oss, scenicID: 1, accountContext: account) viewModel.selectTransferMode( WiredCameraTransferViewModel.modePostShootTransfer, api: api, ossService: oss, scenicID: 1 ) let store = makeTransferModeTestStore(account: account) XCTAssertEqual(store.loadTransferModeOption(), WiredCameraTransferViewModel.modePostShootTransfer) } /// 测试再次进入有线传图页时会恢复上次选择的传输模式。 func testStartRestoresCachedTransferModeOption() async { let account = makeTransferModeTestAccount() let store = makeTransferModeTestStore(account: account) store.saveTransferModeOption(WiredCameraTransferViewModel.modePostShootTransfer) let context = WiredTransferContext(albumId: 7702, albumName: "模式恢复", phone: "", orderNumber: "") let viewModel = WiredCameraTransferViewModel(context: context, cameraService: MockWiredCameraService()) await viewModel.start( api: MockTravelAlbumAPIForWiredTransfer(), ossService: WiredTransferMockOSSUploadService(), scenicID: 1, accountContext: account ) XCTAssertEqual(viewModel.transferModeOption, WiredCameraTransferViewModel.modePostShootTransfer) } /// 测试下载完成后生成本地缩略图,并优先用于列表展示。 func testDownloadedPhotoGeneratesThumbnailForList() async throws { let camera = MockWiredCameraService() let albumID = 6601 let context = WiredTransferContext(albumId: albumID, albumName: "缩略图相册", phone: "", orderNumber: "") let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) let account = AccountContext() account.applyLogin(profile: AccountProfile(userId: "thumb-user", displayName: "测试")) await viewModel.start( api: MockTravelAlbumAPIForWiredTransfer(), ossService: WiredTransferMockOSSUploadService(), scenicID: 1, accountContext: account ) let scope = downloadScope(account: account, albumID: albumID) let imageURL = CameraDownloadStorage.uniqueLocalURL(for: "thumb_source.JPG", scope: scope) try makeJPEGData().write(to: imageURL) defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } camera.downloadURL = imageURL camera.onNewAsset?(CameraAsset(id: "thumb_asset_001", filename: "thumb_source.JPG", fileSize: 128)) try await Task.sleep(nanoseconds: 900_000_000) let photo = try XCTUnwrap(viewModel.photos.first { $0.id == "thumb_asset_001" }) XCTAssertTrue(photo.thumbnailURL.contains("/CameraDownloads/\(scope.accountDirectoryName)/\(albumID)/thumbnails/")) XCTAssertTrue(photo.previewURL.contains("/CameraDownloads/")) XCTAssertFalse(photo.previewURL.contains("/thumbnails/")) } /// 测试旧记录没有缩略图时列表先占位,但仍保留原图预览能力。 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: ""), cameraService: MockWiredCameraService() ) 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 fileURL = CameraDownloadStorage.uniqueLocalURL(for: "clear_target.JPG", scope: scope) let otherFileURL = CameraDownloadStorage.uniqueLocalURL(for: "clear_other.JPG", scope: otherScope) try Data(repeating: 0x01, count: 8).write(to: fileURL) try Data(repeating: 0x02, count: 8).write(to: otherFileURL) let store = WiredTransferPhotoStore( accountPrefixProvider: { account.accountCachePrefix ?? "guest_" }, userIDProvider: { userID } ) store.save(albumID: albumID, records: [ makePhotoRecord( id: "clear_target", fileName: "clear_target.JPG", localPath: CameraDownloadStorage.relativePath(for: fileURL), albumID: albumID, userID: userID ) ]) store.save(albumID: otherAlbumID, records: [ makePhotoRecord( id: "clear_other", fileName: "clear_other.JPG", localPath: CameraDownloadStorage.relativePath(for: otherFileURL), albumID: otherAlbumID, userID: userID ) ]) let deletedIDs = store.clearAlbum(albumID: albumID) XCTAssertEqual(deletedIDs, Set(["clear_target"])) XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: scopeDirectoryPath)) XCTAssertTrue(FileManager.default.fileExists(atPath: otherFileURL.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: otherScopeDirectoryPath)) } /// 测试同一用户 ID 在不同账号类型下的有线传图记录与物理目录隔离。 func testSameUserIDDifferentAccountTypesAreIsolated() throws { let userID = "multi-account-user" let albumID = 8803 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 Data(repeating: 0x03, count: 8).write(to: storeURL) try Data(repeating: 0x04, count: 8).write(to: photographerURL) let store = WiredTransferPhotoStore( accountPrefixProvider: { storeAccount.accountCachePrefix ?? "guest_" }, userIDProvider: { userID } ) let photographerStore = WiredTransferPhotoStore( accountPrefixProvider: { photographerAccount.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 ) ]) photographerStore.save(albumID: albumID, records: [ makePhotoRecord( id: "photographer_photo", fileName: "photographer_photo.JPG", localPath: CameraDownloadStorage.relativePath(for: photographerURL), albumID: albumID, userID: userID ) ]) XCTAssertEqual(store.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: ""), cameraService: MockWiredCameraService() ) 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 DisconnectTrackingWiredCameraService: CameraServiceProtocol { let brand: CameraBrand = .sony var connectionState: CameraConnectionState = .disconnected var onConnectionStateChange: ((CameraConnectionState) -> Void)? var onNewAsset: ((CameraAsset) -> Void)? private(set) var connectCount = 0 private(set) var detachCount = 0 private(set) var shutdownCount = 0 func connect() async { connectCount += 1 connectionState = .connected(deviceName: "Mock") onConnectionStateChange?(.connected(deviceName: "Mock")) while !Task.isCancelled { try? await Task.sleep(nanoseconds: 50_000_000) } } func detachFromUI() { detachCount += 1 } func shutdown() async { shutdownCount += 1 } func listAssets() async throws -> [CameraAsset] { [] } func downloadAsset(_ asset: CameraAsset) async throws -> URL { FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename) } } @MainActor private final class MockWiredCameraService: CameraServiceProtocol { let brand: CameraBrand = .sony var connectionState: CameraConnectionState = .connected(deviceName: "Mock") var onConnectionStateChange: ((CameraConnectionState) -> Void)? var onNewAsset: ((CameraAsset) -> Void)? var downloadURL: URL? func connect() async {} func detachFromUI() {} func shutdown() async {} func listAssets() async throws -> [CameraAsset] { [] } func downloadAsset(_ asset: CameraAsset) async throws -> URL { if let downloadURL { return downloadURL } let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename) if !FileManager.default.fileExists(atPath: url.path) { try Data(repeating: 0xFF, count: 8).write(to: url) } return url } } @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 { 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 { 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.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 ?? "" } ) }