// // TravelAlbumDetailViewControllerTests.swift // suixinkanTests // import UIKit import XCTest @testable import suixinkan /// 相册管理页刷新、预览与选择态交互测试。 @MainActor final class TravelAlbumDetailViewControllerTests: XCTestCase { 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 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) XCTAssertEqual(backButton.bounds.size, CGSize(width: 44, height: 44)) 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: {} ) 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, "每张照片生成精修结果,氛围感可选;本次可免费生成 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: {} ) 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 testAIRetouchTemplatePreviewUsesImmersiveZoomableLayout() throws { let template = TravelAlbumAIRetouchTemplate( id: 2, name: "简约留白", previewURL: "" ) let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) controller.view.layoutIfNeeded() let scrollView = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewScrollView" } as? UIScrollView ) let backButton = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewBackButton" } as? UIButton ) XCTAssertEqual(controller.view.backgroundColor, .black) XCTAssertTrue(controller.view.allLabels().contains { $0.text == "简约留白" }) XCTAssertTrue(controller.view.allLabels().contains { $0.text == "双指缩放查看细节" }) XCTAssertEqual(scrollView.minimumZoomScale, 1) XCTAssertEqual(scrollView.maximumZoomScale, 4) XCTAssertGreaterThanOrEqual(backButton.bounds.width, 44) XCTAssertGreaterThanOrEqual(backButton.bounds.height, 44) } func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() 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 == nil } XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined) XCTAssertNil(controller.presentedViewController) 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() } } }