2168 lines
90 KiB
Swift
2168 lines
90 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 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,
|
|
startsWithModeSelection: 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),
|
|
startsWithModeSelection: 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
|
|
}
|
|
}
|