// // 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) pipeline.configure(uploadSink: sink, uploadEnabled: true) var latestTasks: [CameraTransferTask] = [] 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/"), 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) pipeline.configure(uploadSink: sink, uploadEnabled: false) let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_pipeline.JPG") 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 testSwitchingToLiveCaptureDoesNotUploadPostShootDownloads() async throws { let camera = MockCameraService() let sink = MockUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) pipeline.configure(uploadSink: sink, uploadEnabled: false) 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) 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) pipeline.configure(uploadSink: sink, uploadEnabled: true) 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) pipeline.configure(uploadSink: sink, uploadEnabled: true) 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) pipeline.configure(uploadSink: sink, uploadEnabled: true) var updateCount = 0 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) } } @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 disconnect() 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 disconnect() 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)" } }