Files
suixinkan_ios_new/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift
汉秋 ceac8ba67e 优化有线传图页设备展示,并缓存传输模式选项。
未连接状态改为「未连接相机」;设备型号映射为可读名称而非 iPhone12,1 等内部代号;边拍边传/拍后传输按账号持久化并在下次进入时恢复。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 14:07:34 +08:00

547 lines
23 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 testSelectTransferModePersistsOption() async {
let account = makeTransferModeTestAccount()
let context = WiredTransferContext(albumId: 7701, albumName: "模式缓存", phone: "", orderNumber: "")
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: MockWiredCameraService())
let api = MockTravelAlbumAPIForWiredTransfer()
let oss = WiredTransferMockOSSUploadService()
await viewModel.start(api: api, ossService: oss, scenicID: 1, accountContext: account)
viewModel.selectTransferMode(
WiredCameraTransferViewModel.modePostShootTransfer,
api: api,
ossService: oss,
scenicID: 1
)
let store = makeTransferModeTestStore(account: account)
XCTAssertEqual(store.loadTransferModeOption(), WiredCameraTransferViewModel.modePostShootTransfer)
}
/// 线
func testStartRestoresCachedTransferModeOption() async {
let account = makeTransferModeTestAccount()
let store = makeTransferModeTestStore(account: account)
store.saveTransferModeOption(WiredCameraTransferViewModel.modePostShootTransfer)
let context = WiredTransferContext(albumId: 7702, albumName: "模式恢复", phone: "", orderNumber: "")
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: MockWiredCameraService())
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
XCTAssertEqual(viewModel.transferModeOption, WiredCameraTransferViewModel.modePostShootTransfer)
}
///
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 { "" }
}
@MainActor
private func makeTransferModeTestAccount() -> AccountContext {
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "transfer-mode-user", displayName: "测试"))
return account
}
@MainActor
private func makeTransferModeTestStore(account: AccountContext) -> WiredTransferPhotoStore {
WiredTransferPhotoStore(
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
userIDProvider: { account.profile?.userId ?? "" }
)
}