Files
suixinkan_ios_new/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift
汉秋 19d681192c 修复 OTG 列表删除照片后边拍边传仍会带回已删项的问题。
删除时同步清理管道任务并维护 excludedAssetIDs,避免 notify 将无本地文件的旧任务重新展示为仅文件名的待上传项。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 15:58:25 +08:00

375 lines
15 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.

//
// 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)
}
/// notify asset
func testRemovedAssetStaysExcludedWhenNewAssetArrives() async throws {
let camera = MockCameraService()
let pipeline = CameraTransferPipeline(cameraService: camera)
let scope = makeScope("removed_asset")
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
pipeline.configure(uploadSink: nil, uploadEnabled: false, downloadScope: scope)
attachPipeline(pipeline)
let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD.JPG")
let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW.JPG")
try Data(repeating: 0x01, count: 8).write(to: oldURL)
try Data(repeating: 0x02, count: 8).write(to: newURL)
defer {
try? FileManager.default.removeItem(at: oldURL)
try? FileManager.default.removeItem(at: newURL)
}
camera.onNewAsset?(CameraAsset(id: "ptp_old", filename: "OLD.JPG", fileSize: 8))
try await Task.sleep(nanoseconds: 300_000_000)
pipeline.removeAsset(assetID: "ptp_old")
XCTAssertNil(pipeline.task(forAssetID: "ptp_old"))
camera.onNewAsset?(CameraAsset(id: "ptp_new", filename: "NEW.JPG", fileSize: 8))
try await Task.sleep(nanoseconds: 300_000_000)
XCTAssertNil(pipeline.task(forAssetID: "ptp_old"))
XCTAssertNotNil(pipeline.task(forAssetID: "ptp_new"))
XCTAssertTrue(pipeline.excludedAssetIDs.contains("ptp_old"))
}
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)"
}
}