重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。 Co-authored-by: Cursor <cursoragent@cursor.com>
209 lines
7.3 KiB
Swift
209 lines
7.3 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)
|
||
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 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)?
|
||
|
||
func connect() async {}
|
||
func disconnect() async {}
|
||
|
||
func listAssets() async throws -> [CameraAsset] {
|
||
[]
|
||
}
|
||
|
||
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
|
||
|
||
func upload(
|
||
localURL: URL,
|
||
fileName: String,
|
||
fileType: Int,
|
||
progress: @escaping @Sendable (Int) -> Void
|
||
) async throws -> String {
|
||
uploadCount += 1
|
||
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)"
|
||
}
|
||
}
|