Files
suixinkan_uikit/suixinkanTests/TravelAlbumViewModelTests.swift
T
lujiuyin 396597a160 feat: 优化自动修图设置与模板网格交互
同页切换修图方式并展开三列模板,复用模板卡片与对比预览,保留草稿和滚动位置,统一卡片与固定底栏样式并补充回归测试。
2026-08-27 17:15:50 +08:00

2213 lines
92 KiB
Swift

//
// TravelAlbumViewModelTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// AI 修图任务中心 ViewModel 测试。
@MainActor
final class TravelAlbumAIJobViewModelTests: XCTestCase {
func testListFiltersPaginatesAndDeduplicatesJobs() async {
let api = TravelAlbumMockAPI()
api.aiJobListResponses = [
TravelAlbumAIJobListResponse(
items: [makeSummary(id: 1, status: .processing)],
nextCursor: "page-2",
hasMore: true
),
TravelAlbumAIJobListResponse(
items: [makeSummary(id: 1, status: .processing), makeSummary(id: 2, status: .succeeded)],
nextCursor: nil,
hasMore: false
),
]
let viewModel = TravelAlbumAIJobListViewModel()
await viewModel.selectFilter(.inProgress, api: api)
await viewModel.loadMore(api: api)
XCTAssertEqual(viewModel.selectedFilter, .inProgress)
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
XCTAssertTrue(viewModel.containsInProgressJobs)
XCTAssertEqual(api.aiJobListRequests.map(\.cursor), [nil, "page-2"])
XCTAssertEqual(api.aiJobListRequests.map(\.statusGroup), [.inProgress, .inProgress])
}
func testDetailStopsPollingAtTerminalStateAndRecognizes404() async {
let api = TravelAlbumMockAPI()
api.aiJobDetailResponse = makeDetail(status: .succeeded)
let viewModel = TravelAlbumAIJobDetailViewModel(batchId: 91)
await viewModel.load(api: api)
XCTAssertFalse(viewModel.shouldPoll)
XCTAssertEqual(viewModel.detail?.targets.first?.displayFailureMessage, "处理失败,请前往相册重新修图")
let missing = TravelAlbumAIJobDetailViewModel(batchId: 92)
api.aiJobDetailResponse = nil
var notFoundCount = 0
missing.onNotFound = { notFoundCount += 1 }
await missing.load(api: api)
await missing.load(api: api)
XCTAssertTrue(missing.isNotFound)
XCTAssertEqual(notFoundCount, 1)
}
private func makeSummary(id: Int, status: TravelAlbumAIJobStatus) -> TravelAlbumAIJobSummary {
TravelAlbumAIJobSummary(
aiRetouchBatchId: id,
userEquityTravelId: 6,
scope: "album",
status: status,
album: TravelAlbumAIJobAlbum(id: 6, name: "九寨沟", userPhone: "138****0000", coverURL: ""),
sourceCount: 1,
outputs: [TravelAlbumAIJobOutput(type: .refined, count: 1)],
previewImages: [],
progress: TravelAlbumAIJobProgress(total: 1, queued: 0, processing: status.isInProgress ? 1 : 0, succeeded: status == .succeeded ? 1 : 0, failed: 0, canceled: 0),
estimatedFinishAt: nil,
failureSummary: nil,
createdAt: "2026-08-14T06:00:00Z",
startedAt: nil,
finishedAt: nil
)
}
private func makeDetail(status: TravelAlbumAIJobStatus) -> TravelAlbumAIJobDetail {
TravelAlbumAIJobDetail(
aiRetouchBatchId: 91,
userEquityTravelId: 6,
scope: "album",
status: status,
album: TravelAlbumAIJobAlbum(id: 6, name: "九寨沟", userPhone: "138****0000", coverURL: ""),
sourceCount: 1,
outputs: [TravelAlbumAIJobOutput(type: .refined, count: 1)],
progress: TravelAlbumAIJobProgress(total: 1, queued: 0, processing: 0, succeeded: 0, failed: 1, canceled: 0),
quotaSettlement: TravelAlbumAIJobQuotaSettlement(status: "settled", reservedUnits: 1, consumedUnits: 0, releasedUnits: 1, coverUnits: 0),
targets: [TravelAlbumAIJobTarget(targetId: 1, sourceMaterial: nil, inputMaterialIds: [11], outputType: .refined, template: nil, status: .failed, resultAsset: nil, error: nil, createdAt: "", startedAt: nil, finishedAt: nil)],
estimatedFinishAt: nil,
createdAt: "2026-08-14T06:00:00Z",
startedAt: nil,
finishedAt: "2026-08-14T06:01:00Z",
durationSeconds: 60
)
}
}
@MainActor
/// 旅拍相册 ViewModel 测试。
final class TravelAlbumEntryViewModelTests: XCTestCase {
func testReturnRefreshStateSuppressesAnimationUntilRefreshFinishes() {
var state = TravelAlbumReturnRefreshState()
XCTAssertFalse(state.beginRefreshIfNeeded())
XCTAssertFalse(state.suppressesSnapshotAnimations)
state.markRefreshNeeded()
XCTAssertTrue(state.beginRefreshIfNeeded())
XCTAssertTrue(state.suppressesSnapshotAnimations)
state.finishRefresh()
XCTAssertFalse(state.suppressesSnapshotAnimations)
XCTAssertFalse(state.beginRefreshIfNeeded())
}
func testLoadAlbumsUpdatesListAndTotal() async {
let api = TravelAlbumMockAPI()
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 1, name: "A")])
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
await viewModel.loadAlbums(api: api)
XCTAssertEqual(viewModel.albumTotal, 1)
XCTAssertEqual(viewModel.albums.first?.name, "A")
}
func testPullRefreshFailureKeepsExistingAlbums() async {
let api = TravelAlbumMockAPI()
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 1, name: "A")])
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
await viewModel.loadAlbums(api: api)
api.listError = APIError.serverCode(500, "刷新失败")
await viewModel.refreshAlbums(api: api)
XCTAssertEqual(viewModel.albumTotal, 1)
XCTAssertEqual(viewModel.albums.map(\.id), [1])
}
func testPreShootRequiresSinglePrice() async {
let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
var message = ""
viewModel.onShowMessage = { message = $0 }
await viewModel.confirmCreate(mode: .preShoot, freeCount: "", singlePrice: "", packagePrice: "", order: nil, api: api)
XCTAssertEqual(message, "请输入单张照片价格")
XCTAssertEqual(api.createRequests.count, 0)
}
func testPreOrderRequiresOrder() async {
let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
var message = ""
viewModel.onShowMessage = { message = $0 }
await viewModel.confirmCreate(mode: .preOrder, freeCount: "", singlePrice: "", packagePrice: "", order: nil, api: api)
XCTAssertEqual(message, "请选择绑定订单")
}
func testCreateSuccessRefreshesAlbums() async {
let api = TravelAlbumMockAPI()
api.createResponse = TravelAlbumCreateResponse(id: 9)
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 9)])
let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }, dateProvider: { date })
await viewModel.confirmCreate(mode: .preShoot, freeCount: "2", singlePrice: "12.5", packagePrice: "88", order: nil, api: api)
XCTAssertEqual(api.createRequests.first?.name, "2026-07-07-001")
XCTAssertEqual(api.createRequests.first?.type, 1)
XCTAssertEqual(viewModel.albums.first?.id, 9)
XCTAssertFalse(viewModel.isCreateSheetVisible)
}
func testOrderAlreadyBoundReloadsOrders() async {
let api = TravelAlbumMockAPI()
api.createError = APIError.serverCode(100001, "订单已关联")
api.availableOrdersResponse = [TravelAlbumAvailableOrder(projectName: "新订单", orderNumber: "NO1")]
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
let order = TravelAlbumAvailableOrder(projectName: "项目", orderNumber: "NO0")
await viewModel.confirmCreate(mode: .preOrder, freeCount: "", singlePrice: "", packagePrice: "", order: order, api: api)
XCTAssertEqual(api.availableOrdersCallCount, 1)
XCTAssertEqual(viewModel.availableOrders.first?.projectName, "新订单")
}
}
@MainActor
/// 旅拍相册详情 ViewModel 测试。
final class TravelAlbumDetailViewModelTests: XCTestCase {
func testRefreshLoadsInfoAndMaterials() async {
let api = TravelAlbumMockAPI()
api.infoResponse = TravelAlbum(id: 2, name: "详情")
api.materialListResponses = [
TravelAlbumListResponse(total: 2, list: []),
TravelAlbumListResponse(total: 1, list: []),
TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.refreshAll(api: api)
XCTAssertEqual(viewModel.album?.name, "详情")
XCTAssertEqual(viewModel.allPhotoCount, 2)
XCTAssertEqual(viewModel.purchasedPhotoCount, 1)
XCTAssertEqual(viewModel.materials.count, 2)
XCTAssertEqual(api.materialRequests.map(\.pageSize), [1, 1, 30])
XCTAssertEqual(api.materialRequests.map(\.isPurchased), [nil, 1, nil])
}
func testCountFailuresDoNotBlockMaterialList() async {
let api = TravelAlbumMockAPI()
api.infoResponse = TravelAlbum(id: 2, name: "详情")
api.materialListFailingCallIndexes = [0, 1]
api.materialListResponses = [
TravelAlbumListResponse(total: 3, list: [TravelAlbumMaterial(id: 7)]),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.refreshAll(api: api)
XCTAssertEqual(api.materialRequests.count, 3)
XCTAssertEqual(viewModel.materials.map(\.id), [7])
XCTAssertEqual(viewModel.allPhotoCount, 3)
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
XCTAssertFalse(viewModel.isLoading)
}
func testTabAndSortTriggerMaterialRequests() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
await viewModel.setSortOption(.fileNameDesc, api: api)
XCTAssertEqual(api.materialRequests.first?.isPurchased, 1)
XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
}
func testSortOptionsProvideCompactCurrentStateTitles() {
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.createdAsc.compactTitle, "时间 ↑")
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.createdDesc.compactTitle, "时间 ↓")
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.fileNameAsc.compactTitle, "名称 ↑")
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.fileNameDesc.compactTitle, "名称 ↓")
}
func testAlbumCoverFallsBackToFirstMaterial() {
let album = TravelAlbum(id: 2, coverUrl: "")
let coverMaterial = TravelAlbumMaterial(id: 1, fileUrl: "original", coverUrl: "https://cdn.example.com/cover.jpg")
let originalMaterial = TravelAlbumMaterial(id: 2, fileUrl: "https://cdn.example.com/original.jpg", coverUrl: "")
XCTAssertEqual(
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: [coverMaterial]),
"https://cdn.example.com/cover.jpg"
)
XCTAssertEqual(
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: [originalMaterial]),
"https://cdn.example.com/original.jpg"
)
XCTAssertEqual(
TravelAlbumDisplayFormatter.albumCoverURL(
album: TravelAlbum(id: 2, coverUrl: "https://cdn.example.com/album.jpg"),
materials: [coverMaterial]
),
"https://cdn.example.com/album.jpg"
)
}
func testCreationTimeFormatting() {
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText("2026-08-03 14:05:22"), "2026/08/03 14:05")
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText("2026-08-03T14:05:22+08:00"), "2026/08/03 14:05")
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--")
}
func testAllMaterialsCanBeSelectedAndPurchasedTabSupportsSelectionMode() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [TravelAlbumListResponse(total: 1, list: [])]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, isPurchased: true))
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2, isPurchased: false))
XCTAssertTrue(viewModel.isSelectionMode)
XCTAssertEqual(viewModel.selectedMaterialIds, [1, 2])
XCTAssertEqual(viewModel.selectedPurchasedMaterialCount, 1)
XCTAssertEqual(
viewModel.deleteSelectedConfirmationMessage,
"已选择 2 张素材,其中 1 张已购买。不会删除已购买的项目,只会删除 1 张未购买的项目。"
)
}
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
viewModel.completeAIRetouchSubmission()
XCTAssertFalse(viewModel.isSelectionMode)
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
}
func testAIRetouchSubmissionRefreshesAlbumCountsAndCurrentMaterialList() async {
let api = TravelAlbumMockAPI()
api.infoResponse = TravelAlbum(id: 2, name: "刷新后的相册")
api.materialListResponses = [
TravelAlbumListResponse(total: 3, list: []),
TravelAlbumListResponse(total: 1, list: []),
TravelAlbumListResponse(total: 3, list: [
TravelAlbumMaterial(id: 7),
TravelAlbumMaterial(id: 8),
TravelAlbumMaterial(id: 9),
]),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 7, status: 1))
await viewModel.refreshAfterAIRetouchSubmission(api: api)
XCTAssertFalse(viewModel.isSelectionMode)
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
XCTAssertEqual(viewModel.album?.name, "刷新后的相册")
XCTAssertEqual(viewModel.allPhotoCount, 3)
XCTAssertEqual(viewModel.purchasedPhotoCount, 1)
XCTAssertEqual(viewModel.materials.map(\.id), [7, 8, 9])
XCTAssertEqual(api.materialRequests.count, 3)
}
func testPreviewDeletionRemovesMaterialAndSynchronizesCounts() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(
total: 3,
list: [
TravelAlbumMaterial(id: 1),
TravelAlbumMaterial(id: 2, isPurchased: true),
TravelAlbumMaterial(id: 3),
]
),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.loadMaterials(reset: true, api: api)
viewModel.removeMaterialAfterPreviewDeletion(id: 2)
XCTAssertEqual(viewModel.materials.map(\.id), [1, 3])
XCTAssertEqual(viewModel.allPhotoCount, 2)
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
}
func testRefreshMaterialUpdatesOnlyMatchingCachedMaterial() async throws {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(
total: 2,
list: [TravelAlbumMaterial(id: 7), TravelAlbumMaterial(id: 8)]
),
]
api.materialInfoResponse = TravelAlbumMaterial(
id: 7,
aiRetouchBatchId: 700,
aiRefinedURL: "https://cdn/refined.jpg"
)
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.loadMaterials(reset: true, api: api)
let material = try await viewModel.refreshMaterial(id: 7, api: api)
XCTAssertEqual(material.aiRetouchBatchId, 700)
XCTAssertEqual(viewModel.materials.first { $0.id == 7 }?.aiRefinedURL, "https://cdn/refined.jpg")
XCTAssertEqual(viewModel.materials.first { $0.id == 8 }?.aiRetouchBatchId, 0)
XCTAssertEqual(api.materialInfoRequests, [.init(userEquityTravelId: 2, materialId: 7)])
XCTAssertEqual(api.materialRequests.count, 1)
}
func testDeleteAlbumCallsCallback() async {
let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
var deletedId = 0
viewModel.onDeletedAlbum = { deletedId = $0 }
await viewModel.deleteAlbum(api: api)
XCTAssertEqual(api.deletedAlbumIds, [5])
XCTAssertEqual(deletedId, 5)
}
func testDeleteSelectedMaterialsUsesSingleBatchRequest() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2033, status: 1))
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2031, status: 1))
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2032, status: 1))
await viewModel.deleteSelectedMaterials(api: api)
XCTAssertEqual(api.deletedMaterialIDBatches, [[2031, 2032, 2033]])
XCTAssertFalse(viewModel.isSelectionMode)
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
}
func testDeleteSelectedMaterialsFiltersPurchasedItemsFromBatchRequest() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 10, isPurchased: true))
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 11, isPurchased: false))
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 12, isPurchased: true))
await viewModel.deleteSelectedMaterials(api: api)
XCTAssertEqual(api.deletedMaterialIDBatches, [[11]])
XCTAssertFalse(viewModel.isSelectionMode)
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
}
func testDeleteSelectedMaterialsDoesNotRequestAPIWhenAllSelectedItemsArePurchased() async {
let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
var message = ""
viewModel.onShowMessage = { message = $0 }
viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 10, isPurchased: true))
await viewModel.deleteSelectedMaterials(api: api)
XCTAssertTrue(api.deletedMaterialIDBatches.isEmpty)
XCTAssertEqual(message, "选中的素材均已购买,未删除任何项目")
XCTAssertTrue(viewModel.isSelectionMode)
XCTAssertEqual(viewModel.selectedMaterialIds, [10])
}
}
/// 自动修图配置 ViewModel 测试。
@MainActor
final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase {
func testModeToggleRetainsDraftButDisabledSubmissionOmitsTemplate() async {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(refinedTemplates: [
TravelAlbumAIRetouchTemplate(id: 12, name: "清透", previewURL: ""),
])
let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: true)
await viewModel.loadTemplates(api: api)
viewModel.selectMode(enabled: true)
XCTAssertFalse(viewModel.canConfirm)
viewModel.selectTemplate(id: 12)
viewModel.selectMode(enabled: false)
XCTAssertEqual(viewModel.pendingConfiguration, .disabled)
XCTAssertEqual(viewModel.selectedTemplateId, 12)
viewModel.selectMode(enabled: true)
XCTAssertEqual(viewModel.pendingConfiguration?.refinedTemplateId, 12)
XCTAssertEqual(viewModel.selectedTemplateName, "清透")
XCTAssertTrue(viewModel.canConfirm)
}
func testOriginalModeRemainsConfirmableDuringTemplateLoadingAndFailure() async {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesError = APIError.httpStatus(500, "模板服务不可用")
let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: true)
var couldConfirmWhileLoading = false
viewModel.onStateChange = {
if viewModel.isLoading { couldConfirmWhileLoading = viewModel.canConfirm }
}
await viewModel.loadTemplates(api: api)
viewModel.onStateChange = nil
XCTAssertTrue(couldConfirmWhileLoading)
XCTAssertTrue(viewModel.canConfirm)
XCTAssertEqual(viewModel.pendingConfiguration, .disabled)
viewModel.selectMode(enabled: true)
XCTAssertFalse(viewModel.canConfirm)
}
func testTemplateOnlyEntryRequiresAISelectionAndCannotDisable() {
let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: false)
XCTAssertTrue(viewModel.isEnabled)
XCTAssertFalse(viewModel.canConfirm)
XCTAssertNil(viewModel.pendingConfiguration)
viewModel.selectMode(enabled: false)
XCTAssertTrue(viewModel.isEnabled)
}
func testLoadsOnlyRefinedTemplatesAndKeepsSingleSelection() async {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [
TravelAlbumAIRetouchTemplate(id: 11, name: "清透", previewURL: "https://cdn/11.jpg"),
TravelAlbumAIRetouchTemplate(id: 12, name: "质感", previewURL: "https://cdn/12.jpg"),
],
atmosphereTemplates: [
TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "https://cdn/21.jpg"),
],
coverTemplates: []
)
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: 18,
configuration: .disabled,
allowsModeSelection: true
)
viewModel.selectMode(enabled: true)
await viewModel.loadTemplates(api: api)
viewModel.selectTemplate(id: 11)
viewModel.selectTemplate(id: 12)
XCTAssertEqual(viewModel.templates.map(\.id), [11, 12])
XCTAssertEqual(viewModel.selectedTemplateId, 12)
XCTAssertEqual(viewModel.pendingConfiguration?.refinedTemplateId, 12)
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
}
func testTemplateLoadFailureKeepsConfigurationInvalidForRetry() async {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesError = APIError.httpStatus(500, "模板服务不可用")
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: 18,
configuration: TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 12),
allowsModeSelection: false
)
await viewModel.loadTemplates(api: api)
XCTAssertTrue(viewModel.templates.isEmpty)
XCTAssertNil(viewModel.pendingConfiguration)
XCTAssertFalse(viewModel.errorMessage?.isEmpty ?? true)
}
}
/// 有线传输 ViewModel 测试。
@MainActor
final class WiredCameraTransferViewModelTests: XCTestCase {
func testLegacyOTGRecordDefaultsAutomaticRetouchMetadata() throws {
let data = Data(
#"{"id":"legacy","sourceId":"legacy","clientPhotoId":"client-id","fileName":"A.JPG","localPath":"originals/A.JPG","thumbnailPath":"","capturedAt":"2026-08-26 12:00:00","fileSizeBytes":1024,"status":"UPLOADED","progress":100,"albumId":9,"userId":"u1","remoteUrl":"https://cdn/A.JPG","updatedAt":1}"#.utf8
)
let record = try JSONDecoder().decode(TravelAlbumOTGPhotoRecord.self, from: data)
XCTAssertEqual(record.serverMaterialId, 0)
XCTAssertEqual(record.autoRetouchState, .none)
XCTAssertNil(record.autoRetouchTemplateId)
XCTAssertTrue(record.autoRetouchClientRequestId.isEmpty)
XCTAssertEqual(record.autoRetouchBatchId, 0)
}
func testDefaultDisconnectedStateAndOptions() {
let viewModel = WiredCameraTransferViewModel(
albumId: 1,
albumTitle: "相册",
headerPhone: "13800138000",
userDefaults: makeOTGTestDefaults()
)
XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB")
XCTAssertEqual(viewModel.tabCounts, [0, 0, 0])
XCTAssertEqual(viewModel.photoFormatOption, .jpg)
XCTAssertEqual(viewModel.transferModeOption, "边拍边传")
XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
}
func testTransferModePersistsLocallyWithoutAlbumScope() {
let defaults = makeOTGTestDefaults()
let firstContext = makeOTGTestContext()
let secondContext = makeOTGTestContext()
let firstViewModel = makeWiredViewModel(
albumId: 9,
context: firstContext,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
userDefaults: defaults
)
firstViewModel.selectTransferMode("拍后传输")
let secondViewModel = makeWiredViewModel(
albumId: 18,
context: secondContext,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
userDefaults: defaults
)
XCTAssertEqual(secondViewModel.transferModeOption, "拍后传输")
}
func testConnectedStatusIncludesCameraModelName() {
let context = makeOTGTestContext()
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(
state: .connected(platform: .canon, deviceName: "Canon R8"),
driver: MockCameraDriver(objects: [])
)
)
viewModel.start()
XCTAssertEqual(viewModel.cameraStatusText, "Canon R8已连接")
XCTAssertFalse(viewModel.deviceModelName.isEmpty)
XCTAssertTrue(viewModel.availableStorageText.hasSuffix("GB 可用"))
}
func testTransferPhotoSectionsAndSidebarUseDescendingHalfHourSlots() {
let photos = [
makeOTGPhotoItem(id: "a", capturedAt: "2026-07-08 12:05:00"),
makeOTGPhotoItem(id: "b", capturedAt: "2026-07-08 12:29:59"),
makeOTGPhotoItem(id: "c", capturedAt: "2026-07-08 12:30:00"),
makeOTGPhotoItem(id: "d", capturedAt: "2026-07-07 18:10:00"),
]
let sections = photos.buildPhotoSections()
let groups = photos.buildSidebarGroups()
XCTAssertEqual(sections.map(\.slotId), [
"2026-07-08T12:30",
"2026-07-08T12:00",
"2026-07-07T18:00",
])
XCTAssertEqual(sections[0].headerTitle, "07月08日 12:30 - 13:00")
XCTAssertEqual(sections[1].photos.map(\.id), ["b", "a"])
XCTAssertEqual(groups.map(\.dateLabel), ["07月08日", "07月07日"])
XCTAssertEqual(groups.first?.slots.map(\.slotStartLabel), ["12:30", "12:00"])
XCTAssertEqual(groups.first?.slots.last?.photoCount, 2)
}
func testSelectedTimeSlotFallsBackWhenTabChanges() throws {
let context = makeOTGTestContext()
context.store.save([
try makePersistedOTGRecord(context: context, id: "pending", capturedAt: "2026-07-08 12:05:00", status: .pending),
try makePersistedOTGRecord(context: context, id: "failed", capturedAt: "2026-07-08 12:30:00", status: .failed),
], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
)
viewModel.reloadLocalPhotos()
viewModel.selectTimeSlot(id: "2026-07-08T12:00")
viewModel.selectTab(.failed)
XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:30")
}
func testSyncVisibleTimeSlotDoesNotNotifyStateChange() throws {
let context = makeOTGTestContext()
context.store.save([
try makePersistedOTGRecord(context: context, id: "first", capturedAt: "2026-07-08 12:35:00", status: .pending),
try makePersistedOTGRecord(context: context, id: "second", capturedAt: "2026-07-08 12:05:00", status: .pending),
], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
)
viewModel.reloadLocalPhotos()
var stateChangeCount = 0
viewModel.onStateChange = {
stateChangeCount += 1
}
viewModel.syncVisibleTimeSlot(id: "2026-07-08T12:00")
XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:00")
XCTAssertEqual(stateChangeCount, 0)
viewModel.selectTimeSlot(id: "2026-07-08T12:30")
XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:30")
XCTAssertEqual(stateChangeCount, 1)
}
func testSelectionModeAllowsPendingAndFailedOnly() throws {
let context = makeOTGTestContext()
context.store.save([
try makePersistedOTGRecord(context: context, id: "pending", capturedAt: "2026-07-08 12:05:00", status: .pending),
try makePersistedOTGRecord(context: context, id: "failed", capturedAt: "2026-07-08 12:10:00", status: .failed),
try makePersistedOTGRecord(context: context, id: "uploaded", capturedAt: "2026-07-08 12:15:00", status: .uploaded),
], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
)
viewModel.reloadLocalPhotos()
viewModel.onBatchUploadButtonClick()
viewModel.toggleTransferPhotoSelection(photoId: "pending")
viewModel.toggleTransferPhotoSelection(photoId: "failed")
viewModel.toggleTransferPhotoSelection(photoId: "uploaded")
XCTAssertEqual(viewModel.selectedPhotoIds, ["pending", "failed"])
}
func testEnteringPageDoesNotListCameraObjects() async {
let context = makeOTGTestContext()
let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [object])
let manager = MockWiredCameraConnectionManager(driver: driver)
let viewModel = makeWiredViewModel(context: context, manager: manager)
viewModel.start()
try? await Task.sleep(nanoseconds: 80_000_000)
XCTAssertEqual(driver.listCallCount, 0)
XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
}
func testHistoryImportRequiresConnectedCatalog() {
let context = makeOTGTestContext()
let driver = MockCameraDriver(objects: [])
let manager = MockWiredCameraConnectionManager(driver: driver, isContentCatalogReady: false)
let viewModel = makeWiredViewModel(context: context, manager: manager)
var message = ""
viewModel.onShowMessage = { message = $0 }
viewModel.start()
XCTAssertNil(viewModel.makeCameraImportViewModel())
XCTAssertEqual(message, "相机照片目录尚未就绪,请稍候再试")
}
func testHistoryImportCreatesImportViewModelWhenCatalogReady() {
let context = makeOTGTestContext()
let jpeg = CameraObject(id: "JPG|10", filename: "IMG_0001.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [jpeg])
let manager = MockWiredCameraConnectionManager(driver: driver)
let viewModel = makeWiredViewModel(context: context, manager: manager)
viewModel.start()
XCTAssertNotNil(viewModel.makeCameraImportViewModel())
XCTAssertEqual(driver.listCallCount, 0)
}
func testHistoryImportDoesNotDownloadFromTransferPage() async {
let context = makeOTGTestContext()
let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [object])
let manager = MockWiredCameraConnectionManager(driver: driver)
let viewModel = makeWiredViewModel(context: context, manager: manager)
viewModel.start()
_ = viewModel.makeCameraImportViewModel()
try? await Task.sleep(nanoseconds: 80_000_000)
XCTAssertEqual(driver.downloadedObjectIds, [])
XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
}
func testStopSuspendsLiveTransferPolling() {
let context = makeOTGTestContext()
let manager = MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
let viewModel = makeWiredViewModel(context: context, manager: manager)
viewModel.start()
viewModel.stop()
XCTAssertEqual(manager.suspendLiveTransferCallCount, 1)
}
func testHistoricalImportPausesLiveTransferPolling() {
let context = makeOTGTestContext()
let manager = MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
let viewModel = makeWiredViewModel(context: context, manager: manager)
viewModel.pauseLiveTransferForHistoricalImport()
XCTAssertEqual(manager.suspendLiveTransferCallCount, 1)
}
func testPostTransferModeKeepsNewImportsPending() throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader
)
let records = [
try makePersistedOTGRecord(context: context, id: "a", capturedAt: "2026-07-08 12:05:00", status: .pending),
try makePersistedOTGRecord(context: context, id: "b", capturedAt: "2026-07-08 12:06:00", status: .pending),
]
context.store.save(records, albumId: 9)
viewModel.selectTransferMode("拍后传输")
viewModel.handleNewlyImportedPhotoIds(records.map(\.id))
XCTAssertEqual(uploader.uploadCallCount, 0)
XCTAssertEqual(Set(context.store.load(albumId: 9).map(\.status)), [.pending])
}
func testSpecifyUploadTodayCapturedOnlyUploadsTodayNotUploadedPhotos() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader
)
let today = TravelAlbumOTGPhotoStore.transferTimeText(Date())
let yesterday = TravelAlbumOTGPhotoStore.transferTimeText(Date().addingTimeInterval(-86_400))
let records = [
try makePersistedOTGRecord(context: context, id: "today-pending", capturedAt: today, status: .pending),
try makePersistedOTGRecord(context: context, id: "today-failed", capturedAt: today, status: .failed),
try makePersistedOTGRecord(context: context, id: "today-uploaded", capturedAt: today, status: .uploaded),
try makePersistedOTGRecord(context: context, id: "yesterday-pending", capturedAt: yesterday, status: .pending),
]
context.store.save(records, albumId: 9)
viewModel.reloadLocalPhotos()
viewModel.onSpecifyUploadOptionSelected(.todayCaptured)
await waitUntil { uploader.uploadCallCount == 2 }
XCTAssertEqual(uploader.uploadedRecordIds.sorted(), ["today-failed", "today-pending"])
let recordsById = Dictionary(uniqueKeysWithValues: context.store.load(albumId: 9).map { ($0.id, $0) })
XCTAssertEqual(recordsById["today-pending"]?.status, .uploaded)
XCTAssertEqual(recordsById["today-failed"]?.status, .uploaded)
XCTAssertEqual(recordsById["today-uploaded"]?.status, .uploaded)
XCTAssertEqual(recordsById["yesterday-pending"]?.status, .pending)
}
func testSpecifyUploadAllPendingUploadsPendingAndFailedPhotos() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader
)
let records = [
try makePersistedOTGRecord(context: context, id: "pending", capturedAt: "2026-07-08 12:05:00", status: .pending),
try makePersistedOTGRecord(context: context, id: "failed", capturedAt: "2026-07-08 12:06:00", status: .failed),
try makePersistedOTGRecord(context: context, id: "uploaded", capturedAt: "2026-07-08 12:07:00", status: .uploaded),
]
context.store.save(records, albumId: 9)
viewModel.reloadLocalPhotos()
viewModel.onSpecifyUploadOptionSelected(.allPending)
await waitUntil { uploader.uploadCallCount == 2 }
XCTAssertEqual(uploader.uploadedRecordIds.sorted(), ["failed", "pending"])
}
func testSwitchingToLiveUploadOnlyUploadsNewImports() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader
)
let oldRecords = [
try makePersistedOTGRecord(context: context, id: "old-a", capturedAt: "2026-07-08 12:05:00", status: .pending),
try makePersistedOTGRecord(context: context, id: "old-b", capturedAt: "2026-07-08 12:06:00", status: .pending),
]
context.store.save(oldRecords, albumId: 9)
viewModel.selectTransferMode("拍后传输")
viewModel.handleNewlyImportedPhotoIds(oldRecords.map(\.id))
let newRecords = [
try makePersistedOTGRecord(context: context, id: "new-c", capturedAt: "2026-07-08 12:10:00", status: .pending),
try makePersistedOTGRecord(context: context, id: "new-d", capturedAt: "2026-07-08 12:11:00", status: .pending),
]
context.store.save(oldRecords + newRecords, albumId: 9)
viewModel.selectTransferMode("边拍边传")
viewModel.handleNewlyImportedPhotoIds(newRecords.map(\.id))
await waitUntil { uploader.uploadCallCount == 2 }
let recordsById = Dictionary(uniqueKeysWithValues: context.store.load(albumId: 9).map { ($0.id, $0) })
XCTAssertEqual(uploader.uploadedRecordIds.sorted(), ["new-c", "new-d"])
XCTAssertEqual(recordsById["old-a"]?.status, .pending)
XCTAssertEqual(recordsById["old-b"]?.status, .pending)
XCTAssertEqual(recordsById["new-c"]?.status, .uploaded)
XCTAssertEqual(recordsById["new-d"]?.status, .uploaded)
}
func testPhoneAlbumImportWritesLocalRecordsAndAutoUploadsInLiveMode() async {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader
)
let result = viewModel.importPhoneAlbumImages([
TravelAlbumPhoneAlbumImportItem(data: Data([1, 2, 3]), fileName: "album.jpg"),
TravelAlbumPhoneAlbumImportItem(data: Data([4, 5, 6]), fileName: "album.jpg"),
])
await waitUntil { uploader.uploadCallCount == 2 }
let records = context.store.load(albumId: 9)
XCTAssertEqual(result.importedCount, 2)
XCTAssertEqual(result.failedCount, 0)
XCTAssertEqual(Set(result.importedPhotoIds), Set(records.map(\.id)))
XCTAssertEqual(Set(records.map(\.status)), [.uploaded])
XCTAssertEqual(Set(records.map(\.fileName)).count, 2)
XCTAssertEqual(Set(records.map(\.clientPhotoId)).count, 2)
XCTAssertTrue(records.allSatisfy { UUID(uuidString: $0.clientPhotoId) != nil })
}
func testStartSynchronizesPersistedUploadStatusesFromServerClientPhotoIds() async {
let context = makeOTGTestContext()
let api = TravelAlbumMockAPI()
api.materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(
clientPhotoIds: ["server-id"]
)
let records = [
makeOTGRecord(id: "matched", capturedAt: "2026-07-16 12:00:00", status: .failed)
.withClientPhotoId("server-id"),
makeOTGRecord(id: "missing", capturedAt: "2026-07-16 12:01:00", status: .uploaded)
.withClientPhotoId("missing-id"),
]
context.store.save(records, albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
api: api
)
viewModel.start()
await waitUntil {
let byId = Dictionary(uniqueKeysWithValues: context.store.load(albumId: 9).map { ($0.id, $0) })
return byId["matched"]?.status == .uploaded && byId["missing"]?.status == .pending
}
viewModel.stop()
XCTAssertEqual(api.materialClientPhotoIdsCallCount, 1)
}
func testOriginalUploadDoesNotSubmitAIRetouchWhenConfigurationIsDisabled() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
let api = TravelAlbumMockAPI()
let record = try makePersistedOTGRecord(
context: context,
id: "without-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: .disabled
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.status == .uploaded }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertTrue(api.aiRetouchRequests.isEmpty)
XCTAssertEqual(context.store.load(albumId: 9).first?.autoRetouchState, TravelAlbumAutoRetouchState.none)
}
func testEnabledConfigurationSubmitsOneIdempotentAIRetouchAfterOriginalUpload() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [77]
let api = TravelAlbumMockAPI()
api.aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 901,
userEquityTravelId: 9,
status: .queued,
progress: TravelAlbumAIJobProgress(
total: 1,
queued: 1,
processing: 0,
succeeded: 0,
failed: 0,
canceled: 0
),
createdAt: "2026-08-26T12:00:00Z"
)
let record = try makePersistedOTGRecord(
context: context,
id: "automatic-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let configuration = TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 12
)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: configuration
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { api.aiRetouchRequests.count == 1 }
let request = try XCTUnwrap(api.aiRetouchRequests.first)
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(request.materialIds, [77])
XCTAssertEqual(request.refinedTemplateId, 12)
XCTAssertEqual(request.clientRequestId, "auto-9-77-tpl12-a0")
let persisted = try XCTUnwrap(context.store.load(albumId: 9).first)
XCTAssertEqual(persisted.status, .uploaded)
XCTAssertEqual(persisted.progress, 100)
XCTAssertEqual(persisted.serverMaterialId, 77)
XCTAssertEqual(persisted.autoRetouchBatchId, 901)
XCTAssertEqual(persisted.autoRetouchState, .processing)
}
func testAIRetouchSubmissionFailureKeepsUploadAndRetryDoesNotUploadOriginalAgain() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [88]
let api = TravelAlbumMockAPI()
api.submitAIRetouchError = APIError.serverCode(190001, "AI 修图额度不足")
let record = try makePersistedOTGRecord(
context: context,
id: "retry-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 19
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.autoRetouchState == .failed }
let firstRequestId = api.aiRetouchRequests.first?.clientRequestId
XCTAssertEqual(context.store.load(albumId: 9).first?.status, .uploaded)
XCTAssertEqual(context.store.load(albumId: 9).first?.progress, 100)
api.submitAIRetouchError = nil
viewModel.retryAutoRetouch(photoId: record.id)
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(api.aiRetouchRequests.last?.clientRequestId, firstRequestId)
XCTAssertEqual(context.store.load(albumId: 9).first?.status, .uploaded)
XCTAssertEqual(context.store.load(albumId: 9).first?.progress, 100)
}
func testAmbiguousNetworkFailureStaysPendingAndForegroundResumeReusesRequestId() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [89]
let api = TravelAlbumMockAPI()
api.submitAIRetouchError = APIError.networkFailed("连接中断")
let record = try makePersistedOTGRecord(
context: context,
id: "ambiguous-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 20
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.autoRetouchState == .pendingSubmission }
let firstRequestId = try XCTUnwrap(api.aiRetouchRequests.first?.clientRequestId)
api.submitAIRetouchError = nil
viewModel.applicationDidBecomeActive()
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(api.aiRetouchRequests.last?.clientRequestId, firstRequestId)
XCTAssertEqual(context.store.load(albumId: 9).first?.autoRetouchState, .processing)
}
func testTerminalTaskFailureRetryCreatesNewAttemptIdentifierWithoutReupload() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [90]
let api = TravelAlbumMockAPI()
api.aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 902,
userEquityTravelId: 9,
status: .failed,
progress: TravelAlbumAIJobProgress(
total: 1,
queued: 0,
processing: 0,
succeeded: 0,
failed: 1,
canceled: 0
),
createdAt: "2026-08-26T12:00:00Z"
)
let record = try makePersistedOTGRecord(
context: context,
id: "terminal-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 22
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.autoRetouchState == .failed }
let firstRequestId = api.aiRetouchRequests.first?.clientRequestId
api.aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 903,
userEquityTravelId: 9,
status: .queued,
progress: TravelAlbumAIJobProgress(
total: 1,
queued: 1,
processing: 0,
succeeded: 0,
failed: 0,
canceled: 0
),
createdAt: "2026-08-26T12:01:00Z"
)
viewModel.retryAutoRetouch(photoId: record.id)
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertNotEqual(api.aiRetouchRequests.last?.clientRequestId, firstRequestId)
XCTAssertEqual(api.aiRetouchRequests.last?.clientRequestId, "auto-9-90-tpl22-a1")
}
func testConfigurationChangeOnlyAffectsPhotosWhoseUploadHasNotStarted() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [101, 102]
let api = TravelAlbumMockAPI()
let records = [
try makePersistedOTGRecord(
context: context,
id: "first-snapshot",
capturedAt: "2026-08-26 12:00:00",
status: .pending
),
try makePersistedOTGRecord(
context: context,
id: "second-snapshot",
capturedAt: "2026-08-26 12:01:00",
status: .pending
),
]
context.store.save(records, albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 11
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: records[0].id)
await waitUntil { api.aiRetouchRequests.count == 1 }
api.autoRetouchConfigurationResponse = TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 12
)
await viewModel.updateAutoRetouchConfiguration(
TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 12)
)
viewModel.retryPhoto(photoId: records[1].id)
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(api.aiRetouchRequests.map(\.refinedTemplateId), [11, 12])
XCTAssertEqual(api.aiRetouchRequests.map(\.clientRequestId), [
"auto-9-101-tpl11-a0",
"auto-9-102-tpl12-a0",
])
}
func testPendingAIRetouchResumesWithPersistedIdempotencyIdentifier() async {
let context = makeOTGTestContext()
let api = TravelAlbumMockAPI()
let persisted = TravelAlbumOTGPhotoRecord(
id: "resume-retouch",
fileName: "resume.JPG",
localPath: "originals/resume.JPG",
capturedAt: "2026-08-26 12:00:00",
fileSizeBytes: 1024,
status: .uploaded,
progress: 100,
albumId: 9,
userId: "u1",
remoteUrl: "https://cdn.example.com/resume.JPG",
serverMaterialId: 99,
autoRetouchState: .pendingSubmission,
autoRetouchTemplateId: 21,
autoRetouchClientRequestId: "persisted-request-id"
)
context.store.save([persisted], albumId: 9)
let uploader = MockTravelAlbumOTGUploader()
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api
)
viewModel.reloadLocalPhotos()
viewModel.applicationDidBecomeActive()
await waitUntil { api.aiRetouchRequests.count == 1 }
XCTAssertEqual(uploader.uploadCallCount, 0)
XCTAssertEqual(api.aiRetouchRequests.first?.clientRequestId, "persisted-request-id")
XCTAssertEqual(api.aiRetouchRequests.first?.materialIds, [99])
}
func testConfigurationUpdateUsesServerNormalizedResponse() async {
let context = makeOTGTestContext()
let api = TravelAlbumMockAPI()
let serverConfiguration = TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 31
)
api.autoRetouchConfigurationResponse = serverConfiguration
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
api: api,
initialAutoRetouchConfiguration: .disabled
)
var message = ""
viewModel.onShowMessage = { message = $0 }
await viewModel.updateAutoRetouchConfiguration(
TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 22)
)
XCTAssertEqual(api.autoRetouchConfigurationRequests.first?.refinedTemplateId, 22)
XCTAssertEqual(viewModel.autoRetouchConfiguration, serverConfiguration)
XCTAssertEqual(message, "已开启 AI 自动修图")
}
}
/// OTG 修图任务入口、只读预览路由和失败恢复测试。
@MainActor
final class WiredCameraTransferPreviewTests: XCTestCase {
func testTaskButtonUsesCompactWhiteIconAndTitleWithoutSharedBackground() throws {
let fixture = try makeFixture()
let controller = WiredCameraTransferViewController(viewModel: fixture.viewModel)
controller.setupNavigationBar()
let album = TravelAlbumDetailViewController(albumId: 9, api: fixture.api)
album.setupNavigationBar()
let expected = try XCTUnwrap(album.navigationItem.rightBarButtonItems?.first { $0.title == "修图任务" })
let item = try XCTUnwrap(controller.navigationItem.rightBarButtonItem)
let button = try XCTUnwrap(item.customView as? UIButton)
let fittingSize = button.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
XCTAssertEqual(button.configuration?.title, expected.title)
XCTAssertNotNil(button.configuration?.image)
XCTAssertEqual(button.configuration?.image?.renderingMode, .alwaysOriginal)
XCTAssertEqual(button.buttonType, .custom)
XCTAssertEqual(button.accessibilityLabel, expected.accessibilityLabel)
XCTAssertEqual(button.configuration?.baseForegroundColor, .white)
XCTAssertEqual(button.tintColor, .white)
XCTAssertEqual(button.configuration?.background.backgroundColor, .clear)
XCTAssertEqual(button.accessibilityIdentifier, "wiredTransfer.aiRetouchTasksButton")
XCTAssertEqual(fittingSize.height, 44, accuracy: 0.1)
XCTAssertGreaterThanOrEqual(fittingSize.width, 44)
XCTAssertLessThanOrEqual(fittingSize.width, 90)
if #available(iOS 26.0, *) {
XCTAssertTrue(item.hidesSharedBackground)
}
XCTAssertTrue(button.isEnabled)
}
func testTaskButtonOpensExistingJobListWithInjectedAPIAndReturnsToTransferState() async throws {
let fixture = try makeFixture()
fixture.viewModel.selectTransferMode("拍后传输")
fixture.viewModel.selectTab(.uploaded)
let controller = WiredCameraTransferViewController(viewModel: fixture.viewModel)
let navigation = UINavigationController(rootViewController: controller)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = navigation
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
fixture.viewModel.stop()
}
controller.loadViewIfNeeded()
let button = try XCTUnwrap(controller.navigationItem.rightBarButtonItem?.customView as? UIButton)
button.sendActions(for: .touchUpInside)
let jobList = try XCTUnwrap(navigation.topViewController as? TravelAlbumAIJobListViewController)
jobList.loadViewIfNeeded()
await waitUntil { !fixture.api.aiJobListRequests.isEmpty && navigation.transitionCoordinator == nil }
XCTAssertEqual(fixture.api.aiJobListRequests.first?.statusGroup, .all)
button.sendActions(for: .touchUpInside)
XCTAssertEqual(navigation.viewControllers.count, 2)
XCTAssertTrue(navigation.topViewController === jobList)
navigation.popViewController(animated: false)
XCTAssertTrue(navigation.topViewController === controller)
XCTAssertEqual(fixture.viewModel.transferMode, .postTransfer)
XCTAssertEqual(fixture.viewModel.selectedTab, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.status, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.progress, 100)
XCTAssertEqual((controller.navigationItem.rightBarButtonItem?.customView as? UIButton)?.configuration?.title, "修图任务")
XCTAssertEqual(navigation.navigationBar.tintColor, .white)
}
func testPreviewLoadsLatestOriginalAndRefinedResultWithoutChangingUploadState() async throws {
let fixture = try makeFixture()
let project = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTAssertEqual(fixture.api.materialInfoRequests, [.init(userEquityTravelId: 9, materialId: 77)])
XCTAssertEqual(project.originalMaterialId, 77)
XCTAssertEqual(project.aiRetouchBatchId, 901)
XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .retouched])
XCTAssertEqual(project.asset(for: .retouched)?.displayURL, fixture.api.materialInfoResponse.aiRefinedURL)
XCTAssertNotNil(project.comparisonContent(for: .retouched))
XCTAssertEqual(fixture.viewModel.photos.first?.status, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.progress, 100)
XCTAssertEqual(fixture.viewModel.photos.first?.autoRetouchState, .completed)
XCTAssertTrue(fixture.api.aiRetouchRequests.isEmpty)
XCTAssertTrue(fixture.api.uploadMaterialRequests.isEmpty)
}
func testPreviewRejectsMissingMaterialIdAndUnfinishedRetouchWithoutRequest() async throws {
for (state, materialId) in [(TravelAlbumAutoRetouchState.completed, 0), (.processing, 77), (.none, 77)] {
let fixture = try makeFixture(state: state, materialId: materialId)
do {
_ = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTFail("Invalid preview should fail")
} catch {
XCTAssertEqual(error as? TravelAlbumOTGPreviewError, .materialUnavailable)
}
XCTAssertTrue(fixture.api.materialInfoRequests.isEmpty)
}
}
func testPreviewRejectsMissingRefinedResultAndPropagatesRequestFailure() async throws {
let fixture = try makeFixture()
fixture.api.materialInfoResponse = TravelAlbumMaterial(id: 77, fileUrl: "file:///original.jpg", aiRefinedURL: " ")
do {
_ = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTFail("Missing refined result should fail")
} catch {
XCTAssertEqual(error as? TravelAlbumOTGPreviewError, .resultNotReady)
}
let failure = APIError.networkFailed("网络不可用")
fixture.api.materialInfoError = failure
do {
_ = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTFail("Request failure should propagate")
} catch {
XCTAssertEqual(error.localizedDescription, failure.localizedDescription)
}
XCTAssertEqual(fixture.viewModel.photos.first?.status, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.progress, 100)
XCTAssertEqual(fixture.viewModel.photos.first?.autoRetouchState, .completed)
}
func testCompletedPhotoOpensReadOnlyRefinedPreviewAndIgnoresDuplicateTaps() async throws {
let fixture = try makeFixture()
fixture.api.materialInfoDelayNanoseconds = 100_000_000
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
tapPhoto(in: page.collection)
await waitUntil { fixture.api.materialInfoRequests.count == 1 }
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
await waitUntil { page.controller.presentedViewController is TravelAlbumPhotoPreviewViewController }
let preview = try XCTUnwrap(page.controller.presentedViewController as? TravelAlbumPhotoPreviewViewController)
preview.loadViewIfNeeded()
let variants = try XCTUnwrap(findView(in: preview.view, identifier: "travelAlbum.previewVariantSegmentedControl") as? UISegmentedControl)
let actions = try XCTUnwrap(findView(in: preview.view, identifier: "travelAlbum.previewActionStack"))
let comparison = try XCTUnwrap(findView(in: preview.view, identifier: "travelAlbum.previewComparisonButton"))
XCTAssertEqual(variants.numberOfSegments, 2)
XCTAssertEqual(variants.titleForSegment(at: variants.selectedSegmentIndex), "精修后")
XCTAssertTrue(actions.isHidden)
XCTAssertFalse(comparison.isHidden)
XCTAssertEqual(fixture.api.materialInfoRequests.count, 1)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
func testUnfinishedFailedAndUnretouchedPhotosKeepGenericPreview() async throws {
for state in [TravelAlbumAutoRetouchState.none, .processing, .failed] {
let fixture = try makeFixture(state: state)
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
await waitUntil { page.controller.presentedViewController != nil }
XCTAssertTrue(page.controller.presentedViewController is MediaPreviewViewController)
XCTAssertTrue(fixture.api.materialInfoRequests.isEmpty)
}
}
func testPreviewFailuresStayOnTransferPageAndStopLoading() async throws {
for scenario in 0..<3 {
let fixture = try makeFixture(materialId: scenario == 0 ? 0 : 77)
let expectedMessage: String
switch scenario {
case 0:
expectedMessage = TravelAlbumOTGPreviewError.materialUnavailable.localizedDescription
case 1:
let error = APIError.networkFailed("预览网络不可用")
fixture.api.materialInfoError = error
expectedMessage = error.localizedDescription
default:
fixture.api.materialInfoResponse = TravelAlbumMaterial(id: 77, fileUrl: "file:///original.jpg")
expectedMessage = TravelAlbumOTGPreviewError.resultNotReady.localizedDescription
}
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
await waitUntil { self.containsLabel(in: page.controller.view, text: expectedMessage) }
XCTAssertNil(page.controller.presentedViewController)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
XCTAssertTrue(containsLabel(in: page.controller.view, text: expectedMessage))
}
}
func testSelectionModeDoesNotOpenPreview() async throws {
let fixture = try makeFixture()
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
fixture.viewModel.onBatchUploadButtonClick()
tapPhoto(in: page.collection)
XCTAssertNil(page.controller.presentedViewController)
XCTAssertTrue(fixture.api.materialInfoRequests.isEmpty)
}
func testLeavingTransferPageCancelsPreviewLoading() async throws {
let fixture = try makeFixture()
fixture.api.materialInfoDelayNanoseconds = 500_000_000
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
await waitUntil { fixture.api.materialInfoRequests.count == 1 }
page.controller.viewWillDisappear(false)
await waitUntil { !GlobalLoadingManager.shared.isShowing }
XCTAssertNil(page.controller.presentedViewController)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
private func makeFixture(
state: TravelAlbumAutoRetouchState = .completed,
materialId: Int = 77
) throws -> (viewModel: WiredCameraTransferViewModel, api: TravelAlbumMockAPI) {
let context = makeOTGTestContext()
var record = try makePersistedOTGRecord(context: context, id: "preview", capturedAt: "2026-08-27 12:00:00", status: .uploaded)
record.clientPhotoId = "preview-client"
record.progress = 100
record.serverMaterialId = materialId
record.autoRetouchState = state
record.autoRetouchBatchId = 901
context.store.save([record], albumId: 9)
let api = TravelAlbumMockAPI()
api.materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [record.clientPhotoId])
let imageURL = try XCTUnwrap(context.store.absoluteURL(for: record.localPath, albumId: 9)).absoluteString
api.materialInfoResponse = TravelAlbumMaterial(
id: 77, userEquityTravelId: 9, fileName: "preview.JPG", fileUrl: imageURL,
aiRetouchBatchId: 901, aiRefinedURL: imageURL, aiAtmosphereURL: imageURL
)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
api: api
)
viewModel.reloadLocalPhotos()
return (viewModel, api)
}
/// 测试页面与其窗口,保持预览展示期间的 UIKit 生命周期。
private struct Page {
let window: UIWindow
let controller: WiredCameraTransferViewController
let collection: UICollectionView
}
private func showPage(viewModel: WiredCameraTransferViewModel) async throws -> Page {
let controller = WiredCameraTransferViewController(viewModel: viewModel)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = controller
window.makeKeyAndVisible()
controller.loadViewIfNeeded()
let collection = try XCTUnwrap(findView(in: controller.view, identifier: "wiredTransfer.photoCollectionView") as? UICollectionView)
await waitUntil { collection.numberOfSections > 0 && collection.numberOfItems(inSection: 0) > 0 }
controller.view.layoutIfNeeded()
return Page(window: window, controller: controller, collection: collection)
}
private func closePage(_ page: Page, viewModel: WiredCameraTransferViewModel) {
page.controller.dismiss(animated: false)
page.window.isHidden = true
page.window.rootViewController = nil
viewModel.stop()
}
private func tapPhoto(in collection: UICollectionView) {
collection.delegate?.collectionView?(collection, didSelectItemAt: IndexPath(item: 0, section: 0))
}
private func findView(in view: UIView, identifier: String) -> UIView? {
if view.accessibilityIdentifier == identifier { return view }
for subview in view.subviews {
if let match = findView(in: subview, identifier: identifier) { return match }
}
return nil
}
private func containsLabel(in view: UIView, text: String) -> Bool {
if let label = view as? UILabel, label.text == text { return true }
return view.subviews.contains { containsLabel(in: $0, text: text) }
}
}
/// 旅拍相册相机历史导入 ViewModel 测试。
@MainActor
final class TravelAlbumCameraImportViewModelTests: XCTestCase {
func testLoadPhotosListsCameraObjectsAndGroupsByDay() async {
let context = makeOTGTestContext()
let calendar = Calendar(identifier: .gregorian)
let first = calendar.date(from: DateComponents(year: 2026, month: 7, day: 8, hour: 10))!
let second = calendar.date(from: DateComponents(year: 2026, month: 7, day: 7, hour: 10))!
let driver = MockCameraDriver(objects: [
CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: first),
CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: second),
])
let viewModel = makeImportViewModel(context: context, driver: driver)
viewModel.loadPhotos()
await waitUntil {
if case .loaded(let sections) = viewModel.state {
return sections.count == 2
}
return false
}
XCTAssertEqual(driver.listCallCount, 1)
if case .loaded(let sections) = viewModel.state {
XCTAssertEqual(sections.first?.photos.first?.id, "A|10")
} else {
XCTFail("Expected loaded state")
}
}
func testAlreadyImportedPhotoCannotBeSelected() async throws {
let context = makeOTGTestContext()
let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
let localFile = try context.store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 9)
try Data([1]).write(to: localFile)
context.store.save([
TravelAlbumOTGPhotoRecord(
id: "local-id",
sourceId: object.id,
fileName: "A.JPG",
localPath: localFile.path,
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt),
fileSizeBytes: 1,
status: .pending,
albumId: 9,
userId: "u1"
)
], albumId: 9)
let viewModel = makeImportViewModel(context: context, driver: MockCameraDriver(objects: [object]))
viewModel.loadPhotos()
await waitUntil {
if case .loaded = viewModel.state { return true }
return false
}
viewModel.togglePhoto(id: object.id)
XCTAssertTrue(viewModel.isPhotoAlreadyInAlbum(id: object.id))
XCTAssertTrue(viewModel.selectedPhotoIds.isEmpty)
}
func testSelectionAndSectionToggleStates() async {
let context = makeOTGTestContext()
let photos = [
CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date()),
CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: Date()),
]
let viewModel = makeImportViewModel(context: context, driver: MockCameraDriver(objects: photos))
viewModel.loadPhotos()
await waitUntil {
if case .loaded = viewModel.state { return true }
return false
}
guard case .loaded(let sections) = viewModel.state, let section = sections.first else {
return XCTFail("Expected loaded state")
}
viewModel.togglePhoto(id: photos[0].id)
XCTAssertTrue(viewModel.isSectionPartiallySelected(section))
XCTAssertFalse(viewModel.isSectionFullySelected(section))
viewModel.toggleSection(section)
XCTAssertTrue(viewModel.isSectionFullySelected(section))
viewModel.toggleSection(section)
XCTAssertTrue(viewModel.selectedPhotoIds.isEmpty)
}
func testImportSelectedDownloadsOnlySelectedAndWritesPendingRecords() async {
let context = makeOTGTestContext()
let selected = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
let unselected = CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [selected, unselected])
let viewModel = makeImportViewModel(context: context, driver: driver)
viewModel.loadPhotos()
await waitUntil {
if case .loaded = viewModel.state { return true }
return false
}
viewModel.togglePhoto(id: selected.id)
viewModel.importSelected()
await waitUntil {
if case .finished = viewModel.state { return true }
return false
}
XCTAssertEqual(driver.downloadedObjectIds, [selected.id])
let records = context.store.load(albumId: 9)
XCTAssertEqual(records.count, 1)
XCTAssertEqual(records.first?.sourceId, selected.id)
XCTAssertEqual(records.first?.status, .pending)
if case .finished(_, _, let importedPhotoIds) = viewModel.state {
XCTAssertEqual(importedPhotoIds, records.map(\.id))
} else {
XCTFail("Expected finished state")
}
}
func testImportSelectedContinuesAfterSingleFailure() async {
let context = makeOTGTestContext()
let failed = CameraObject(id: "F|10", filename: "F.JPG", fileSize: 10, capturedAt: Date())
let success = CameraObject(id: "S|10", filename: "S.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [failed, success], failingObjectIds: [failed.id])
let viewModel = makeImportViewModel(context: context, driver: driver)
viewModel.loadPhotos()
await waitUntil {
if case .loaded(let sections) = viewModel.state {
sections.first?.photos.count == 2
} else {
false
}
}
guard case .loaded(let sections) = viewModel.state, let section = sections.first else {
return XCTFail("Expected loaded state")
}
viewModel.toggleSection(section)
viewModel.importSelected()
await waitUntil {
if case .finished = viewModel.state { return true }
return false
}
XCTAssertEqual(Set(driver.downloadedObjectIds), [failed.id, success.id])
XCTAssertTrue(viewModel.failedPhotoIds.contains(failed.id))
XCTAssertEqual(context.store.load(albumId: 9).first?.sourceId, success.id)
if case .finished(let importedCount, let failedCount, let importedPhotoIds) = viewModel.state {
XCTAssertEqual(importedCount, 1)
XCTAssertEqual(failedCount, 1)
XCTAssertEqual(importedPhotoIds, context.store.load(albumId: 9).map(\.id))
} else {
XCTFail("Expected finished state")
}
}
func testSonyEmptyCatalogShowsMTPHelp() async {
let context = makeOTGTestContext()
let driver = MockCameraDriver(platform: .sony, objects: [])
let viewModel = makeImportViewModel(context: context, driver: driver)
viewModel.loadPhotos()
await waitUntil {
if case .failed = viewModel.state { return true }
return false
}
XCTAssertTrue(viewModel.shouldShowSonyMTPHelp)
}
private func makeImportViewModel(
context: OTGTestContext,
driver: MockCameraDriver
) -> TravelAlbumCameraImportViewModel {
prepareAppStoreForOTGTests()
return TravelAlbumCameraImportViewModel(
albumId: 9,
albumTitle: "相册",
driver: driver,
storage: context.store
)
}
}
/// OTG 测试存储上下文。
private struct OTGTestContext {
let root: URL
let store: TravelAlbumOTGPhotoStore
}
@MainActor
private func makeWiredViewModel(
albumId: Int = 9,
context: OTGTestContext,
manager: MockWiredCameraConnectionManager,
uploader: MockTravelAlbumOTGUploader? = nil,
api: TravelAlbumMockAPI? = nil,
userDefaults: UserDefaults = makeOTGTestDefaults(),
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
) -> WiredCameraTransferViewModel {
prepareAppStoreForOTGTests()
return WiredCameraTransferViewModel(
albumId: albumId,
albumTitle: "相册",
headerPhone: "13800138000",
initialAutoRetouchConfiguration: initialAutoRetouchConfiguration,
connectionManager: manager,
storage: context.store,
uploader: uploader ?? MockTravelAlbumOTGUploader(),
api: api ?? TravelAlbumMockAPI(),
userDefaults: userDefaults
)
}
private extension TravelAlbumOTGPhotoRecord {
func withClientPhotoId(_ value: String) -> TravelAlbumOTGPhotoRecord {
var copy = self
copy.clientPhotoId = value
return copy
}
}
private func makeOTGPhotoItem(
id: String,
capturedAt: String,
status: TravelAlbumOTGUploadStatus = .pending
) -> TravelAlbumOTGPhotoItem {
TravelAlbumOTGPhotoItem(
id: id,
sourceId: id,
fileName: "\(id).JPG",
thumbnailURL: nil,
capturedAt: capturedAt,
fileSizeText: "1 KB",
fileSizeBytes: 1024,
status: status,
progress: 0,
errorMessage: nil,
localPath: "originals/\(id).JPG",
remoteUrl: ""
)
}
private func makeOTGRecord(
id: String,
capturedAt: String,
status: TravelAlbumOTGUploadStatus
) -> TravelAlbumOTGPhotoRecord {
TravelAlbumOTGPhotoRecord(
id: id,
fileName: "\(id).JPG",
localPath: "originals/\(id).JPG",
capturedAt: capturedAt,
fileSizeBytes: 1024,
status: status,
albumId: 9,
userId: "u1"
)
}
private func makePersistedOTGRecord(
context: OTGTestContext,
id: String,
capturedAt: String,
status: TravelAlbumOTGUploadStatus
) throws -> TravelAlbumOTGPhotoRecord {
let relativePath = "originals/\(id).JPG"
let directory = try context.store.originalsDirectory(albumId: 9)
let url = directory.appendingPathComponent("\(id).JPG")
try Data([0xFF, 0xD8, 0xFF]).write(to: url)
return TravelAlbumOTGPhotoRecord(
id: id,
fileName: "\(id).JPG",
localPath: relativePath,
capturedAt: capturedAt,
fileSizeBytes: 3,
status: status,
albumId: 9,
userId: "u1"
)
}
@MainActor
private func makeOTGTestContext() -> OTGTestContext {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let store = TravelAlbumOTGPhotoStore(
context: TravelAlbumOTGStorageContext(
accountCachePrefix: "u1_photog",
userId: "u1",
scenicId: 11,
storeId: 22
),
applicationSupportDirectory: root.appendingPathComponent("ApplicationSupport", isDirectory: true),
cachesDirectory: root.appendingPathComponent("Caches", isDirectory: true)
)
return OTGTestContext(root: root, store: store)
}
private func makeOTGTestDefaults() -> UserDefaults {
let suiteName = "TravelAlbumOTGTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return defaults
}
@MainActor
private func prepareAppStoreForOTGTests() {
AppStore.shared.session.userId = "u1"
AppStore.shared.session.accountType = .unknown("photog")
AppStore.shared.session.currentScenicId = 11
AppStore.shared.session.currentStoreId = 22
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping @MainActor () -> Bool
) async {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if await condition() { return }
try? await Task.sleep(nanoseconds: 20_000_000)
}
}
/// 有线相机连接管理测试替身。
@MainActor
private final class MockWiredCameraConnectionManager: WiredCameraConnectionManaging {
weak var delegate: ConnectionManagerDelegate?
var state: ConnectionState
var currentDriver: CameraDriver?
var isContentCatalogReady: Bool
private(set) var suspendLiveTransferCallCount = 0
init(
state: ConnectionState = .connected(platform: .canon, deviceName: "Canon"),
driver: CameraDriver?,
isContentCatalogReady: Bool = true
) {
self.state = state
self.currentDriver = driver
self.isContentCatalogReady = isContentCatalogReady
}
func configureLiveTransfer(albumID: Int?) {}
func start() {}
func unbindDelegate() {}
func suspendLiveTransfer() {
suspendLiveTransferCallCount += 1
}
func disconnect() {
state = .disconnected
}
}
/// 相机 Driver 测试替身。
private final class MockCameraDriver: CameraDriver {
let platform: CameraPlatform
let deviceInfo: CameraDeviceInfo
private let objects: [CameraObject]
private let failingObjectIds: Set<String>
private(set) var listCallCount = 0
private(set) var downloadedObjectIds: [String] = []
init(
platform: CameraPlatform = .canon,
objects: [CameraObject],
failingObjectIds: Set<String> = []
) {
self.platform = platform
self.deviceInfo = CameraDeviceInfo(
name: "Canon",
manufacturer: "Canon",
model: "Mock",
serialNumber: nil,
usbVendorID: 0x04A9,
usbProductID: 1
)
self.objects = objects
self.failingObjectIds = failingObjectIds
}
func connect() async throws {}
func disconnect() {}
func listObjects() async throws -> [CameraObject] {
listCallCount += 1
return objects
}
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? {
nil
}
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL {
downloadedObjectIds.append(object.id)
if failingObjectIds.contains(object.id) {
throw CameraError.connectionFailed("下载失败")
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent(object.filename)
try Data([1, 2, 3]).write(to: url, options: .atomic)
return url
}
}
/// OTG 上传测试替身。
@MainActor
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
private(set) var uploadCallCount = 0
private(set) var uploadedRecordIds: [String] = []
var materialIds: [Int] = []
var error: Error?
func upload(
record: TravelAlbumOTGPhotoRecord,
scenicId: Int,
progress: @escaping (Int) -> Void
) async throws -> TravelAlbumMaterial {
uploadCallCount += 1
uploadedRecordIds.append(record.id)
if let error { throw error }
let materialId = materialIds.isEmpty ? uploadCallCount : materialIds.removeFirst()
return TravelAlbumMaterial(
id: materialId,
fileName: record.fileName,
fileUrl: "https://cdn.example.com/\(record.fileName)"
)
}
}
/// 旅拍相册 API 测试替身。
@MainActor
final class TravelAlbumMockAPI: TravelAlbumServing {
struct AIJobListRequest: Equatable {
let statusGroup: TravelAlbumAIJobFilter
let limit: Int
let cursor: String?
}
struct MaterialRequest: Equatable {
let userEquityTravelId: Int
let page: Int
let pageSize: Int
let orderBy: Int
let isPurchased: Int?
}
struct MaterialInfoRequest: Equatable {
let userEquityTravelId: Int
let materialId: Int
}
var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
var listResponse = TravelAlbumListResponse<TravelAlbum>()
var listError: Error?
var createResponse = TravelAlbumCreateResponse(id: 0)
var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
var materialListFailingCallIndexes: Set<Int> = []
var materialInfoResponse = TravelAlbumMaterial()
var materialInfoError: Error?
var materialInfoDelayNanoseconds: UInt64 = 0
var uploadMaterialResponse = TravelAlbumMaterial()
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse()
var autoRetouchConfigurationResponse: TravelAlbumAutoRetouchConfiguration = .disabled
var updateAutoRetouchConfigurationError: Error?
var createError: Error?
var aiRetouchTemplatesError: Error?
var submitAIRetouchError: Error?
var submitAIRetouchDelayNanoseconds: UInt64 = 0
var submitAIReretouchError: Error?
var submitAIReretouchDelayNanoseconds: UInt64 = 0
var aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 1,
userEquityTravelId: 1,
status: .queued,
progress: TravelAlbumAIJobProgress(total: 1, queued: 1, processing: 0, succeeded: 0, failed: 0, canceled: 0),
createdAt: "2026-08-14T06:26:12.123Z"
)
var aiJobListResponse = TravelAlbumAIJobListResponse(items: [], nextCursor: nil, hasMore: false)
var aiJobListResponses: [TravelAlbumAIJobListResponse] = []
var aiJobListDelayNanoseconds: UInt64 = 0
var aiJobDetailResponse: TravelAlbumAIJobDetail?
var deleteMaterialError: Error?
var deleteMaterialDelayNanoseconds: UInt64 = 0
private(set) var availableOrdersCallCount = 0
private(set) var createRequests: [TravelAlbumCreateRequest] = []
private(set) var materialRequests: [MaterialRequest] = []
private(set) var materialInfoRequests: [MaterialInfoRequest] = []
private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
private(set) var materialClientPhotoIdsCallCount = 0
private(set) var deletedAlbumIds: [Int] = []
private(set) var deletedMaterialIDBatches: [[Int]] = []
private(set) var aiRetouchTemplateScenicIds: [Int] = []
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
private(set) var autoRetouchConfigurationRequests: [TravelAlbumAutoRetouchConfigurationRequest] = []
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
private(set) var aiJobListRequests: [AIJobListRequest] = []
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
availableOrdersCallCount += 1
return availableOrdersResponse
}
func create(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
createRequests.append(request)
if let createError { throw createError }
return createResponse
}
func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum> {
if let listError { throw listError }
return listResponse
}
func info(id: Int) async throws -> TravelAlbum {
infoResponse
}
func materialList(
userEquityTravelId: Int,
page: Int,
pageSize: Int,
orderBy: Int,
isPurchased: Int?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
let callIndex = materialRequests.count
materialRequests.append(
MaterialRequest(
userEquityTravelId: userEquityTravelId,
page: page,
pageSize: pageSize,
orderBy: orderBy,
isPurchased: isPurchased
)
)
if materialListFailingCallIndexes.contains(callIndex) {
throw APIError.serverCode(500, "素材数量加载失败")
}
if materialListResponses.isEmpty { return TravelAlbumListResponse() }
return materialListResponses.removeFirst()
}
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial {
materialInfoRequests.append(
MaterialInfoRequest(userEquityTravelId: userEquityTravelId, materialId: materialId)
)
if materialInfoDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: materialInfoDelayNanoseconds)
}
if let materialInfoError { throw materialInfoError }
return materialInfoResponse
}
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
uploadMaterialRequests.append(request)
return uploadMaterialResponse
}
func materialClientPhotoIds(userEquityTravelId: Int) async throws -> TravelAlbumMaterialClientPhotoIDsResponse {
materialClientPhotoIdsCallCount += 1
return materialClientPhotoIdsResponse
}
func deleteAlbum(id: Int) async throws {
deletedAlbumIds.append(id)
}
func batchDeleteMaterials(ids: [Int]) async throws {
deletedMaterialIDBatches.append(ids)
if deleteMaterialDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: deleteMaterialDelayNanoseconds)
}
if let deleteMaterialError { throw deleteMaterialError }
}
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
mpCodeResponse
}
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse {
aiRetouchTemplateScenicIds.append(scenicId)
if let aiRetouchTemplatesError { throw aiRetouchTemplatesError }
return aiRetouchTemplatesResponse
}
func updateAutoRetouchConfiguration(
_ request: TravelAlbumAutoRetouchConfigurationRequest
) async throws -> TravelAlbumAutoRetouchConfiguration {
autoRetouchConfigurationRequests.append(request)
if let updateAutoRetouchConfigurationError { throw updateAutoRetouchConfigurationError }
return autoRetouchConfigurationResponse
}
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
aiRetouchRequests.append(request)
if submitAIRetouchDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: submitAIRetouchDelayNanoseconds)
}
if let submitAIRetouchError { throw submitAIRetouchError }
return aiJobSubmission
}
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission {
aiReretouchRequests.append(request)
if submitAIReretouchDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: submitAIReretouchDelayNanoseconds)
}
if let submitAIReretouchError { throw submitAIReretouchError }
return aiJobSubmission
}
func aiRetouchJobList(
statusGroup: TravelAlbumAIJobFilter,
limit: Int,
cursor: String?
) async throws -> TravelAlbumAIJobListResponse {
aiJobListRequests.append(AIJobListRequest(statusGroup: statusGroup, limit: limit, cursor: cursor))
if aiJobListDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: aiJobListDelayNanoseconds)
}
if !aiJobListResponses.isEmpty { return aiJobListResponses.removeFirst() }
return aiJobListResponse
}
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail {
guard let aiJobDetailResponse else { throw APIError.httpStatus(404, "not found") }
return aiJobDetailResponse
}
}