增强有线传图手动上传并发能力,并稳定列表行布局。

指定/勾选/批量上传支持恢复仅本地持久化的待传照片,手动上传与 pipeline 均按 3 路并发;pipeline 任务签名去重避免状态回退,照片行固定高度并预留进度条占位。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 15:10:47 +08:00
parent 5ebcec1cfc
commit 660852ffad
7 changed files with 283 additions and 72 deletions

View File

@ -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()

View File

@ -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)"
}