对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。
重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -37,6 +37,72 @@ final class CameraTransferPipelineTests: XCTestCase {
|
||||
}
|
||||
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
|
||||
@ -62,6 +128,54 @@ private final class MockCameraService: CameraServiceProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
@ -77,3 +191,18 @@ private final class MockUploadSink: CameraAssetUploadSink {
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user