增强有线传图手动上传并发能力,并稳定列表行布局。
指定/勾选/批量上传支持恢复仅本地持久化的待传照片,手动上传与 pipeline 均按 3 路并发;pipeline 任务签名去重避免状态回退,照片行固定高度并预留进度条占位。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -60,6 +60,24 @@ final class CameraTransferPipelineTests: XCTestCase {
|
||||
XCTAssertEqual(pipeline.task(forAssetID: "asset_retry_001")?.status, .uploaded)
|
||||
}
|
||||
|
||||
/// 测试手动上传多张照片时按并发上限上传,而不是串行一张张上传。
|
||||
func testManualUploadAssetsUsesConcurrentLimit() async throws {
|
||||
let camera = MockCameraService()
|
||||
let sink = MockSlowUploadSink()
|
||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
||||
pipeline.configure(uploadSink: sink, uploadEnabled: true)
|
||||
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()
|
||||
|
||||
@ -296,6 +296,65 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.photos.first?.status, .uploaded)
|
||||
}
|
||||
|
||||
/// 测试指定上传“所有未上传”会恢复本地持久化照片并全部上传。
|
||||
func testBatchUploadAllUploadsPersistedPendingFiles() async throws {
|
||||
let albumID = 7711
|
||||
let userID = "batch-persist-user"
|
||||
let account = AccountContext()
|
||||
account.applyLogin(profile: AccountProfile(userId: userID, displayName: "测试"))
|
||||
|
||||
let fileURLs = try (1 ... 5).map { index in
|
||||
let url = CameraDownloadStorage.uniqueLocalURL(for: "batch_persist_\(index).JPG")
|
||||
try Data(repeating: UInt8(0xA0 + index), count: 32).write(to: url)
|
||||
return url
|
||||
}
|
||||
defer {
|
||||
fileURLs.forEach { try? FileManager.default.removeItem(at: $0) }
|
||||
}
|
||||
|
||||
let records = fileURLs.enumerated().map { offset, url in
|
||||
let index = offset + 1
|
||||
return makePhotoRecord(
|
||||
id: "batch_persist_\(index)",
|
||||
fileName: "batch_persist_\(index).JPG",
|
||||
localPath: CameraDownloadStorage.relativePath(for: url),
|
||||
albumID: albumID,
|
||||
userID: userID
|
||||
)
|
||||
}
|
||||
let store = WiredTransferPhotoStore(
|
||||
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
|
||||
userIDProvider: { userID }
|
||||
)
|
||||
store.save(albumID: albumID, records: records)
|
||||
|
||||
let viewModel = WiredCameraTransferViewModel(
|
||||
context: WiredTransferContext(albumId: albumID, albumName: "批量上传", phone: "", orderNumber: ""),
|
||||
cameraService: MockWiredCameraService()
|
||||
)
|
||||
let oss = WiredTransferMockOSSUploadService()
|
||||
oss.uploadDelayNanoseconds = 150_000_000
|
||||
await viewModel.start(
|
||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||
ossService: oss,
|
||||
scenicID: 1,
|
||||
accountContext: account
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.photos.filter(\.isNotUploaded).count, 5)
|
||||
|
||||
await viewModel.batchUploadAll(
|
||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||
ossService: oss,
|
||||
scenicID: 1
|
||||
)
|
||||
|
||||
XCTAssertEqual(Set(oss.uploadedFileNames), Set((1 ... 5).map { "batch_persist_\($0).JPG" }))
|
||||
XCTAssertGreaterThan(oss.maxActiveUploads, 1)
|
||||
XCTAssertLessThanOrEqual(oss.maxActiveUploads, 3)
|
||||
XCTAssertTrue(viewModel.photos.allSatisfy { $0.status == .uploaded })
|
||||
}
|
||||
|
||||
/// 测试切换传输模式后会按账号缓存选项。
|
||||
func testSelectTransferModePersistsOption() async {
|
||||
let account = makeTransferModeTestAccount()
|
||||
@ -444,6 +503,32 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
private func makePhotoRecord(
|
||||
id: String,
|
||||
fileName: String,
|
||||
localPath: String,
|
||||
albumID: Int,
|
||||
userID: String,
|
||||
status: WiredTransferUploadStatus = .pending
|
||||
) -> WiredTransferPhotoRecord {
|
||||
WiredTransferPhotoRecord(
|
||||
id: id,
|
||||
sourceId: id,
|
||||
fileName: fileName,
|
||||
localPath: localPath,
|
||||
thumbnailPath: "",
|
||||
capturedAt: "2026-05-20 12:05:18",
|
||||
fileSizeBytes: 32,
|
||||
status: status.rawValue,
|
||||
progress: 0,
|
||||
errorMessage: nil,
|
||||
albumId: albumID,
|
||||
userId: userID,
|
||||
remoteURL: "",
|
||||
updatedAt: Date().timeIntervalSince1970
|
||||
)
|
||||
}
|
||||
|
||||
private func makeJPEGData() -> Data {
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8))
|
||||
return renderer.jpegData(withCompressionQuality: 0.9) { context in
|
||||
@ -516,10 +601,22 @@ private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing {
|
||||
|
||||
@MainActor
|
||||
private final class WiredTransferMockOSSUploadService: OSSUploadServing {
|
||||
private(set) var uploadedFileNames: [String] = []
|
||||
var uploadDelayNanoseconds: UInt64 = 0
|
||||
private(set) var activeUploads = 0
|
||||
private(set) var maxActiveUploads = 0
|
||||
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
activeUploads += 1
|
||||
maxActiveUploads = max(maxActiveUploads, activeUploads)
|
||||
if uploadDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: uploadDelayNanoseconds)
|
||||
}
|
||||
activeUploads -= 1
|
||||
uploadedFileNames.append(fileName)
|
||||
onProgress(100)
|
||||
return "https://cdn/mock/\(fileName)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user