引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。 Co-authored-by: Cursor <cursoragent@cursor.com>
344 lines
13 KiB
Swift
344 lines
13 KiB
Swift
//
|
||
// 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)"
|
||
}
|
||
}
|