// // TravelAlbumViewModelTests.swift // suixinkanTests // 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 WiredCameraTransferViewModelTests: XCTestCase { 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) } } /// 旅拍相册相机历史导入 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() ) -> WiredCameraTransferViewModel { prepareAppStoreForOTGTests() return WiredCameraTransferViewModel( albumId: albumId, albumTitle: "相册", headerPhone: "13800138000", 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 private(set) var listCallCount = 0 private(set) var downloadedObjectIds: [String] = [] init( platform: CameraPlatform = .canon, objects: [CameraObject], failingObjectIds: Set = [] ) { 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] = [] func upload( record: TravelAlbumOTGPhotoRecord, scenicId: Int, progress: @escaping (Int) -> Void ) async throws -> TravelAlbumMaterial { uploadCallCount += 1 uploadedRecordIds.append(record.id) return TravelAlbumMaterial(id: 1, 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() var listError: Error? var createResponse = TravelAlbumCreateResponse(id: 0) var infoResponse = TravelAlbum() var materialListResponses: [TravelAlbumListResponse] = [] var materialListFailingCallIndexes: Set = [] var materialInfoResponse = TravelAlbumMaterial() var materialInfoError: Error? var uploadMaterialResponse = TravelAlbumMaterial() var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: []) var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse() 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 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 { 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 { 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 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 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 } }