Files
suixinkan_ios_new/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift
汉秋 5e620623eb 对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。
重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 13:53:59 +08:00

493 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// WiredCameraTransferViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/30.
//
import XCTest
import UIKit
@testable import suixinkan
@MainActor
/// 线 ViewModel
final class WiredCameraTransferViewModelTests: XCTestCase {
///
func testFirstNewPipelineTaskAppearsInPhotos() async throws {
let camera = MockWiredCameraService()
let context = WiredTransferContext(
albumId: 6603,
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 tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_NEW.JPG")
try Data(repeating: 0xFF, count: 16).write(to: tempURL)
defer { try? FileManager.default.removeItem(at: tempURL) }
let asset = CameraAsset(id: "ptp_new_001", filename: "DSC_NEW.JPG", fileSize: 16)
camera.downloadURL = tempURL
camera.onNewAsset?(asset)
try await Task.sleep(nanoseconds: 500_000_000)
XCTAssertEqual(viewModel.photos.count, 1)
XCTAssertEqual(viewModel.photos.first?.id, "ptp_new_001")
XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG")
}
///
func testPhotoBoundToOtherAlbumIsFilteredOut() async throws {
let bindingStore = WiredTransferPhotoStore(
accountPrefixProvider: { "user_101" },
userIDProvider: { "101" }
)
bindingStore.bindPhotoToAlbum(photoID: "ptp_other_album", albumID: 99)
defer { bindingStore.bindPhotoToAlbum(photoID: "ptp_other_album", albumID: 1) }
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: "测试"), accountType: "user")
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_OTHER.JPG")
try Data(repeating: 0xAA, count: 8).write(to: tempURL)
defer { try? FileManager.default.removeItem(at: tempURL) }
camera.downloadURL = tempURL
camera.onNewAsset?(CameraAsset(id: "ptp_other_album", filename: "DSC_OTHER.JPG", fileSize: 8))
try await Task.sleep(nanoseconds: 500_000_000)
XCTAssertTrue(viewModel.photos.isEmpty)
}
///
func testBatchUploadButtonThreeStateFlow() async {
let viewModel = makeViewModelWithPendingPhotos()
let api = MockTravelAlbumAPIForWiredTransfer()
let oss = WiredTransferMockOSSUploadService()
XCTAssertFalse(viewModel.selectUploadMode)
await viewModel.onBatchUploadButtonClick(api: api, ossService: oss, scenicID: 1)
XCTAssertTrue(viewModel.selectUploadMode)
XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty)
viewModel.togglePhotoSelection(id: "pending-1")
XCTAssertEqual(viewModel.selectedPhotoIDs, ["pending-1"])
await viewModel.onBatchUploadButtonClick(api: api, ossService: oss, scenicID: 1)
XCTAssertFalse(viewModel.selectUploadMode)
XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty)
}
/// pending
func testToggleSelectAllPendingInVisible() {
let viewModel = makeViewModelWithPendingPhotos()
viewModel.selectUploadMode = true
viewModel.toggleSelectAllPendingInVisible()
XCTAssertEqual(viewModel.selectedPhotoIDs, ["pending-1", "pending-2"])
viewModel.toggleSelectAllPendingInVisible()
XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty)
}
///
func testUploadAllTodayCapturedFiltersByDate() async {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.locale = Locale(identifier: "zh_CN")
let today = formatter.string(from: Date())
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
cameraService: MockWiredCameraService()
)
viewModel.replacePhotos([
makePendingPhoto(id: "today", capturedAt: today),
makePendingPhoto(id: "old", capturedAt: "2020-01-01 09:00:00"),
])
await viewModel.onSpecifyUploadOptionSelected(
WiredCameraTransferViewModel.specifyUploadTodayCaptured,
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1
)
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.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
}
private func makePendingPhoto(id: String, capturedAt: String) -> WiredTransferPhotoItem {
WiredTransferPhotoItem(
id: id,
fileName: "\(id).JPG",
thumbnailURL: "",
capturedAt: capturedAt,
fileSizeText: "1 MB",
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
private final class MockWiredCameraService: CameraServiceProtocol {
let brand: CameraBrand = .sony
var connectionState: CameraConnectionState = .connected(deviceName: "Mock")
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewAsset: ((CameraAsset) -> Void)?
var downloadURL: URL?
func connect() async {}
func disconnect() async {}
func listAssets() async throws -> [CameraAsset] {
[]
}
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
if let downloadURL {
return downloadURL
}
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 MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing {
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 { throw APIError.emptyData }
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> {
ListPayload(total: 0, list: [])
}
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
let data = 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
)
let envelope = try JSONDecoder().decode(APIEnvelope<TravelAlbumMaterial>.self, from: data)
return try XCTUnwrap(envelope.data)
}
func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws {}
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
TravelAlbumMpCodeResponse(mpCodeOssURL: "")
}
}
@MainActor
private final class WiredTransferMockOSSUploadService: OSSUploadServing {
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 {
onProgress(100)
return "https://cdn/mock/\(fileName)"
}
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
}