对齐旅拍相册详情页 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)"
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,6 +61,29 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
XCTAssertEqual(body["file_url"] as? String, "https://cdn/a.jpg")
|
||||
}
|
||||
|
||||
/// 测试素材列表接口携带 order_by 与 is_purchased 参数。
|
||||
func testMaterialListUsesOrderByAndPurchasedQuery() async throws {
|
||||
let session = TravelAlbumRecordingURLSession(data: Self.materialListResponse)
|
||||
let api = TravelAlbumAPI(client: APIClient(session: session))
|
||||
|
||||
_ = try await api.materialList(
|
||||
userEquityTravelId: 8,
|
||||
page: 2,
|
||||
pageSize: 30,
|
||||
orderBy: 3,
|
||||
isPurchased: 1
|
||||
)
|
||||
|
||||
let components = try XCTUnwrap(URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false))
|
||||
let query = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value) })
|
||||
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/travel-album/material-list")
|
||||
XCTAssertEqual(query["user_equity_travel_id"], "8")
|
||||
XCTAssertEqual(query["page"], "2")
|
||||
XCTAssertEqual(query["page_size"], "30")
|
||||
XCTAssertEqual(query["order_by"], "3")
|
||||
XCTAssertEqual(query["is_purchased"], "1")
|
||||
}
|
||||
|
||||
fileprivate static let listResponse = Data(
|
||||
#"{"code":100000,"msg":"success","data":{"total":"1","list":[{"id":"8","store_user_id":"1","name":"2026-06-29-001","type":"1","order_number":"","material_num":"3","material_price":"990","material_package_price":"9900","photo_price":"0","cover_url":"","user_id":"100","status":"1","created_at":"2026","updated_at":"2026","user":{"id":"200","phone":"13800138000"}}]}}"#.utf8
|
||||
)
|
||||
@ -72,6 +95,10 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
fileprivate static let uploadMaterialResponse = Data(
|
||||
#"{"code":100000,"msg":"success","data":{"id":"11","user_equity_travel_id":"8","status":"1","order_number":"","user_id":"100","file_name":"DSC_001.JPG","file_type":"2","file_url":"https://cdn/a.jpg","file_size":"2048","cover_url":"","is_purchased":"0","created_at":"2026","updated_at":"2026"}}"#.utf8
|
||||
)
|
||||
|
||||
fileprivate static let materialListResponse = Data(
|
||||
#"{"code":100000,"msg":"success","data":{"total":"0","list":[]}}"#.utf8
|
||||
)
|
||||
}
|
||||
|
||||
private final class TravelAlbumRecordingURLSession: URLSessionProtocol {
|
||||
|
||||
@ -81,6 +81,139 @@ final class TravelAlbumViewModelTests: XCTestCase {
|
||||
)
|
||||
XCTAssertTrue(name.hasSuffix("003"))
|
||||
}
|
||||
|
||||
/// 测试切换已购 Tab 时携带 is_purchased 参数并重置选择状态。
|
||||
func testDetailSelectPurchasedTabUsesPurchasedFilter() async {
|
||||
let viewModel = TravelAlbumDetailViewModel()
|
||||
let api = MockTravelAlbumDetailAPI()
|
||||
|
||||
await viewModel.refreshAll(api: api, albumID: 8)
|
||||
viewModel.isSelectionMode = true
|
||||
viewModel.selectedMaterialIDs = [1]
|
||||
|
||||
await viewModel.selectTab(TravelAlbumDetailViewModel.tabPurchased, api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.selectedTab, TravelAlbumDetailViewModel.tabPurchased)
|
||||
XCTAssertFalse(viewModel.isSelectionMode)
|
||||
XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty)
|
||||
XCTAssertEqual(api.materialListCalls.last?.isPurchased, 1)
|
||||
}
|
||||
|
||||
/// 测试排序变更触发重载。
|
||||
func testDetailSetOrderByReloadsMaterials() async {
|
||||
let viewModel = TravelAlbumDetailViewModel()
|
||||
let api = MockTravelAlbumDetailAPI()
|
||||
|
||||
await viewModel.refreshAll(api: api, albumID: 8)
|
||||
let initialCount = api.materialListCalls.count
|
||||
|
||||
await viewModel.setOrderBy(3, api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.orderBy, 3)
|
||||
XCTAssertGreaterThan(api.materialListCalls.count, initialCount)
|
||||
XCTAssertEqual(api.materialListCalls.last?.orderBy, 3)
|
||||
}
|
||||
|
||||
/// 测试仅未购买素材可选中。
|
||||
func testDetailToggleSelectionRejectsPurchasedMaterial() async {
|
||||
let viewModel = TravelAlbumDetailViewModel()
|
||||
viewModel.isSelectionMode = true
|
||||
|
||||
let purchased = try! decodeMaterial(id: 2, status: 2)
|
||||
viewModel.toggleMaterialSelection(purchased)
|
||||
XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty)
|
||||
XCTAssertEqual(viewModel.errorMessage, "仅未购买素材可删除")
|
||||
|
||||
let unpurchased = try! decodeMaterial(id: 3, status: TravelAlbumDetailViewModel.materialStatusUnpurchased)
|
||||
viewModel.toggleMaterialSelection(unpurchased)
|
||||
XCTAssertEqual(viewModel.selectedMaterialIDs, [3])
|
||||
}
|
||||
|
||||
/// 测试批量删除按顺序调用 API。
|
||||
func testDetailConfirmDeleteSelectedMaterialsDeletesInOrder() async {
|
||||
let viewModel = TravelAlbumDetailViewModel()
|
||||
let api = MockTravelAlbumDetailAPI()
|
||||
viewModel.selectedMaterialIDs = [11, 12, 13]
|
||||
|
||||
let success = await viewModel.confirmDeleteSelectedMaterials(api: api)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(Set(api.deletedMaterialIDs), Set([11, 12, 13]))
|
||||
XCTAssertFalse(viewModel.isSelectionMode)
|
||||
XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试全部 Tab 下 allPhotoCount 与 total 同步。
|
||||
func testDetailAllPhotoCountSyncsWithTotalOnAllTab() async {
|
||||
let viewModel = TravelAlbumDetailViewModel()
|
||||
let api = MockTravelAlbumDetailAPI(materialTotal: 42)
|
||||
|
||||
await viewModel.refreshAll(api: api, albumID: 8)
|
||||
|
||||
XCTAssertEqual(viewModel.allPhotoCount, 42)
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeMaterial(id: Int, status: Int) throws -> TravelAlbumMaterial {
|
||||
let json = """
|
||||
{"code":100000,"msg":"success","data":{"id":"\(id)","user_equity_travel_id":"8","status":"\(status)","order_number":"","user_id":"100","file_name":"DSC.JPG","file_type":"2","file_url":"https://cdn/a.jpg","file_size":"2048","cover_url":"","is_purchased":"0","created_at":"2026","updated_at":"2026"}}
|
||||
"""
|
||||
let data = Data(json.utf8)
|
||||
let envelope = try JSONDecoder().decode(APIEnvelope<TravelAlbumMaterial>.self, from: data)
|
||||
return try XCTUnwrap(envelope.data)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockTravelAlbumDetailAPI: TravelAlbumServing {
|
||||
struct MaterialListCall {
|
||||
let orderBy: Int
|
||||
let isPurchased: Int?
|
||||
let page: Int
|
||||
}
|
||||
|
||||
var materialTotal: Int
|
||||
var materialListCalls: [MaterialListCall] = []
|
||||
var deletedMaterialIDs: [Int] = []
|
||||
|
||||
init(materialTotal: Int = 5) {
|
||||
self.materialTotal = materialTotal
|
||||
}
|
||||
|
||||
func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] }
|
||||
func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
|
||||
TravelAlbumCreateResponse(id: 1)
|
||||
}
|
||||
func albumList(page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
func albumInfo(id: Int) async throws -> TravelAlbumItem {
|
||||
let data = Data(
|
||||
#"{"code":100000,"msg":"success","data":{"id":"8","store_user_id":"1","name":"2026-06-30-001","type":"1","order_number":"","material_num":"3","material_price":"990","material_package_price":"9900","photo_price":"0","cover_url":"","user_id":"100","status":"1","created_at":"2026-06-30","updated_at":"2026-06-30","user":{"id":"200","phone":"13800138000"}}}"#.utf8
|
||||
)
|
||||
let envelope = try JSONDecoder().decode(APIEnvelope<TravelAlbumItem>.self, from: data)
|
||||
return try XCTUnwrap(envelope.data)
|
||||
}
|
||||
func editAlbum(_ request: TravelAlbumEditRequest) async throws {}
|
||||
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {}
|
||||
func materialList(
|
||||
userEquityTravelId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderBy: Int,
|
||||
isPurchased: Int?
|
||||
) async throws -> ListPayload<TravelAlbumMaterial> {
|
||||
materialListCalls.append(MaterialListCall(orderBy: orderBy, isPurchased: isPurchased, page: page))
|
||||
return ListPayload(total: materialTotal, list: [])
|
||||
}
|
||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
||||
throw APIError.emptyData
|
||||
}
|
||||
func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws {
|
||||
deletedMaterialIDs.append(request.id)
|
||||
}
|
||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
|
||||
TravelAlbumMpCodeResponse(mpCodeOssURL: "")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -108,7 +241,13 @@ private final class MockTravelAlbumAPI: TravelAlbumServing {
|
||||
|
||||
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: [])
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -19,6 +19,7 @@ final class WiredTransferPhotoGroupingTests: XCTestCase {
|
||||
let sections = photos.buildPhotoSections()
|
||||
XCTAssertEqual(sections.count, 2)
|
||||
XCTAssertEqual(sections[0].photos.count, 2)
|
||||
XCTAssertEqual(sections[0].photos[0].id, "2")
|
||||
XCTAssertTrue(sections[0].headerTitle.contains("12:00 - 12:30"))
|
||||
|
||||
let sidebar = photos.buildSidebarGroups()
|
||||
@ -39,6 +40,17 @@ final class WiredTransferPhotoGroupingTests: XCTestCase {
|
||||
XCTAssertFalse(makePhoto(id: "old", capturedAt: "2020-01-01 10:00:00", status: .pending).isCapturedToday())
|
||||
}
|
||||
|
||||
/// 测试按拍摄时间倒序排列,最新照片在最前。
|
||||
func testSortedByCaptureTimeDescending() {
|
||||
let photos = [
|
||||
makePhoto(id: "1", capturedAt: "2026-05-20 12:05:18", status: .uploaded),
|
||||
makePhoto(id: "2", capturedAt: "2026-05-20 12:18:06", status: .pending),
|
||||
makePhoto(id: "3", capturedAt: "2026-05-20 11:55:51", status: .failed),
|
||||
]
|
||||
|
||||
XCTAssertEqual(photos.sortedByCaptureTimeDescending().map(\.id), ["2", "1", "3"])
|
||||
}
|
||||
|
||||
/// 测试 Tab 计数与 pending 可选集合。
|
||||
func testTransferTabCountsAndSelectablePendingIDs() {
|
||||
let photos = [
|
||||
|
||||
Reference in New Issue
Block a user