// // TravelAlbumDetailViewControllerTests.swift // suixinkanTests // import UIKit import XCTest @testable import suixinkan /// 相册管理页选择态底部操作区测试。 @MainActor final class TravelAlbumDetailViewControllerTests: XCTestCase { func testPreviewDeleteHandlerCallsMaterialAPIAndPreservesServerFailure() async { let api = TravelAlbumMockAPI() let handler = TravelAlbumPreviewActionHandler(api: api) let success = await handler.deleteProject(originalMaterialId: 8) XCTAssertEqual(api.deletedMaterialIds, [8]) XCTAssertEqual(success, .success("删除成功")) api.deleteMaterialError = APIError.serverCode(500, "服务暂不可用") let failure = await handler.deleteProject(originalMaterialId: 9) XCTAssertEqual(api.deletedMaterialIds, [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.deletedMaterialIds, [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.deletedMaterialIds, [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.deletedMaterialIds, [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 retouchedButton = try XCTUnwrap( controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "精修后" } as? UIButton ) retouchedButton.sendActions(for: .touchUpInside) 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 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: "")] ) 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() XCTAssertTrue(collectionView.collectionViewLayout is UICollectionViewCompositionalLayout) XCTAssertFalse(bottomBar.isDescendant(of: collectionView)) XCTAssertEqual(cancelButton.configuration?.title, "取消") XCTAssertEqual(confirmButton.configuration?.title, "确定") XCTAssertEqual(cancelButton.bounds.width, confirmButton.bounds.width, accuracy: 0.5) 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: "")] ) 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 accessibleLabels = controller.view.allAccessibilityLabels() XCTAssertTrue(accessibleLabels.contains("原图精修")) XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填")) XCTAssertTrue(accessibleLabels.contains("封面风格模板")) let modeCell = TravelAlbumAIRetouchModeCell(frame: .zero) modeCell.apply() XCTAssertEqual(modeCell.accessibilityLabel, "AI精修,按张收费,剩余张数暂不可用") } func testAIRetouchTemplateCellExposesSelectedState() { let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero) let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "") cell.apply(template: template, selected: true) XCTAssertEqual(cell.contentView.layer.borderWidth, 2) XCTAssertTrue(cell.accessibilityTraits.contains(.selected)) XCTAssertEqual(cell.accessibilityValue, "已选择") cell.apply(template: template, selected: false) XCTAssertEqual(cell.contentView.layer.borderWidth, 1) XCTAssertFalse(cell.accessibilityTraits.contains(.selected)) XCTAssertEqual(cell.accessibilityValue, "未选择") } 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() } } }