Files
suixinkan_uikit/suixinkanTests/TravelAlbumDetailViewControllerTests.swift
T

1267 lines
53 KiB
Swift

//
// 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:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
)
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("原图精修,必选"))
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 testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndNotifiesSubmission() 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
),
]
)
var submissionNotified = false
let controller = TravelAlbumPhotoPreviewViewController(
projects: [project],
totalCount: 1,
startProjectIndex: 0,
albumId: 8,
scenicIdProvider: { 18 },
aiRetouchAPI: api,
onAIRetouchSubmitted: { submissionNotified = true }
)
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 { submissionNotified }
XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined)
XCTAssertTrue(submissionNotified)
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() }
}
}