// // CameraTransferPipelineTests.swift // suixinkanTests // // Created by Codex on 2026/6/29. // import XCTest @testable import suixinkan @MainActor /// 相机传输管道测试,使用 Mock 相机与上传 Sink。 final class CameraTransferPipelineTests: XCTestCase { /// 测试新照片下载后触发上传 Sink,并持久化相对路径。 func testNewAssetTriggersUploadSink() async { let camera = MockCameraService() let sink = MockUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) let scope = makeScope("new_asset") defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) var latestTasks: [CameraTransferTask] = [] attachPipeline(pipeline, onTasksUpdated: { latestTasks = $0 }) let asset = CameraAsset(id: "ptp_1", filename: "DSC_001.JPG", fileSize: 1024) let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_001.JPG") try? Data(repeating: 0xFF, count: 8).write(to: tempURL) camera.onNewAsset?(asset) try? await Task.sleep(nanoseconds: 300_000_000) XCTAssertEqual(sink.uploadCount, 1) let savedTask = latestTasks.first { $0.assetID == asset.id } XCTAssertEqual( savedTask?.localPath?.hasPrefix("CameraDownloads/test_account/\(scope.albumID)/originals/"), true ) XCTAssertEqual(savedTask?.localPath?.hasPrefix("/"), false) if let localURL = savedTask?.localURL { try? FileManager.default.removeItem(at: localURL) } try? FileManager.default.removeItem(at: tempURL) } /// 测试重传可从相对路径恢复管道任务并上传。 func testRetryUploadRehydratesFromRelativePath() async throws { let camera = MockCameraService() let sink = MockUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) let scope = makeScope("retry") defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope) let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_pipeline.JPG", scope: scope) try Data(repeating: 0xAB, count: 16).write(to: fileURL) defer { try? FileManager.default.removeItem(at: fileURL) } let relativePath = CameraDownloadStorage.relativePath(for: fileURL) await pipeline.retryUpload( assetID: "asset_retry_001", filename: "retry_pipeline.JPG", localPath: relativePath ) XCTAssertEqual(sink.uploadCount, 1) XCTAssertEqual(pipeline.task(forAssetID: "asset_retry_001")?.status, .uploaded) } /// 测试手动上传多张照片时按并发上限上传,而不是串行一张张上传。 func testManualUploadAssetsUsesConcurrentLimit() async throws { let camera = MockCameraService() let sink = MockSlowUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) let scope = makeScope("manual") defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) camera.listedAssets = (1 ... 6).map { index in CameraAsset(id: "manual_\(index)", filename: "MANUAL_\(index).JPG", fileSize: 1024) } await pipeline.syncExistingPhotos() await pipeline.uploadAssets(withIDs: camera.listedAssets.map(\.id)) XCTAssertEqual(sink.uploadCount, 6) XCTAssertGreaterThan(sink.maxActiveUploads, 1) XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3) } /// 测试拍后传输下载的旧照片,切到边拍边传后不会被新照片触发自动上传。 func testSwitchingToLiveCaptureDoesNotUploadPostShootDownloads() async throws { let camera = MockCameraService() let sink = MockUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) let scope = makeScope("mode_switch") defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope) attachPipeline(pipeline) camera.onNewAsset?(CameraAsset(id: "post_001", filename: "POST_001.JPG", fileSize: 1024)) try await Task.sleep(nanoseconds: 400_000_000) XCTAssertEqual(sink.uploadedFileNames, []) XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded) pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) camera.onNewAsset?(CameraAsset(id: "live_001", filename: "LIVE_001.JPG", fileSize: 1024)) try await Task.sleep(nanoseconds: 500_000_000) XCTAssertEqual(sink.uploadedFileNames, ["LIVE_001.JPG"]) XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded) XCTAssertEqual(pipeline.task(forAssetID: "live_001")?.status, .uploaded) } /// 测试边拍边传模式下刷新历史照片只同步到本地,不授予自动上传资格。 func testSyncExistingPhotosDoesNotAutoUploadInLiveCaptureMode() async throws { let camera = MockCameraService() let sink = MockUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) let scope = makeScope("sync_existing") defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) camera.listedAssets = [ CameraAsset(id: "history_001", filename: "HISTORY_001.JPG", fileSize: 1024) ] await pipeline.syncExistingPhotos() try await Task.sleep(nanoseconds: 200_000_000) XCTAssertEqual(sink.uploadedFileNames, []) XCTAssertEqual(pipeline.task(forAssetID: "history_001")?.status, .downloaded) } /// 测试边拍边传连拍时,多张照片会按并发上限同时上传。 func testBurstCaptureUploadsAllAssetsWhileFirstUploadInProgress() async throws { let camera = MockBurstCameraService() let sink = MockSlowUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) let scope = makeScope("burst") defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) attachPipeline(pipeline) for index in 1 ... 10 { let filename = "DSC_\(index).JPG" let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) try Data(repeating: UInt8(index), count: 8).write(to: tempURL) camera.downloadURLs[filename] = tempURL camera.onNewAsset?( CameraAsset(id: "ptp_\(index)", filename: filename, fileSize: 1024) ) } try await Task.sleep(nanoseconds: 2_000_000_000) XCTAssertEqual(sink.uploadCount, 10) XCTAssertGreaterThan(sink.maxActiveUploads, 1) XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3) for index in 1 ... 10 { XCTAssertEqual(pipeline.task(forAssetID: "ptp_\(index)")?.status, .uploaded) } } /// 测试高频上传进度会被合并通知,避免连拍时频繁刷新 SwiftUI 列表。 func testUploadProgressNotificationsAreThrottled() async throws { let camera = MockCameraService() let sink = MockChattyUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) let scope = makeScope("chatty") defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) var updateCount = 0 attachPipeline(pipeline, onTasksUpdated: { _ in updateCount += 1 }) camera.onNewAsset?(CameraAsset(id: "ptp_chatty", filename: "chatty.JPG", fileSize: 1024)) try await Task.sleep(nanoseconds: 800_000_000) XCTAssertEqual(pipeline.task(forAssetID: "ptp_chatty")?.status, .uploaded) XCTAssertLessThan(updateCount, 18) } /// 测试 shutdown 会调用相机服务完整释放。 func testShutdownCallsCameraService() async { let camera = DisconnectTrackingMockCameraService() let pipeline = CameraTransferPipeline(cameraService: camera) await pipeline.shutdown() XCTAssertEqual(camera.shutdownCount, 1) } /// 测试 detachFromUI 仅清回调,不 shutdown 相机。 func testDetachFromUIDoesNotShutdownCameraService() async { let camera = DisconnectTrackingMockCameraService() let pipeline = CameraTransferPipeline(cameraService: camera) attachPipeline(pipeline) pipeline.detachFromUI() XCTAssertEqual(camera.detachCount, 1) XCTAssertEqual(camera.shutdownCount, 0) } private func attachPipeline( _ pipeline: CameraTransferPipeline, onTasksUpdated: @escaping ([CameraTransferTask]) -> Void = { _ in } ) { pipeline.attachToUI(onConnectionStateChange: { _ in }, onTasksUpdated: onTasksUpdated) } private func makeScope(_ albumName: String) -> CameraDownloadStorage.Scope { let albumID = albumName.unicodeScalars.reduce(0) { partial, scalar in (partial + Int(scalar.value)) % 10_000 } return CameraDownloadStorage.Scope(accountKey: "test_account", albumID: albumID) } } @MainActor private final class DisconnectTrackingMockCameraService: CameraServiceProtocol { let brand: CameraBrand = .sony var connectionState: CameraConnectionState = .disconnected var onConnectionStateChange: ((CameraConnectionState) -> Void)? var onNewAsset: ((CameraAsset) -> Void)? private(set) var detachCount = 0 private(set) var shutdownCount = 0 func connect() async {} 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 MockCameraService: CameraServiceProtocol { let brand: CameraBrand = .sony var connectionState: CameraConnectionState = .connected(deviceName: "Mock") var onConnectionStateChange: ((CameraConnectionState) -> Void)? var onNewAsset: ((CameraAsset) -> Void)? var listedAssets: [CameraAsset] = [] func connect() async {} func detachFromUI() {} func shutdown() async {} func listAssets() async throws -> [CameraAsset] { listedAssets } func downloadAsset(_ asset: CameraAsset) async throws -> URL { 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 MockBurstCameraService: CameraServiceProtocol { let brand: CameraBrand = .sony var connectionState: CameraConnectionState = .connected(deviceName: "Mock") var onConnectionStateChange: ((CameraConnectionState) -> Void)? var onNewAsset: ((CameraAsset) -> Void)? var downloadURLs: [String: URL] = [:] func connect() async {} func detachFromUI() {} func shutdown() async {} func listAssets() async throws -> [CameraAsset] { [] } func downloadAsset(_ asset: CameraAsset) async throws -> URL { try await Task.sleep(nanoseconds: 30_000_000) if let url = downloadURLs[asset.filename] { return url } 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 MockSlowUploadSink: CameraAssetUploadSink { var uploadCount = 0 var activeUploads = 0 var maxActiveUploads = 0 func upload( localURL: URL, fileName: String, fileType: Int, progress: @escaping @Sendable (Int) -> Void ) async throws -> String { uploadCount += 1 activeUploads += 1 maxActiveUploads = max(maxActiveUploads, activeUploads) try await Task.sleep(nanoseconds: 150_000_000) activeUploads -= 1 progress(100) return "https://cdn/mock/\(fileName)" } } @MainActor private final class MockUploadSink: CameraAssetUploadSink { var uploadCount = 0 var uploadedFileNames: [String] = [] func upload( localURL: URL, fileName: String, fileType: Int, progress: @escaping @Sendable (Int) -> Void ) async throws -> String { uploadCount += 1 uploadedFileNames.append(fileName) progress(100) return "https://cdn/mock/\(fileName)" } } @MainActor private final class MockChattyUploadSink: CameraAssetUploadSink { func upload( localURL: URL, fileName: String, fileType: Int, progress: @escaping @Sendable (Int) -> Void ) async throws -> String { for value in 0 ... 100 { progress(value) } return "https://cdn/mock/\(fileName)" } }