对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。
重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import UIKit
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
@ -15,7 +16,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
func testFirstNewPipelineTaskAppearsInPhotos() async throws {
|
||||
let camera = MockWiredCameraService()
|
||||
let context = WiredTransferContext(
|
||||
albumId: 1,
|
||||
albumId: 6603,
|
||||
albumName: "测试相册",
|
||||
phone: "",
|
||||
orderNumber: ""
|
||||
@ -125,10 +126,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
|
||||
cameraService: MockWiredCameraService()
|
||||
)
|
||||
viewModel.photos = [
|
||||
viewModel.replacePhotos([
|
||||
makePendingPhoto(id: "today", capturedAt: today),
|
||||
makePendingPhoto(id: "old", capturedAt: "2020-01-01 09:00:00"),
|
||||
]
|
||||
])
|
||||
|
||||
await viewModel.onSpecifyUploadOptionSelected(
|
||||
WiredCameraTransferViewModel.specifyUploadTodayCaptured,
|
||||
@ -139,23 +140,257 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
XCTAssertNil(viewModel.errorMessage)
|
||||
}
|
||||
|
||||
/// 测试上传进度更新时保留 capturedAt,避免列表分组抖动。
|
||||
func testPipelineProgressUpdatePreservesCapturedAt() async throws {
|
||||
let camera = MockWiredCameraService()
|
||||
let context = WiredTransferContext(
|
||||
albumId: 1,
|
||||
albumName: "测试相册",
|
||||
phone: "",
|
||||
orderNumber: ""
|
||||
)
|
||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
|
||||
let account = AccountContext()
|
||||
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
|
||||
|
||||
await viewModel.start(
|
||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||
ossService: WiredTransferMockOSSUploadService(),
|
||||
scenicID: 1,
|
||||
accountContext: account
|
||||
)
|
||||
let originalCapturedAt = "2026-05-20 12:05:18"
|
||||
viewModel.replacePhotos([
|
||||
WiredTransferPhotoItem(
|
||||
id: "ptp_progress",
|
||||
fileName: "DSC_PROGRESS.JPG",
|
||||
thumbnailURL: "",
|
||||
previewURL: "",
|
||||
capturedAt: originalCapturedAt,
|
||||
fileSizeText: "2 MB",
|
||||
status: .uploading,
|
||||
progress: 10
|
||||
),
|
||||
])
|
||||
|
||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "DSC_PROGRESS.JPG")
|
||||
try Data(repeating: 0xAB, count: 16).write(to: fileURL)
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
|
||||
camera.downloadURL = fileURL
|
||||
camera.onNewAsset?(CameraAsset(id: "ptp_progress", filename: "DSC_PROGRESS.JPG", fileSize: 16))
|
||||
try await Task.sleep(nanoseconds: 300_000_000)
|
||||
|
||||
XCTAssertEqual(viewModel.photos.first?.capturedAt, originalCapturedAt)
|
||||
}
|
||||
|
||||
/// 测试较新拍摄时间的照片排在列表前面。
|
||||
func testNewerCapturedPhotoAppearsFirst() async throws {
|
||||
let camera = MockWiredCameraService()
|
||||
let context = WiredTransferContext(
|
||||
albumId: 6604,
|
||||
albumName: "测试相册",
|
||||
phone: "",
|
||||
orderNumber: ""
|
||||
)
|
||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
|
||||
let account = AccountContext()
|
||||
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
|
||||
|
||||
await viewModel.start(
|
||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||
ossService: WiredTransferMockOSSUploadService(),
|
||||
scenicID: 1,
|
||||
accountContext: account
|
||||
)
|
||||
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
let olderURL = fileURL.appendingPathComponent("OLDER.JPG")
|
||||
let newerURL = fileURL.appendingPathComponent("NEWER.JPG")
|
||||
try Data(repeating: 0xAA, count: 8).write(to: olderURL)
|
||||
try Data(repeating: 0xBB, count: 8).write(to: newerURL)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: olderURL)
|
||||
try? FileManager.default.removeItem(at: newerURL)
|
||||
}
|
||||
|
||||
camera.downloadURL = olderURL
|
||||
camera.onNewAsset?(
|
||||
CameraAsset(
|
||||
id: "ptp_old",
|
||||
filename: "OLDER.JPG",
|
||||
fileSize: 8,
|
||||
creationDate: Date(timeIntervalSince1970: 1_700_000_000)
|
||||
)
|
||||
)
|
||||
try await Task.sleep(nanoseconds: 400_000_000)
|
||||
|
||||
camera.downloadURL = newerURL
|
||||
camera.onNewAsset?(
|
||||
CameraAsset(
|
||||
id: "ptp_new",
|
||||
filename: "NEWER.JPG",
|
||||
fileSize: 8,
|
||||
creationDate: Date(timeIntervalSince1970: 1_800_000_000)
|
||||
)
|
||||
)
|
||||
try await Task.sleep(nanoseconds: 400_000_000)
|
||||
|
||||
XCTAssertEqual(viewModel.photos.count, 2)
|
||||
XCTAssertEqual(viewModel.photos[0].id, "ptp_new")
|
||||
XCTAssertEqual(viewModel.photos[1].id, "ptp_old")
|
||||
}
|
||||
|
||||
/// 测试拍后传输模式下重传会从持久化记录恢复上传。
|
||||
func testRetryPhotoInPostShootModeUploadsPersistedFile() async throws {
|
||||
let camera = MockWiredCameraService()
|
||||
let context = WiredTransferContext(
|
||||
albumId: 1,
|
||||
albumName: "测试相册",
|
||||
phone: "",
|
||||
orderNumber: ""
|
||||
)
|
||||
let account = AccountContext()
|
||||
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
|
||||
|
||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_failed.JPG")
|
||||
try Data(repeating: 0xCD, count: 32).write(to: fileURL)
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
|
||||
let photoID = "persist_failed_001"
|
||||
let record = WiredTransferPhotoRecord(
|
||||
id: photoID,
|
||||
sourceId: photoID,
|
||||
fileName: "retry_failed.JPG",
|
||||
localPath: CameraDownloadStorage.relativePath(for: fileURL),
|
||||
thumbnailPath: "",
|
||||
capturedAt: "2026-05-20 12:05:18",
|
||||
fileSizeBytes: 32,
|
||||
status: WiredTransferUploadStatus.failed.rawValue,
|
||||
progress: 0,
|
||||
errorMessage: "upload failed",
|
||||
albumId: 1,
|
||||
userId: "101",
|
||||
remoteURL: "",
|
||||
updatedAt: Date().timeIntervalSince1970
|
||||
)
|
||||
let store = WiredTransferPhotoStore(
|
||||
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
|
||||
userIDProvider: { "101" }
|
||||
)
|
||||
store.save(albumID: 1, records: [record])
|
||||
|
||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
|
||||
viewModel.transferModeOption = WiredCameraTransferViewModel.modePostShootTransfer
|
||||
|
||||
let api = MockTravelAlbumAPIForWiredTransfer()
|
||||
let oss = WiredTransferMockOSSUploadService()
|
||||
await viewModel.start(api: api, ossService: oss, scenicID: 1, accountContext: account)
|
||||
|
||||
XCTAssertEqual(viewModel.photos.first?.id, photoID)
|
||||
XCTAssertEqual(viewModel.photos.first?.status, .failed)
|
||||
|
||||
await viewModel.retryPhoto(id: photoID, api: api, ossService: oss, scenicID: 1)
|
||||
try await Task.sleep(nanoseconds: 500_000_000)
|
||||
|
||||
XCTAssertEqual(viewModel.photos.first?.status, .uploaded)
|
||||
}
|
||||
|
||||
/// 测试下载完成后生成本地缩略图,并优先用于列表展示。
|
||||
func testDownloadedPhotoGeneratesThumbnailForList() async throws {
|
||||
let camera = MockWiredCameraService()
|
||||
let albumID = 6601
|
||||
let context = WiredTransferContext(albumId: albumID, albumName: "缩略图相册", phone: "", orderNumber: "")
|
||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
|
||||
let account = AccountContext()
|
||||
account.applyLogin(profile: AccountProfile(userId: "thumb-user", displayName: "测试"))
|
||||
|
||||
await viewModel.start(
|
||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||
ossService: WiredTransferMockOSSUploadService(),
|
||||
scenicID: 1,
|
||||
accountContext: account
|
||||
)
|
||||
|
||||
let imageURL = CameraDownloadStorage.uniqueLocalURL(for: "thumb_source.JPG")
|
||||
try makeJPEGData().write(to: imageURL)
|
||||
defer { try? FileManager.default.removeItem(at: imageURL) }
|
||||
|
||||
camera.downloadURL = imageURL
|
||||
camera.onNewAsset?(CameraAsset(id: "thumb_asset_001", filename: "thumb_source.JPG", fileSize: 128))
|
||||
try await Task.sleep(nanoseconds: 900_000_000)
|
||||
|
||||
let photo = try XCTUnwrap(viewModel.photos.first { $0.id == "thumb_asset_001" })
|
||||
XCTAssertTrue(photo.thumbnailURL.contains("/CameraDownloads/Thumbnails/"))
|
||||
XCTAssertTrue(photo.previewURL.contains("/CameraDownloads/"))
|
||||
XCTAssertFalse(photo.previewURL.contains("/Thumbnails/"))
|
||||
}
|
||||
|
||||
/// 测试旧记录没有缩略图时列表先占位,但仍保留原图预览能力。
|
||||
func testLegacyRecordWithoutThumbnailKeepsPreviewURL() async throws {
|
||||
let albumID = 6602
|
||||
let account = AccountContext()
|
||||
account.applyLogin(profile: AccountProfile(userId: "legacy-user", displayName: "测试"))
|
||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "legacy_only.JPG")
|
||||
try makeJPEGData().write(to: fileURL)
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
|
||||
let record = WiredTransferPhotoRecord(
|
||||
id: "legacy_photo_001",
|
||||
sourceId: "legacy_photo_001",
|
||||
fileName: "legacy_only.JPG",
|
||||
localPath: CameraDownloadStorage.relativePath(for: fileURL),
|
||||
thumbnailPath: "",
|
||||
capturedAt: "2026-05-20 12:05:18",
|
||||
fileSizeBytes: 128,
|
||||
status: WiredTransferUploadStatus.pending.rawValue,
|
||||
progress: 0,
|
||||
errorMessage: nil,
|
||||
albumId: albumID,
|
||||
userId: "legacy-user",
|
||||
remoteURL: "",
|
||||
updatedAt: Date().timeIntervalSince1970
|
||||
)
|
||||
let store = WiredTransferPhotoStore(
|
||||
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
|
||||
userIDProvider: { "legacy-user" }
|
||||
)
|
||||
store.save(albumID: albumID, records: [record])
|
||||
|
||||
let viewModel = WiredCameraTransferViewModel(
|
||||
context: WiredTransferContext(albumId: albumID, albumName: "旧记录", phone: "", orderNumber: ""),
|
||||
cameraService: MockWiredCameraService()
|
||||
)
|
||||
await viewModel.start(
|
||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||
ossService: WiredTransferMockOSSUploadService(),
|
||||
scenicID: 1,
|
||||
accountContext: account
|
||||
)
|
||||
|
||||
let photo = try XCTUnwrap(viewModel.photos.first)
|
||||
XCTAssertEqual(photo.thumbnailURL, "")
|
||||
XCTAssertFalse(photo.previewURL.isEmpty)
|
||||
}
|
||||
|
||||
private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel {
|
||||
let viewModel = WiredCameraTransferViewModel(
|
||||
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
|
||||
cameraService: MockWiredCameraService()
|
||||
)
|
||||
viewModel.photos = [
|
||||
viewModel.replacePhotos([
|
||||
makePendingPhoto(id: "pending-1", capturedAt: "2026-05-20 12:05:18"),
|
||||
makePendingPhoto(id: "pending-2", capturedAt: "2026-05-20 12:18:06"),
|
||||
WiredTransferPhotoItem(
|
||||
id: "uploaded-1",
|
||||
fileName: "uploaded.JPG",
|
||||
thumbnailURL: "",
|
||||
previewURL: "",
|
||||
capturedAt: "2026-05-20 12:20:00",
|
||||
fileSizeText: "1 MB",
|
||||
status: .uploaded
|
||||
),
|
||||
]
|
||||
])
|
||||
return viewModel
|
||||
}
|
||||
|
||||
@ -169,6 +404,14 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
status: .pending
|
||||
)
|
||||
}
|
||||
|
||||
private func makeJPEGData() -> Data {
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8))
|
||||
return renderer.jpegData(withCompressionQuality: 0.9) { context in
|
||||
UIColor.systemBlue.setFill()
|
||||
context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -210,7 +453,13 @@ private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing {
|
||||
func albumInfo(id: Int) async throws -> TravelAlbumItem { throw APIError.emptyData }
|
||||
func editAlbum(_ request: TravelAlbumEditRequest) async throws {}
|
||||
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {}
|
||||
func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumMaterial> {
|
||||
func materialList(
|
||||
userEquityTravelId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderBy: Int,
|
||||
isPurchased: Int?
|
||||
) async throws -> ListPayload<TravelAlbumMaterial> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
||||
|
||||
Reference in New Issue
Block a user