// // TravelAlbumDetailViewControllerTests.swift // suixinkanTests // import UIKit import XCTest @testable import suixinkan /// 相册管理页刷新、预览与选择态交互测试。 @MainActor final class TravelAlbumDetailViewControllerTests: XCTestCase { func testAIJobDetailMatchesDesignCardHierarchyAndShowsInlineFailureReason() async throws { let api = TravelAlbumMockAPI() let source = TravelAlbumAIJobSourceMaterial( id: 691, fileName: "IMG_8293.JPG", thumbnailURL: "" ) let failedTarget = TravelAlbumAIJobTarget( targetId: 3, sourceMaterial: source, inputMaterialIds: [691], outputType: .refined, template: TravelAlbumAIJobTemplate(id: 7, name: "自然通透"), status: .failed, resultAsset: nil, error: TravelAlbumAIJobError( code: "VENDOR_TIMEOUT", message: "AI服务处理超时,请重新修图", retryable: true ), createdAt: "2026-08-14T03:20:43.000Z", startedAt: nil, finishedAt: "2026-08-14T03:21:43.000Z" ) api.aiJobDetailResponse = TravelAlbumAIJobDetail( aiRetouchBatchId: 59, userEquityTravelId: 17, scope: "batch", status: .processing, album: TravelAlbumAIJobAlbum( id: 17, name: "旅拍相册", userPhone: "13222319413", coverURL: "" ), sourceCount: 1, outputs: [ TravelAlbumAIJobOutput(type: .refined, count: 4), TravelAlbumAIJobOutput(type: .atmosphere, count: 4), TravelAlbumAIJobOutput(type: .cover, count: 1), ], progress: TravelAlbumAIJobProgress( total: 9, queued: 2, processing: 1, succeeded: 5, failed: 1, canceled: 0 ), quotaSettlement: TravelAlbumAIJobQuotaSettlement( status: "reserved", reservedUnits: 9, consumedUnits: 6, releasedUnits: 0, coverUnits: 1 ), targets: [failedTarget], estimatedFinishAt: "2026-08-14T03:32:00.000Z", createdAt: "2026-08-14T03:20:43.000Z", startedAt: "2026-08-14T03:20:44.000Z", finishedAt: nil, durationSeconds: nil ) let controller = TravelAlbumAIJobDetailViewController(batchId: 59, api: api) let navigationController = UINavigationController(rootViewController: controller) let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) window.rootViewController = navigationController window.makeKeyAndVisible() defer { window.isHidden = true } controller.loadViewIfNeeded() await waitUntil { controller.view.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.detail.statusCard" } != nil } controller.view.layoutIfNeeded() for identifier in [ "aiRetouchJob.detail.statusCard", "aiRetouchJob.detail.albumCard", "aiRetouchJob.detail.contentCard", "aiRetouchJob.detail.processingCard", "aiRetouchJob.detail.albumButton", ] { XCTAssertNotNil(controller.view.findSubview { $0.accessibilityIdentifier == identifier }) } let labels = controller.view.allLabels().compactMap(\.text) XCTAssertTrue(labels.contains("修图中")) XCTAssertTrue(labels.contains("已完成 6 / 9")) XCTAssertTrue(labels.contains("67%")) XCTAssertTrue(labels.contains("失败原因:AI服务处理超时,请重新修图")) XCTAssertFalse(labels.contains("去相册处理")) XCTAssertFalse(labels.contains { $0.contains("VENDOR_TIMEOUT") }) let detailScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in window.layer.render(in: context.cgContext) } let detailAttachment = XCTAttachment(image: detailScreenshot) detailAttachment.name = "AI修图任务详情设计还原" detailAttachment.lifetime = .keepAlways add(detailAttachment) } func testAIJobListCardMatchesDesignStructureAndKeepsSinglePreviewInGrid() async throws { let api = TravelAlbumMockAPI() let job = TravelAlbumAIJobSummary( aiRetouchBatchId: 59, userEquityTravelId: 17, scope: "batch", status: .queued, album: TravelAlbumAIJobAlbum( id: 17, name: "2026-06-17-005", userPhone: "13222319413", coverURL: "" ), sourceCount: 1, outputs: [ TravelAlbumAIJobOutput(type: .refined, count: 1), TravelAlbumAIJobOutput(type: .atmosphere, count: 1), ], previewImages: [TravelAlbumAIJobPreviewImage(materialId: 691, thumbnailURL: "")], progress: TravelAlbumAIJobProgress( total: 2, queued: 2, processing: 0, succeeded: 0, failed: 0, canceled: 0 ), estimatedFinishAt: "2026-08-14T03:23:34.147Z", failureSummary: nil, createdAt: "2026-08-14T03:20:43.000Z", startedAt: nil, finishedAt: nil ) api.aiJobListResponses = [ TravelAlbumAIJobListResponse(items: [job], nextCursor: nil, hasMore: false), ] let controller = TravelAlbumAIJobListViewController(api: api) let navigationController = UINavigationController(rootViewController: controller) let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) window.rootViewController = navigationController window.makeKeyAndVisible() defer { window.isHidden = true } controller.loadViewIfNeeded() await waitUntil { api.aiJobListRequests.count == 1 } try await Task.sleep(for: .milliseconds(150)) controller.view.layoutIfNeeded() let collectionView = try XCTUnwrap( controller.view.findSubview { $0 is UICollectionView } as? UICollectionView ) collectionView.layoutIfNeeded() let cell = try XCTUnwrap(collectionView.cellForItem(at: IndexPath(item: 0, section: 0))) let albumCover = try XCTUnwrap( cell.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.albumCover" } ) let firstPreview = try XCTUnwrap( cell.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.preview.0" } ) let filterContainer = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.filterContainer" } ) XCTAssertEqual(filterContainer.bounds.height, 46, accuracy: 0.5) XCTAssertEqual(albumCover.bounds.width, 68, accuracy: 2) XCTAssertEqual(albumCover.bounds.height, 68, accuracy: 2) XCTAssertLessThan(firstPreview.bounds.width, cell.bounds.width * 0.4) XCTAssertTrue(cell.allLabels().contains { $0.text == "排队中" }) XCTAssertTrue(cell.allLabels().contains { $0.text == "已完成 0 / 2" }) XCTAssertTrue(cell.allLabels().contains { $0.text == "0%" }) XCTAssertTrue(cell.allLabels().contains { $0.text == "精修 1张 · 氛围感 1张" }) XCTAssertTrue(cell.allLabels().contains { $0.text == "查看详情" }) let listScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in window.layer.render(in: context.cgContext) } let listAttachment = XCTAttachment(image: listScreenshot) listAttachment.name = "AI修图任务列表设计还原" listAttachment.lifetime = .keepAlways add(listAttachment) } func testAIJobEmptyInProgressFilterDismissesGlobalLoading() async throws { GlobalLoadingManager.shared.hideAll() defer { GlobalLoadingManager.shared.hideAll() } let api = TravelAlbumMockAPI() api.aiJobListDelayNanoseconds = 30_000_000 api.aiJobListResponses = [ TravelAlbumAIJobListResponse(items: [], nextCursor: nil, hasMore: false), TravelAlbumAIJobListResponse(items: [], nextCursor: nil, hasMore: false), ] let controller = TravelAlbumAIJobListViewController(api: api) controller.loadViewIfNeeded() controller.viewDidAppear(false) defer { controller.viewWillDisappear(false) } await waitUntil { api.aiJobListRequests.count == 1 && !GlobalLoadingManager.shared.isShowing } let inProgressButton = try XCTUnwrap( controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "筛选:进行中" } as? UIButton ) inProgressButton.sendActions(for: .touchUpInside) await waitUntil { api.aiJobListRequests.count == 2 && api.aiJobListRequests.last?.statusGroup == .inProgress && !GlobalLoadingManager.shared.isShowing } XCTAssertFalse(GlobalLoadingManager.shared.isShowing) } func testAIJobEntryOnlyAppearsOnAlbumManagementNavigationBar() { let detail = TravelAlbumDetailViewController(albumId: 2, api: TravelAlbumMockAPI()) detail.setupNavigationBar() let entry = TravelAlbumEntryViewController(api: TravelAlbumMockAPI()) entry.setupNavigationBar() XCTAssertTrue(detail.navigationItem.rightBarButtonItems?.contains { $0.title == "修图任务" } == true) XCTAssertNil(entry.navigationItem.rightBarButtonItem) XCTAssertTrue(entry.navigationItem.rightBarButtonItems?.isEmpty ?? true) } func testPullToRefreshReloadsGridAndEndsRefreshing() async throws { let api = TravelAlbumMockAPI() api.infoResponse = TravelAlbum(id: 2, name: "测试相册") api.materialListResponses = [ TravelAlbumListResponse(total: 1, list: []), TravelAlbumListResponse(total: 0, list: []), TravelAlbumListResponse(total: 1, list: [TravelAlbumMaterial(id: 1)]), TravelAlbumListResponse(total: 2, list: []), TravelAlbumListResponse(total: 1, list: []), TravelAlbumListResponse( total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)] ), ] let controller = TravelAlbumDetailViewController(albumId: 2, api: api) let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) window.rootViewController = controller window.makeKeyAndVisible() defer { window.isHidden = true } controller.loadViewIfNeeded() controller.view.layoutIfNeeded() let collectionView = try XCTUnwrap( controller.view.findSubview { $0 is UICollectionView } as? UICollectionView ) let refreshControl = try XCTUnwrap(collectionView.refreshControl) await waitUntil { api.materialRequests.count == 3 } refreshControl.beginRefreshing() XCTAssertTrue(refreshControl.isRefreshing) refreshControl.sendActions(for: .valueChanged) await waitUntil { api.materialRequests.count == 6 && !refreshControl.isRefreshing && collectionView.numberOfItems(inSection: 0) == 2 } XCTAssertEqual(refreshControl.accessibilityIdentifier, "travelAlbum.refreshControl") XCTAssertEqual(collectionView.numberOfItems(inSection: 0), 2) XCTAssertFalse(refreshControl.isRefreshing) } func testPreviewDeleteHandlerCallsMaterialAPIAndPreservesServerFailure() async { let api = TravelAlbumMockAPI() let handler = TravelAlbumPreviewActionHandler(api: api) let success = await handler.deleteProject(originalMaterialId: 8) XCTAssertEqual(api.deletedMaterialIDBatches, [[8]]) XCTAssertEqual(success, .success("删除成功")) api.deleteMaterialError = APIError.serverCode(500, "服务暂不可用") let failure = await handler.deleteProject(originalMaterialId: 9) XCTAssertEqual(api.deletedMaterialIDBatches, [[8], [9]]) XCTAssertEqual(failure, .failure("服务暂不可用")) } func testPreviewCounterUsesHorizontalPaddingAndContentDrivenWidth() throws { let controller = TravelAlbumPhotoPreviewViewController( projects: [makePreviewProject(id: 1, fileName: "原图.jpg")], totalCount: 10_000, startProjectIndex: 0 ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) controller.view.layoutIfNeeded() let counterLabel = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewCounterLabel" } as? UILabel ) let counterContainer = try XCTUnwrap(counterLabel.superview) let expectedWidth = max(48, counterLabel.intrinsicContentSize.width + 24) XCTAssertEqual(counterContainer.bounds.width, expectedWidth, accuracy: 0.5) XCTAssertEqual(counterLabel.frame.minX, 12, accuracy: 0.5) XCTAssertEqual(counterContainer.bounds.width - counterLabel.frame.maxX, 12, accuracy: 0.5) } func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws { let api = TravelAlbumMockAPI() api.deleteMaterialDelayNanoseconds = 1_000_000 var deletedIds: [Int] = [] let controller = TravelAlbumPhotoPreviewViewController( projects: [ makePreviewProject(id: 1, fileName: "第一张.jpg"), makePreviewProject(id: 2, fileName: "第二张.jpg"), makePreviewProject(id: 3, fileName: "第三张.jpg"), ], totalCount: 3, startProjectIndex: 0, actionHandler: TravelAlbumPreviewActionHandler(api: api), onProjectDeleted: { deletedIds.append($0) } ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) controller.view.layoutIfNeeded() controller.deleteCurrentProjectAfterConfirmation() controller.deleteCurrentProjectAfterConfirmation() await waitUntil { controller.view.allAccessibilityLabels().contains("第 1 张,共 2 张") } XCTAssertEqual(api.deletedMaterialIDBatches, [[1]]) XCTAssertEqual(deletedIds, [1]) XCTAssertTrue( controller.view.allLabels().contains { $0.text == "第二张.jpg" } ) } func testPreviewDeletingLastProjectShowsPreviousProject() async { let api = TravelAlbumMockAPI() let controller = TravelAlbumPhotoPreviewViewController( projects: [ makePreviewProject(id: 1, fileName: "第一张.jpg"), makePreviewProject(id: 2, fileName: "第二张.jpg"), makePreviewProject(id: 3, fileName: "第三张.jpg"), ], totalCount: 3, startProjectIndex: 2, actionHandler: TravelAlbumPreviewActionHandler(api: api) ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) controller.view.layoutIfNeeded() controller.deleteCurrentProjectAfterConfirmation() await waitUntil { controller.view.allAccessibilityLabels().contains("第 2 张,共 2 张") } XCTAssertEqual(api.deletedMaterialIDBatches, [[3]]) XCTAssertTrue( controller.view.allLabels().contains { $0.text == "第二张.jpg" } ) } func testPreviewDeletingOnlyProjectDismissesPage() async { UIView.setAnimationsEnabled(false) defer { UIView.setAnimationsEnabled(true) } let api = TravelAlbumMockAPI() let host = UIViewController() let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) window.rootViewController = host window.makeKeyAndVisible() let controller = TravelAlbumPhotoPreviewViewController( projects: [makePreviewProject(id: 1, fileName: "仅有一张.jpg")], totalCount: 1, startProjectIndex: 0, actionHandler: TravelAlbumPreviewActionHandler(api: api) ) host.present(controller, animated: false) await waitUntil { controller.presentingViewController === host } controller.deleteCurrentProjectAfterConfirmation() await waitUntil { host.presentedViewController == nil } XCTAssertEqual(api.deletedMaterialIDBatches, [[1]]) XCTAssertNil(host.presentedViewController) window.isHidden = true } func testPreviewImageViewKeepsViewportConstraintsWhenSwitchingVariant() throws { UIView.setAnimationsEnabled(false) defer { UIView.setAnimationsEnabled(true) } let project = TravelAlbumPreviewProject( originalMaterialId: 1, assets: [ TravelAlbumPreviewAsset( id: "original-1", kind: .original, fileURL: "", coverURL: "", fileName: "原图.jpg", fileSize: 0 ), TravelAlbumPreviewAsset( id: "retouched-1", kind: .retouched, fileURL: "", coverURL: "", fileName: "精修后.jpg", fileSize: 0 ), ] ) let controller = TravelAlbumPhotoPreviewViewController( projects: [project], totalCount: 1, startProjectIndex: 0 ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) controller.view.layoutIfNeeded() let segmentedControl = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl" } as? UISegmentedControl ) segmentedControl.selectedSegmentIndex = 1 segmentedControl.sendActions(for: .valueChanged) controller.view.layoutIfNeeded() let imageView = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewImageView" } as? UIImageView ) let scrollView = try XCTUnwrap(imageView.superview as? UIScrollView) scrollView.layoutIfNeeded() XCTAssertEqual(imageView.bounds.size, scrollView.bounds.size) XCTAssertFalse(imageView.constraintsAffectingLayout(for: .horizontal).isEmpty) XCTAssertFalse(imageView.constraintsAffectingLayout(for: .vertical).isEmpty) } func testPreviewUsesScreenshotChromeOverFullScreenImageViewport() throws { UIView.setAnimationsEnabled(false) defer { UIView.setAnimationsEnabled(true) } let project = TravelAlbumPreviewProject( originalMaterialId: 1, assets: [ TravelAlbumPreviewAsset( id: "original-1", kind: .original, fileURL: "https://example.com/original.jpg", coverURL: "https://example.com/cover.jpg", fileName: "IMG_0002.jpeg", fileSize: 2_516_582 ), TravelAlbumPreviewAsset( id: "retouched-1", kind: .retouched, fileURL: "", coverURL: "", fileName: "IMG_0002.jpeg", fileSize: 2_516_582 ), TravelAlbumPreviewAsset( id: "atmosphere-1", kind: .atmosphere, fileURL: "", coverURL: "", fileName: "IMG_0002.jpeg", fileSize: 2_516_582 ), ] ) let controller = TravelAlbumPhotoPreviewViewController( projects: [project], totalCount: 10, startProjectIndex: 0 ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) controller.view.layoutIfNeeded() let topChrome = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewTopChrome" } ) let bottomChrome = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewBottomChrome" } ) let collectionView = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewCollectionView" } as? UICollectionView ) let highResolutionButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewHighResolutionButton" } as? UIButton ) let segmentedControl = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl" } as? UISegmentedControl ) let backButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewBackButton" } as? UIButton ) let actionStack = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewActionStack" } as? UIStackView ) let aiRetouchButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewAIRetouchButton" } as? UIButton ) let deleteButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewDeleteButton" } as? UIButton ) let refreshButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewRefreshButton" } as? UIButton ) XCTAssertFalse(controller.prefersStatusBarHidden) XCTAssertEqual(topChrome.backgroundColor, UIColor.black.withAlphaComponent(0.82)) XCTAssertEqual(bottomChrome.backgroundColor, UIColor.black.withAlphaComponent(0.82)) XCTAssertEqual(highResolutionButton.configuration?.title, "查看高清图") XCTAssertEqual(segmentedControl.numberOfSegments, 3) XCTAssertEqual(segmentedControl.selectedSegmentIndex, 0) XCTAssertFalse(segmentedControl.apportionsSegmentWidthsByContent) XCTAssertEqual(segmentedControl.selectedSegmentTintColor, UIColor.white.withAlphaComponent(0.2)) XCTAssertEqual( segmentedControl.titleTextAttributes(for: .normal)?[.foregroundColor] as? UIColor, UIColor.white.withAlphaComponent(0.7) ) XCTAssertEqual( segmentedControl.titleTextAttributes(for: .selected)?[.foregroundColor] as? UIColor, UIColor.white ) XCTAssertNil(segmentedControl.backgroundImage(for: .selected, barMetrics: .default)) XCTAssertNil( segmentedControl.dividerImage( forLeftSegmentState: .normal, rightSegmentState: .normal, barMetrics: .default ) ) XCTAssertEqual(segmentedControl.bounds.width, 180, accuracy: 0.5) XCTAssertEqual(segmentedControl.bounds.height, 32, accuracy: 1) XCTAssertGreaterThanOrEqual(highResolutionButton.frame.minX - segmentedControl.frame.maxX, 12) XCTAssertEqual(backButton.bounds.size, CGSize(width: 44, height: 44)) XCTAssertEqual(backButton.configuration?.image, UIImage(systemName: "chevron.down")) XCTAssertNil(backButton.configuration?.baseBackgroundColor) XCTAssertEqual(actionStack.spacing, 10) XCTAssertTrue(actionStack.isLayoutMarginsRelativeArrangement) XCTAssertEqual(actionStack.directionalLayoutMargins.top, 9) XCTAssertEqual(actionStack.arrangedSubviews, [aiRetouchButton, deleteButton, refreshButton]) XCTAssertEqual(aiRetouchButton.bounds.height, 52, accuracy: 0.5) XCTAssertEqual(deleteButton.bounds.height, 52, accuracy: 0.5) XCTAssertEqual(refreshButton.bounds.height, 52, accuracy: 0.5) XCTAssertEqual(aiRetouchButton.configuration?.baseBackgroundColor?.travelAlbumTestHexRGB, 0x1677FF) XCTAssertEqual(deleteButton.configuration?.baseForegroundColor?.travelAlbumTestHexRGB, 0xFF6B6B) XCTAssertEqual(refreshButton.configuration?.baseBackgroundColor?.travelAlbumTestHexRGB, 0x1C1C1E) XCTAssertEqual(aiRetouchButton.configuration?.imagePlacement, .leading) XCTAssertEqual(collectionView.frame, controller.view.bounds) XCTAssertLessThan(collectionView.frame.minY, topChrome.frame.maxY) XCTAssertGreaterThan(collectionView.frame.maxY, bottomChrome.frame.minY) XCTAssertEqual(collectionView.contentInsetAdjustmentBehavior, .never) XCTAssertTrue(controller.view.allLabels().contains { $0.text == "IMG_0002.jpeg" }) XCTAssertTrue(controller.view.allLabels().contains { $0.text == "2.4MB" }) XCTAssertTrue(controller.view.allLabels().contains { $0.text == "1/10" }) highResolutionButton.sendActions(for: .touchUpInside) XCTAssertEqual(topChrome.alpha, 0) XCTAssertEqual(bottomChrome.alpha, 0) } func testPreviewHidesVariantControlsAndHighResolutionButtonWithoutVariants() throws { let project = TravelAlbumPreviewProject( originalMaterialId: 1, assets: [ TravelAlbumPreviewAsset( id: "original-1", kind: .original, fileURL: "https://example.com/original.jpg", coverURL: "https://example.com/cover.jpg", fileName: "IMG_0002.jpeg", fileSize: 2_516_582 ), ] ) let controller = TravelAlbumPhotoPreviewViewController( projects: [project], totalCount: 1, startProjectIndex: 0 ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) controller.view.layoutIfNeeded() let segmentedControl = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl" } as? UISegmentedControl ) let highResolutionButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewHighResolutionButton" } as? UIButton ) let collectionView = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewCollectionView" } as? UICollectionView ) let bottomChrome = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewBottomChrome" } ) XCTAssertTrue(segmentedControl.isHidden) XCTAssertTrue(highResolutionButton.isHidden) XCTAssertEqual(collectionView.frame, controller.view.bounds) XCTAssertGreaterThan(collectionView.frame.maxY, bottomChrome.frame.minY) } func testSelectionModeShowsAIRetouchOnLeftAndDeleteOnRight() async throws { let controller = TravelAlbumDetailViewController(albumId: 0) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) let selectButton = try XCTUnwrap( controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "选择照片" } as? UIButton ) selectButton.sendActions(for: .touchUpInside) await Task.yield() controller.view.layoutIfNeeded() let actionStack = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.selectionActionStack" } as? UIStackView ) let aiRetouchButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchButton" } as? UIButton ) let deleteButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.deleteButton" } as? UIButton ) XCTAssertFalse(actionStack.isHidden) XCTAssertEqual(actionStack.arrangedSubviews, [aiRetouchButton, deleteButton]) XCTAssertEqual(actionStack.distribution, .fillEqually) XCTAssertEqual(actionStack.spacing, 12) XCTAssertEqual(aiRetouchButton.configuration?.title, "AI修图") XCTAssertEqual(deleteButton.configuration?.title, "删除") XCTAssertEqual(aiRetouchButton.bounds.width, deleteButton.bounds.width, accuracy: 0.5) XCTAssertLessThan(aiRetouchButton.frame.minX, deleteButton.frame.minX) } func testAIRetouchTemplateSheetUsesLargeDetentAndFixedBottomActions() async throws { let api = TravelAlbumMockAPI() api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")], atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")], coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")], remainingQuota: 8 ) let viewModel = TravelAlbumAIRetouchTemplateViewModel( albumId: 9, scenicId: 18, materialIds: [1, 2, 3, 4] ) let controller = TravelAlbumAIRetouchTemplateViewController( viewModel: viewModel, api: api, onSubmitted: { _ in } ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 } controller.view.layoutIfNeeded() let sheet = try XCTUnwrap(controller.sheetPresentationController) XCTAssertEqual(sheet.detents.count, 1) XCTAssertEqual(sheet.selectedDetentIdentifier, .large) XCTAssertTrue(sheet.prefersGrabberVisible) let collectionView = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection" } as? UICollectionView ) let bottomBar = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchBottomBar" } ) let cancelButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchCancelButton" } as? UIButton ) let confirmButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton" } as? UIButton ) await waitUntil { confirmButton.isEnabled } controller.view.layoutIfNeeded() let selectionCountLabel = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel" } as? UILabel ) let tipsLabel = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsLabel" } as? UILabel ) let tipsContainer = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsContainer" } ) XCTAssertTrue(collectionView.collectionViewLayout is UICollectionViewCompositionalLayout) XCTAssertEqual(selectionCountLabel.text, "已选择 4 张照片") XCTAssertEqual( tipsLabel.text, "Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。" ) XCTAssertFalse(tipsContainer.isHidden) XCTAssertEqual(tipsContainer.backgroundColor?.travelAlbumTestHexRGB, 0xF4F8FF) XCTAssertEqual(tipsLabel.textColor.travelAlbumTestHexRGB, 0x385778) XCTAssertEqual(tipsContainer.layer.cornerRadius, 12) XCTAssertTrue(tipsContainer.isDescendant(of: collectionView)) XCTAssertFalse(bottomBar.isDescendant(of: collectionView)) XCTAssertEqual(cancelButton.currentTitle, "取消") XCTAssertEqual(confirmButton.currentTitle, "确定") XCTAssertLessThan(cancelButton.bounds.width, confirmButton.bounds.width) XCTAssertTrue(confirmButton.isEnabled) } func testAIRetouchSheetShowsOptionalAtmosphereCoverAndModeCopy() async throws { let api = TravelAlbumMockAPI() api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")], atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")], coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")], remainingQuota: 8 ) let viewModel = TravelAlbumAIRetouchTemplateViewModel( albumId: 9, scenicId: 18, materialIds: [1, 2, 3, 4] ) let controller = TravelAlbumAIRetouchTemplateViewController( viewModel: viewModel, api: api, onSubmitted: { _ in } ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 } controller.view.layoutIfNeeded() let collectionView = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection" } as? UICollectionView ) let accessibleLabels = controller.view.allAccessibilityLabels() XCTAssertTrue(accessibleLabels.contains("原图精修")) XCTAssertFalse(accessibleLabels.contains("原图精修,必选")) XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填")) XCTAssertTrue(accessibleLabels.contains("封面风格,赠送 · 不占额度")) collectionView.scrollToItem( at: IndexPath(item: 0, section: 4), at: .top, animated: false ) controller.view.layoutIfNeeded() let quotaLabel = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchRemainingQuotaLabel" } as? UILabel ) let requiredQuotaLabel = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchRequiredQuotaLabel" } as? UILabel ) XCTAssertEqual(requiredQuotaLabel.text, "预计消耗 4 张") XCTAssertEqual(requiredQuotaLabel.accessibilityLabel, "预计消耗4张修图额度") XCTAssertEqual(quotaLabel.text, "剩余 8 张") XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8张修图额度") } func testAIRetouchTemplateCellExposesSelectedState() { let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero) let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "") var previewTapped = false cell.onPreviewTapped = { previewTapped = true } cell.apply(template: template, selected: true) let previewButton = cell.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewButton" } as? UIButton let previewImageView = cell.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateImage" } as? UIImageView previewButton?.sendActions(for: .touchUpInside) XCTAssertNotNil(UIImage(named: "ai_retouch_template_placeholder")) XCTAssertNotNil(previewImageView?.image) XCTAssertEqual(cell.contentView.layer.borderWidth, 2) XCTAssertTrue(cell.accessibilityTraits.contains(.selected)) XCTAssertEqual(cell.accessibilityValue, "已选择") XCTAssertTrue(previewTapped) cell.apply(template: template, selected: false) XCTAssertEqual(cell.contentView.layer.borderWidth, 1) XCTAssertFalse(cell.accessibilityTraits.contains(.selected)) XCTAssertEqual(cell.accessibilityValue, "未选择") } func testBeforeAfterComparisonUsesImmersiveAccessibleLayout() throws { let controller = BeforeAfterComparisonViewController( viewModel: BeforeAfterComparisonViewModel( content: BeforeAfterComparisonContent( beforeURL: "https://cdn.example.com/before.jpg", afterURL: "https://cdn.example.com/after.jpg" ) ) ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) controller.view.layoutIfNeeded() let imageContainer = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.beforeAfterImageContainer" } ) let backButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.beforeAfterBackButton" } as? UIButton ) let divider = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.beforeAfterDivider" } ) XCTAssertEqual(controller.view.backgroundColor, UIColor(hex: 0x05070D)) XCTAssertTrue(controller.view.allLabels().contains { $0.text == "原图" }) XCTAssertTrue(controller.view.allLabels().contains { $0.text == "效果图" }) XCTAssertEqual(imageContainer.layer.cornerRadius, 0) XCTAssertTrue(divider.accessibilityTraits.contains(.adjustable)) XCTAssertEqual(divider.accessibilityValue, "原图占比 50%") XCTAssertGreaterThanOrEqual(backButton.bounds.width, 44) XCTAssertGreaterThanOrEqual(backButton.bounds.height, 44) } func testPreviewComparisonButtonOnlyShowsForSelectedAIResult() throws { UIView.setAnimationsEnabled(false) defer { UIView.setAnimationsEnabled(true) } let project = TravelAlbumPreviewProject( originalMaterialId: 7, assets: [ TravelAlbumPreviewAsset( id: "original-7", kind: .original, fileURL: "https://cdn.example.com/original.jpg", coverURL: "", fileName: "原图.jpg", fileSize: 10 ), TravelAlbumPreviewAsset( id: "retouched-7", kind: .retouched, fileURL: "https://cdn.example.com/retouched.jpg", coverURL: "", fileName: "精修.jpg", fileSize: 10 ), ] ) let controller = TravelAlbumPhotoPreviewViewController( projects: [project], totalCount: 1, startProjectIndex: 0 ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) controller.view.layoutIfNeeded() let segmentedControl = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl" } as? UISegmentedControl ) let comparisonButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewComparisonButton" } as? UIButton ) XCTAssertTrue(comparisonButton.isHidden) segmentedControl.selectedSegmentIndex = 1 segmentedControl.sendActions(for: .valueChanged) XCTAssertFalse(comparisonButton.isHidden) XCTAssertNil(comparisonButton.configuration?.title) XCTAssertNotNil(comparisonButton.configuration?.image) XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48)) } func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndShowsTaskAction() async throws { UIView.setAnimationsEnabled(false) defer { UIView.setAnimationsEnabled(true) } let api = TravelAlbumMockAPI() api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 11, name: "清透", previewURL: "")], atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "")], remainingQuota: 8 ) let project = TravelAlbumPreviewProject( originalMaterialId: 7, aiRetouchBatchId: 70, assets: [ TravelAlbumPreviewAsset( id: "original-7", kind: .original, fileURL: "", coverURL: "", fileName: "原图.jpg", fileSize: 0 ), TravelAlbumPreviewAsset( id: "retouched-7", kind: .retouched, fileURL: "", coverURL: "", fileName: "精修后.jpg", fileSize: 0 ), ] ) let controller = TravelAlbumPhotoPreviewViewController( projects: [project], totalCount: 1, startProjectIndex: 0, albumId: 8, scenicIdProvider: { 18 }, aiRetouchAPI: api ) let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) window.rootViewController = controller window.makeKeyAndVisible() defer { window.isHidden = true } controller.loadViewIfNeeded() controller.view.layoutIfNeeded() let segmentedControl = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl" } as? UISegmentedControl ) segmentedControl.selectedSegmentIndex = 1 segmentedControl.sendActions(for: .valueChanged) let aiButton = try XCTUnwrap( controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "AI修图" } as? UIButton ) aiButton.sendActions(for: .touchUpInside) await waitUntil { controller.presentedViewController is TravelAlbumAIRetouchTemplateViewController } let sheet = try XCTUnwrap(controller.presentedViewController as? TravelAlbumAIRetouchTemplateViewController) sheet.loadViewIfNeeded() let confirmButton = try XCTUnwrap( sheet.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton" } as? UIButton ) await waitUntil { api.aiRetouchTemplateScenicIds.count == 1 && confirmButton.isEnabled } sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) sheet.view.layoutIfNeeded() let visibleLabelTexts = sheet.view.allLabels().compactMap { label in label.isHidden ? nil : label.text } XCTAssertTrue(visibleLabelTexts.contains("原图精修")) XCTAssertFalse(visibleLabelTexts.contains { $0.contains("氛围感修图") }) let selectionCountLabel = try XCTUnwrap( sheet.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel" } as? UILabel ) let tipsLabel = try XCTUnwrap( sheet.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsLabel" } as? UILabel ) let tipsContainer = try XCTUnwrap( sheet.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsContainer" } ) XCTAssertEqual(selectionCountLabel.text, "已选择 1 张照片") XCTAssertTrue(tipsContainer.isHidden) XCTAssertTrue(tipsLabel.isHidden) confirmButton.sendActions(for: .touchUpInside) await waitUntil { api.aiReretouchRequests.count == 1 } await waitUntil { controller.presentedViewController is UIAlertController } XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined) let successAlert = try XCTUnwrap(controller.presentedViewController as? UIAlertController) XCTAssertEqual(successAlert.title, "AI修图任务已提交") XCTAssertEqual(successAlert.actions.map(\.title), ["知道了", "查看任务"]) XCTAssertTrue(window.rootViewController === controller) } func testPreviewRefreshPreservesVariantAndFallsBackWhenVariantDisappears() async throws { let original = TravelAlbumPreviewAsset( id: "original-7", kind: .original, fileURL: "", coverURL: "", fileName: "原图.jpg", fileSize: 0 ) let retouched = TravelAlbumPreviewAsset( id: "retouched-7", kind: .retouched, fileURL: "", coverURL: "", fileName: "精修后.jpg", fileSize: 0 ) var reloadCount = 0 let controller = TravelAlbumPhotoPreviewViewController( projects: [TravelAlbumPreviewProject(originalMaterialId: 7, assets: [original, retouched])], totalCount: 1, startProjectIndex: 0, reload: { materialId in XCTAssertEqual(materialId, 7) reloadCount += 1 let assets = reloadCount == 1 ? [original, retouched] : [original] return TravelAlbumPreviewProject(originalMaterialId: 7, assets: assets) } ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) controller.view.layoutIfNeeded() let segmentedControl = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl" } as? UISegmentedControl ) segmentedControl.selectedSegmentIndex = 1 segmentedControl.sendActions(for: .valueChanged) let refreshButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewRefreshButton" } as? UIButton ) refreshButton.sendActions(for: .touchUpInside) await waitUntil { reloadCount == 1 && refreshButton.isEnabled } XCTAssertEqual(segmentedControl.selectedSegmentIndex, 1) refreshButton.sendActions(for: .touchUpInside) await waitUntil { reloadCount == 2 && refreshButton.isEnabled } XCTAssertTrue(segmentedControl.isHidden) XCTAssertEqual(segmentedControl.numberOfSegments, 0) XCTAssertTrue(controller.view.allLabels().contains { $0.text == "原图.jpg" }) } func testPreviewRefreshUsesGlobalLoadingWithoutToast() async throws { GlobalLoadingManager.shared.hideAll() defer { GlobalLoadingManager.shared.hideAll() } let original = TravelAlbumPreviewAsset( id: "original-7", kind: .original, fileURL: "", coverURL: "", fileName: "原图.jpg", fileSize: 0 ) let project = TravelAlbumPreviewProject(originalMaterialId: 7, assets: [original]) let controller = TravelAlbumPhotoPreviewViewController( projects: [project], totalCount: 1, startProjectIndex: 0, reload: { _ in try await Task.sleep(nanoseconds: 50_000_000) return project } ) controller.loadViewIfNeeded() let refreshButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.previewRefreshButton" } as? UIButton ) refreshButton.sendActions(for: .touchUpInside) XCTAssertTrue(GlobalLoadingManager.shared.isShowing) await waitUntil { !GlobalLoadingManager.shared.isShowing && refreshButton.isEnabled } XCTAssertFalse(GlobalLoadingManager.shared.isShowing) XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新成功" }) XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新失败" }) } func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws { let cases: [(TravelAlbumMaterial, String, UInt)] = [ (TravelAlbumMaterial(isPurchased: true), "已购", 0x475569), (TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309), (TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8), (TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857), ( TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面"), "AI封面", 0x6D28D9 ), (TravelAlbumMaterial(aiRetouchStatus: 4), "失败", 0xB91C1C), ] for (material, expectedText, expectedColor) in cases { let cell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220)) cell.apply(material: material, selectionMode: true, selected: false) cell.layoutIfNeeded() let badgeView = try XCTUnwrap( cell.findSubview { $0.accessibilityIdentifier == "travelAlbum.materialStatusBadge" } ) let badgeLabel = try XCTUnwrap( cell.findSubview { $0.accessibilityIdentifier == "travelAlbum.materialStatusBadgeLabel" } as? UILabel ) let selectionCheck = try XCTUnwrap( cell.findSubview { $0.accessibilityIdentifier == "travelAlbum.materialSelectionCheck" } ) XCTAssertFalse(badgeView.isHidden) XCTAssertEqual(badgeLabel.text, expectedText) XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor) XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX) XCTAssertTrue(cell.accessibilityLabel?.contains("状态:\(expectedText)") == true) } let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220)) hiddenCell.apply( material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"), selectionMode: false, selected: false ) let hiddenBadge = try XCTUnwrap( hiddenCell.findSubview { $0.accessibilityIdentifier == "travelAlbum.materialStatusBadge" } ) XCTAssertTrue(hiddenBadge.isHidden) XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("状态:") == true) } private func waitUntil(_ condition: @escaping () -> Bool) async { for _ in 0 ..< 200 { if condition() { return } try? await Task.sleep(nanoseconds: 1_000_000) } } private func makePreviewProject(id: Int, fileName: String) -> TravelAlbumPreviewProject { TravelAlbumPreviewProject( originalMaterialId: id, assets: [ TravelAlbumPreviewAsset( id: "original-\(id)", kind: .original, fileURL: "", coverURL: "", fileName: fileName, fileSize: 0 ), ] ) } } private extension UIColor { var travelAlbumTestHexRGB: UInt { var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 getRed(&red, green: &green, blue: &blue, alpha: &alpha) return UInt(round(red * 255)) << 16 | UInt(round(green * 255)) << 8 | UInt(round(blue * 255)) } } private extension UIView { func findSubview(where predicate: (UIView) -> Bool) -> UIView? { if predicate(self) { return self } for subview in subviews { if let match = subview.findSubview(where: predicate) { return match } } return nil } func allAccessibilityLabels() -> [String] { let current = accessibilityLabel.map { [$0] } ?? [] return current + subviews.flatMap { $0.allAccessibilityLabels() } } func allLabels() -> [UILabel] { let current = (self as? UILabel).map { [$0] } ?? [] return current + subviews.flatMap { $0.allLabels() } } }