1767 lines
80 KiB
Swift
1767 lines
80 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: "beauty_1757851944526_这个文件标题特别长用于验证布局不会挤压状态.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 contentCard = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "aiRetouchJob.detail.contentCard"
|
||
})
|
||
let refinedChip = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.refined"
|
||
})
|
||
let atmosphereChip = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.atmosphere"
|
||
})
|
||
let coverChip = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.cover"
|
||
})
|
||
XCTAssertEqual(refinedChip.backgroundColor?.travelAlbumTestHexRGB, 0xEAF3FF)
|
||
XCTAssertEqual(atmosphereChip.backgroundColor?.travelAlbumTestHexRGB, 0xFFF2DC)
|
||
XCTAssertEqual(coverChip.backgroundColor?.travelAlbumTestHexRGB, 0xF2ECFF)
|
||
XCTAssertNil(refinedChip.findSubview { $0 is UIImageView })
|
||
XCTAssertNil(atmosphereChip.findSubview { $0 is UIImageView })
|
||
XCTAssertNil(coverChip.findSubview { $0 is UIImageView })
|
||
XCTAssertGreaterThan(atmosphereChip.bounds.width, refinedChip.bounds.width)
|
||
let coverFrame = coverChip.convert(coverChip.bounds, to: contentCard)
|
||
XCTAssertLessThan(coverFrame.maxX, contentCard.bounds.maxX - 16)
|
||
let titleLabel = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "aiRetouchJob.target.3.title"
|
||
})
|
||
let templateLabel = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "aiRetouchJob.target.3.template"
|
||
})
|
||
let statusView = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "aiRetouchJob.target.3.status"
|
||
})
|
||
let titleFrame = titleLabel.convert(titleLabel.bounds, to: controller.view)
|
||
let templateFrame = templateLabel.convert(templateLabel.bounds, to: controller.view)
|
||
let statusFrame = statusView.convert(statusView.bounds, to: controller.view)
|
||
XCTAssertLessThanOrEqual(titleFrame.maxX, controller.view.bounds.maxX - 18)
|
||
XCTAssertGreaterThan(statusFrame.minY, templateFrame.maxY)
|
||
XCTAssertLessThanOrEqual(statusFrame.maxX, controller.view.bounds.maxX - 18)
|
||
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 testAlbumManagementShowsAIJobEntryButAlbumEntryDoesNot() {
|
||
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 testPreviewAppliesRequestedInitialKindForCoverOnlyProject() throws {
|
||
let cover = TravelAlbumPreviewAsset(
|
||
id: "cover-897",
|
||
kind: .cover,
|
||
fileURL: "https://example.com/final.png",
|
||
coverURL: "https://example.com/final.png",
|
||
fileName: "AI封面",
|
||
fileSize: 0
|
||
)
|
||
let controller = TravelAlbumPhotoPreviewViewController(
|
||
projects: [TravelAlbumPreviewProject(originalMaterialId: 897, assets: [cover])],
|
||
totalCount: 1,
|
||
startProjectIndex: 0,
|
||
startKind: .cover,
|
||
allowsActions: false
|
||
)
|
||
controller.loadViewIfNeeded()
|
||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||
controller.view.layoutIfNeeded()
|
||
|
||
let highResolutionButton = try XCTUnwrap(
|
||
controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.previewHighResolutionButton"
|
||
} as? UIButton
|
||
)
|
||
let loadingIndicator = try XCTUnwrap(
|
||
controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.previewLoadingIndicator"
|
||
}
|
||
)
|
||
let activityIndicator = try XCTUnwrap(
|
||
loadingIndicator.findSubview { $0 is UIActivityIndicatorView } as? UIActivityIndicatorView
|
||
)
|
||
let fileSizeLabel = try XCTUnwrap(
|
||
controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.previewFileSizeLabel"
|
||
} as? UILabel
|
||
)
|
||
|
||
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "AI封面" })
|
||
XCTAssertTrue(highResolutionButton.isEnabled)
|
||
XCTAssertTrue(fileSizeLabel.isHidden)
|
||
XCTAssertNil(fileSizeLabel.text)
|
||
XCTAssertEqual(fileSizeLabel.bounds.height, 0, accuracy: 0.5)
|
||
XCTAssertEqual(loadingIndicator.bounds.size, CGSize(width: 44, height: 44))
|
||
XCTAssertEqual(loadingIndicator.backgroundColor?.travelAlbumTestHexRGB, 0x27272A)
|
||
XCTAssertEqual(loadingIndicator.accessibilityLabel, "图片加载中")
|
||
XCTAssertEqual(activityIndicator.color.travelAlbumTestHexRGB, 0xFFFFFF)
|
||
}
|
||
|
||
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 testOriginalTabReretouchSheetShowsBothTemplateGroupsAsOptional() async throws {
|
||
let api = TravelAlbumMockAPI()
|
||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
|
||
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
|
||
remainingQuota: 8
|
||
)
|
||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||
scenicId: 18,
|
||
workflow: .reretouch(materialId: 7, batchId: 70, type: .all)
|
||
)
|
||
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 confirmButton = try XCTUnwrap(
|
||
controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
|
||
} as? UIButton
|
||
)
|
||
let accessibleLabels = controller.view.allAccessibilityLabels()
|
||
XCTAssertTrue(accessibleLabels.contains("原图精修,选填"))
|
||
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
|
||
XCTAssertFalse(confirmButton.isEnabled)
|
||
|
||
viewModel.toggleTemplate(id: 2, category: .atmosphere)
|
||
await waitUntil { confirmButton.isEnabled }
|
||
XCTAssertNil(viewModel.selectedRefinedTemplateId)
|
||
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 2)
|
||
}
|
||
|
||
func testAutoRetouchGridUsesThreeColumnsAndKeepsFooterAndSelectionWhileScrolling() async throws {
|
||
let fixture = try await makeAutoRetouchSheet(templateCount: 19)
|
||
let controller = fixture.controller
|
||
let collection = fixture.collection
|
||
let confirm = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.autoRetouchConfirmButton"
|
||
} as? UIButton)
|
||
XCTAssertTrue(collection.collectionViewLayout is UICollectionViewCompositionalLayout)
|
||
XCTAssertFalse(confirm.isDescendant(of: collection))
|
||
|
||
for width: CGFloat in [375, 390, 430] {
|
||
controller.view.frame = CGRect(x: 0, y: 0, width: width, height: 667)
|
||
controller.view.layoutIfNeeded()
|
||
collection.layoutIfNeeded()
|
||
let frames = try (0 ..< 4).map { index in
|
||
try XCTUnwrap(collection.layoutAttributesForItem(at: IndexPath(item: index, section: 0))).frame
|
||
}
|
||
XCTAssertEqual(frames[0].minX, 16, accuracy: 0.5)
|
||
XCTAssertEqual(frames[0].width, (width - 56) / 3, accuracy: 0.5)
|
||
XCTAssertEqual(frames[0].height, 156)
|
||
XCTAssertEqual(frames[1].minY, frames[0].minY)
|
||
XCTAssertEqual(frames[2].minY, frames[0].minY)
|
||
XCTAssertEqual(frames[1].minX - frames[0].maxX, 12, accuracy: 0.5)
|
||
XCTAssertEqual(frames[2].minX - frames[1].maxX, 12, accuracy: 0.5)
|
||
XCTAssertEqual(frames[3].minX, frames[0].minX)
|
||
XCTAssertEqual(frames[3].minY - frames[0].maxY, 12, accuracy: 0.5)
|
||
XCTAssertLessThanOrEqual(collection.contentSize.width, collection.bounds.width)
|
||
}
|
||
|
||
let footerFrame = confirm.convert(confirm.bounds, to: controller.view)
|
||
let last = IndexPath(item: 18, section: 0)
|
||
collection.scrollToItem(at: last, at: .bottom, animated: false)
|
||
collection.layoutIfNeeded()
|
||
let lastCell = try XCTUnwrap(collection.cellForItem(at: last) as? TravelAlbumAIRetouchTemplateCell)
|
||
XCTAssertEqual(lastCell.frame.minX, 16, accuracy: 0.5)
|
||
XCTAssertGreaterThan(collection.contentOffset.y, 0)
|
||
let scrollOffset = collection.contentOffset
|
||
collection.delegate?.collectionView?(collection, didSelectItemAt: last)
|
||
await waitUntil { lastCell.accessibilityValue == "已选择" }
|
||
XCTAssertEqual(fixture.viewModel.pendingConfiguration?.refinedTemplateId, 19)
|
||
XCTAssertEqual(collection.contentOffset, scrollOffset)
|
||
XCTAssertEqual(confirm.convert(confirm.bounds, to: controller.view), footerFrame)
|
||
XCTAssertLessThanOrEqual(collection.convert(collection.bounds, to: controller.view).maxY, footerFrame.minY)
|
||
XCTAssertTrue(confirm.isEnabled)
|
||
collection.delegate?.collectionView?(collection, didSelectItemAt: last)
|
||
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 19)
|
||
|
||
collection.scrollToItem(at: IndexPath(item: 0, section: 0), at: .top, animated: false)
|
||
collection.layoutIfNeeded()
|
||
let first = try XCTUnwrap(collection.cellForItem(at: IndexPath(item: 0, section: 0)))
|
||
XCTAssertEqual(first.accessibilityValue, "未选择")
|
||
collection.scrollToItem(at: last, at: .bottom, animated: false)
|
||
collection.layoutIfNeeded()
|
||
XCTAssertEqual(collection.cellForItem(at: last)?.accessibilityValue, "已选择")
|
||
}
|
||
|
||
func testAutoRetouchModeSelectionExpandsTemplatesInlineAndKeepsDraftWhenToggling() async throws {
|
||
let fixture = try await makeAutoRetouchSheet(templateCount: 19, allowsModeSelection: true, configuration: .disabled)
|
||
let controller = fixture.controller
|
||
let original = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.autoRetouchOriginalOption"
|
||
} as? UIControl)
|
||
let ai = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.autoRetouchAIOption"
|
||
} as? UIControl)
|
||
let confirm = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.autoRetouchConfirmButton"
|
||
} as? UIButton)
|
||
let summary = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.autoRetouchSelectionSummary"
|
||
} as? UILabel)
|
||
XCTAssertTrue(original.isSelected)
|
||
XCTAssertTrue(fixture.collection.isHidden)
|
||
XCTAssertTrue(confirm.isEnabled)
|
||
let modeFrame = ai.convert(ai.bounds, to: controller.view)
|
||
ai.sendActions(for: .touchUpInside)
|
||
await waitUntil { !fixture.collection.isHidden }
|
||
controller.view.layoutIfNeeded()
|
||
fixture.collection.layoutIfNeeded()
|
||
XCTAssertTrue(ai.isSelected)
|
||
XCTAssertFalse(original.isSelected)
|
||
XCTAssertFalse(confirm.isEnabled)
|
||
XCTAssertNil(controller.presentedViewController)
|
||
XCTAssertEqual(ai.convert(ai.bounds, to: controller.view), modeFrame)
|
||
XCTAssertGreaterThan(fixture.collection.convert(fixture.collection.bounds, to: controller.view).minY, modeFrame.maxY)
|
||
fixture.viewModel.selectTemplate(id: 4)
|
||
await waitUntil { summary.text == "已选择:模板4" }
|
||
let last = IndexPath(item: 18, section: 0)
|
||
fixture.collection.scrollToItem(at: last, at: .bottom, animated: false)
|
||
fixture.collection.layoutIfNeeded()
|
||
let offset = fixture.collection.contentOffset
|
||
original.sendActions(for: .touchUpInside)
|
||
await waitUntil { fixture.collection.isHidden }
|
||
XCTAssertTrue(confirm.isEnabled)
|
||
XCTAssertEqual(fixture.viewModel.pendingConfiguration, .disabled)
|
||
ai.sendActions(for: .touchUpInside)
|
||
await waitUntil { !fixture.collection.isHidden }
|
||
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 4)
|
||
XCTAssertEqual(fixture.collection.contentOffset, offset)
|
||
XCTAssertEqual(summary.text, "已选择:模板4")
|
||
XCTAssertTrue(confirm.isEnabled)
|
||
fixture.collection.scrollToItem(at: IndexPath(item: 0, section: 0), at: .top, animated: false)
|
||
controller.view.layoutIfNeeded()
|
||
fixture.collection.layoutIfNeeded()
|
||
let image = UIGraphicsImageRenderer(bounds: controller.view.bounds).image { context in
|
||
controller.view.layer.render(in: context.cgContext)
|
||
}
|
||
let attachment = XCTAttachment(image: image)
|
||
attachment.name = "自动修图-同页模板选择"
|
||
attachment.lifetime = .keepAlways
|
||
add(attachment)
|
||
}
|
||
|
||
func testAutoRetouchPreviewDoesNotSelectTemplateAndReturnsToSameScrollPosition() async throws {
|
||
let fixture = try await makeAutoRetouchSheet(templateCount: 19)
|
||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||
let host = UIViewController()
|
||
window.rootViewController = host
|
||
window.makeKeyAndVisible()
|
||
defer { window.isHidden = true }
|
||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||
host.present(fixture.controller, animated: false) { continuation.resume() }
|
||
}
|
||
fixture.controller.view.layoutIfNeeded()
|
||
let collection = fixture.collection
|
||
let last = IndexPath(item: 18, section: 0)
|
||
collection.scrollToItem(at: last, at: .bottom, animated: false)
|
||
collection.layoutIfNeeded()
|
||
let offset = collection.contentOffset
|
||
let cell = try XCTUnwrap(collection.cellForItem(at: last))
|
||
let preview = try XCTUnwrap(cell.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewButton"
|
||
} as? UIButton)
|
||
preview.sendActions(for: .touchUpInside)
|
||
await waitUntil { fixture.controller.presentedViewController is BeforeAfterComparisonViewController }
|
||
let comparison = try XCTUnwrap(fixture.controller.presentedViewController as? BeforeAfterComparisonViewController)
|
||
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 1)
|
||
XCTAssertEqual(fixture.viewModel.pendingConfiguration?.refinedTemplateId, 1)
|
||
let returned = expectation(description: "Return from comparison")
|
||
// 等待展示动画完成,再模拟预览返回。
|
||
if let transition = comparison.transitionCoordinator {
|
||
transition.animate(alongsideTransition: nil) { _ in
|
||
comparison.dismiss(animated: false) { returned.fulfill() }
|
||
}
|
||
} else {
|
||
comparison.dismiss(animated: false) { returned.fulfill() }
|
||
}
|
||
await fulfillment(of: [returned], timeout: 3)
|
||
XCTAssertNil(fixture.controller.presentedViewController)
|
||
XCTAssertEqual(collection.contentOffset, offset)
|
||
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 1)
|
||
}
|
||
|
||
func testAutoRetouchMissingComparisonKeepsSelectionAndShowsMessage() async throws {
|
||
let fixture = try await makeAutoRetouchSheet(templateCount: 2, hasComparison: false)
|
||
let cell = try XCTUnwrap(fixture.collection.cellForItem(at: IndexPath(item: 1, section: 0)))
|
||
let preview = try XCTUnwrap(cell.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewButton"
|
||
} as? UIButton)
|
||
preview.sendActions(for: .touchUpInside)
|
||
XCTAssertNil(fixture.controller.presentedViewController)
|
||
XCTAssertEqual(fixture.viewModel.selectedTemplateId, 1)
|
||
XCTAssertTrue(fixture.controller.view.allLabels().contains { $0.text == "暂无对比预览" })
|
||
}
|
||
|
||
private func makeAutoRetouchSheet(
|
||
templateCount: Int,
|
||
allowsModeSelection: Bool = false,
|
||
hasComparison: Bool = true,
|
||
configuration: TravelAlbumAutoRetouchConfiguration = TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 1)
|
||
) async throws -> (
|
||
controller: TravelAlbumAutoRetouchSettingSheetViewController,
|
||
viewModel: TravelAlbumAutoRetouchSettingViewModel,
|
||
collection: UICollectionView
|
||
) {
|
||
let api = TravelAlbumMockAPI()
|
||
let imageURL = FileManager.default.temporaryDirectory.appendingPathComponent("auto-retouch-\(UUID().uuidString).png")
|
||
let imageData = try XCTUnwrap(UIImage(named: "ai_retouch_template_placeholder")?.pngData())
|
||
try imageData.write(to: imageURL)
|
||
addTeardownBlock { try? FileManager.default.removeItem(at: imageURL) }
|
||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||
refinedTemplates: (1 ... templateCount).map {
|
||
TravelAlbumAIRetouchTemplate(
|
||
id: $0, name: "模板\($0)", previewURL: "",
|
||
beforeURL: hasComparison ? imageURL.absoluteString : "",
|
||
afterURL: hasComparison ? imageURL.absoluteString : ""
|
||
)
|
||
}
|
||
)
|
||
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
|
||
scenicId: 18,
|
||
configuration: configuration,
|
||
allowsModeSelection: allowsModeSelection
|
||
)
|
||
let controller = TravelAlbumAutoRetouchSettingSheetViewController(viewModel: viewModel, api: api)
|
||
controller.loadViewIfNeeded()
|
||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 667)
|
||
let collection = try XCTUnwrap(controller.view.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.autoRetouchTemplateCollection"
|
||
} as? UICollectionView)
|
||
await waitUntil { collection.numberOfItems(inSection: 0) == templateCount && !viewModel.isLoading }
|
||
controller.view.layoutIfNeeded()
|
||
collection.layoutIfNeeded()
|
||
XCTAssertEqual(collection.numberOfItems(inSection: 0), templateCount)
|
||
return (controller, viewModel, collection)
|
||
}
|
||
|
||
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 testPreviewAIRetouchFromRetouchedTabAllowsBothOptionalTemplates() 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
|
||
)
|
||
let collectionView = try XCTUnwrap(
|
||
sheet.view.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection"
|
||
} as? UICollectionView
|
||
)
|
||
await waitUntil {
|
||
api.aiRetouchTemplateScenicIds.count == 1 &&
|
||
collectionView.numberOfSections > 2 &&
|
||
collectionView.numberOfItems(inSection: 2) == 1
|
||
}
|
||
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("原图精修"))
|
||
XCTAssertTrue(visibleLabelTexts.contains("氛围感修图"))
|
||
let accessibleLabels = sheet.view.allAccessibilityLabels()
|
||
XCTAssertTrue(accessibleLabels.contains("原图精修,选填"))
|
||
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
|
||
XCTAssertFalse(confirmButton.isEnabled)
|
||
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)
|
||
collectionView.delegate?.collectionView?(
|
||
collectionView,
|
||
didSelectItemAt: IndexPath(item: 0, section: 2)
|
||
)
|
||
await waitUntil { confirmButton.isEnabled }
|
||
confirmButton.sendActions(for: .touchUpInside)
|
||
await waitUntil { api.aiReretouchRequests.count == 1 }
|
||
await waitUntil { submissionNotified }
|
||
|
||
XCTAssertEqual(
|
||
api.aiReretouchRequests.first,
|
||
TravelAlbumAIReretouchRequest(
|
||
id: 7,
|
||
aiRetouchBatchId: 70,
|
||
type: .all,
|
||
refinedTemplateId: nil,
|
||
atmosphereTemplateId: 21
|
||
)
|
||
)
|
||
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 testMaterialCellShowsIndependentPurchaseAndAIRetouchBadgesWithoutOverlappingSelectionCheck() throws {
|
||
let cases: [(TravelAlbumMaterial, String, UInt)] = [
|
||
(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.materialAIRetouchBadge"
|
||
}
|
||
)
|
||
let badgeLabel = try XCTUnwrap(
|
||
cell.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadgeLabel"
|
||
} 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 combinedCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
||
combinedCell.apply(
|
||
material: TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2),
|
||
selectionMode: true,
|
||
selected: false
|
||
)
|
||
combinedCell.layoutIfNeeded()
|
||
let purchaseBadge = try XCTUnwrap(
|
||
combinedCell.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadge"
|
||
}
|
||
)
|
||
let purchaseLabel = try XCTUnwrap(
|
||
combinedCell.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadgeLabel"
|
||
} as? UILabel
|
||
)
|
||
let aiRetouchBadge = try XCTUnwrap(
|
||
combinedCell.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
|
||
}
|
||
)
|
||
XCTAssertFalse(purchaseBadge.isHidden)
|
||
XCTAssertFalse(aiRetouchBadge.isHidden)
|
||
XCTAssertEqual(purchaseLabel.text, "已购")
|
||
XCTAssertEqual(purchaseBadge.backgroundColor?.travelAlbumTestHexRGB, 0x475569)
|
||
XCTAssertGreaterThan(purchaseBadge.frame.minY, aiRetouchBadge.frame.maxY)
|
||
XCTAssertEqual(purchaseBadge.frame.minX, 6, accuracy: 0.5)
|
||
XCTAssertEqual(purchaseBadge.frame.maxY, 170, accuracy: 0.5)
|
||
XCTAssertTrue(combinedCell.accessibilityLabel?.contains("购买状态:已购") == true)
|
||
XCTAssertTrue(combinedCell.accessibilityLabel?.contains("修图状态:修图中") == 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 hiddenPurchaseBadge = try XCTUnwrap(
|
||
hiddenCell.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadge"
|
||
}
|
||
)
|
||
let hiddenAIRetouchBadge = try XCTUnwrap(
|
||
hiddenCell.findSubview {
|
||
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
|
||
}
|
||
)
|
||
XCTAssertTrue(hiddenPurchaseBadge.isHidden)
|
||
XCTAssertTrue(hiddenAIRetouchBadge.isHidden)
|
||
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("购买状态:") == true)
|
||
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("修图状态:") == true)
|
||
}
|
||
|
||
func testWiredTransferProgressUsesOnlyUploadStatusAndRealProgress() throws {
|
||
let cell = WiredTransferPhotoCell(frame: CGRect(x: 0, y: 0, width: 300, height: 66))
|
||
let progressView = try XCTUnwrap(cell.findSubview {
|
||
$0.accessibilityIdentifier == "wiredTransfer.uploadProgress"
|
||
} as? UIProgressView)
|
||
let uploadStates: [TravelAlbumOTGUploadStatus] = [.pending, .transferring, .uploading, .uploaded, .failed]
|
||
let retouchStates: [TravelAlbumAutoRetouchState] = [.none, .pendingSubmission, .submitting, .processing, .completed, .failed]
|
||
|
||
for uploadState in uploadStates {
|
||
for retouchState in retouchStates {
|
||
var item = makeWiredTransferPhotoItem(state: retouchState)
|
||
item.status = uploadState
|
||
item.progress = uploadState == .uploaded ? 100 : 37
|
||
|
||
cell.apply(item: item, selectionMode: false, selected: false)
|
||
|
||
XCTAssertEqual(progressView.isHidden, uploadState != .transferring && uploadState != .uploading)
|
||
XCTAssertEqual(progressView.progress, Float(item.progress) / 100, accuracy: 0.001)
|
||
}
|
||
}
|
||
}
|
||
|
||
func testWiredTransferRetouchBadgeShowsStateColorsAndKeepsCircularThumbnailLayout() throws {
|
||
let cases: [(TravelAlbumAutoRetouchState, String, UIColor)] = [
|
||
(.pendingSubmission, "修图中", UIColor(hex: 0x7C3AED)),
|
||
(.submitting, "修图中", UIColor(hex: 0x7C3AED)),
|
||
(.processing, "修图中", UIColor(hex: 0x7C3AED)),
|
||
(.completed, "修图成功", UIColor(hex: 0x047857)),
|
||
(.failed, "修图失败", AppColor.danger),
|
||
]
|
||
let cell = WiredTransferPhotoCell(frame: CGRect(x: 0, y: 0, width: 300, height: 66))
|
||
let badge = try XCTUnwrap(cell.findSubview {
|
||
$0.accessibilityIdentifier == "wiredTransfer.retouchBadge"
|
||
} as? UILabel)
|
||
let thumbnail = try XCTUnwrap(cell.findSubview {
|
||
$0.accessibilityIdentifier == "wiredTransfer.thumbnail"
|
||
})
|
||
|
||
for (state, expectedStatus, expectedColor) in cases {
|
||
cell.apply(item: makeWiredTransferPhotoItem(state: state), selectionMode: false, selected: false)
|
||
cell.layoutIfNeeded()
|
||
|
||
XCTAssertFalse(badge.isHidden)
|
||
XCTAssertEqual(badge.text, "修")
|
||
XCTAssertEqual(badge.backgroundColor?.travelAlbumTestHexRGB, expectedColor.travelAlbumTestHexRGB)
|
||
XCTAssertEqual(badge.textColor, .white)
|
||
XCTAssertEqual(badge.frame.size, CGSize(width: 16, height: 16))
|
||
// Auto Layout 对齐 alignmentRect,系统符号占位图的 frame 可能带有留白。
|
||
let badgeAlignmentRect = badge.alignmentRect(forFrame: badge.frame)
|
||
let thumbnailAlignmentRect = thumbnail.alignmentRect(forFrame: thumbnail.frame)
|
||
XCTAssertEqual(badgeAlignmentRect.minY, thumbnailAlignmentRect.minY + 2, accuracy: 0.1)
|
||
XCTAssertEqual(badgeAlignmentRect.maxX, thumbnailAlignmentRect.maxX - 2, accuracy: 0.1)
|
||
XCTAssertEqual(badge.layer.cornerRadius, 8)
|
||
XCTAssertEqual(badge.accessibilityLabel, expectedStatus)
|
||
XCTAssertTrue(cell.accessibilityLabel?.contains("修图状态:\(expectedStatus)") == true)
|
||
}
|
||
|
||
cell.prepareForReuse()
|
||
cell.apply(item: makeWiredTransferPhotoItem(state: .none), selectionMode: false, selected: false)
|
||
XCTAssertTrue(badge.isHidden)
|
||
XCTAssertNil(badge.accessibilityLabel)
|
||
XCTAssertFalse(cell.accessibilityLabel?.contains("修图状态:") == true)
|
||
}
|
||
|
||
func testWiredTransferUploadLabelIgnoresAllRetouchStates() throws {
|
||
let cell = WiredTransferPhotoCell(frame: CGRect(x: 0, y: 0, width: 300, height: 66))
|
||
let label = try XCTUnwrap(cell.findSubview {
|
||
$0.accessibilityIdentifier == "wiredTransfer.uploadStatus"
|
||
} as? UILabel)
|
||
let successColor = UIColor(hex: 0x16A34A)
|
||
let cases: [(TravelAlbumOTGUploadStatus, String, UIColor, UIColor)] = [
|
||
(.pending, "待上传", AppColor.textSecondary, AppColor.pageBackground),
|
||
(.transferring, "传输中", AppColor.primary, AppColor.primary.withAlphaComponent(0.12)),
|
||
(.uploading, "上传中", AppColor.primary, AppColor.primary.withAlphaComponent(0.12)),
|
||
(.uploaded, "已上传", successColor, successColor.withAlphaComponent(0.12)),
|
||
(.failed, "上传失败", AppColor.danger, AppColor.danger.withAlphaComponent(0.12)),
|
||
]
|
||
let retouchStates: [TravelAlbumAutoRetouchState] = [
|
||
.none, .pendingSubmission, .submitting, .processing, .completed, .failed,
|
||
]
|
||
|
||
for (uploadStatus, text, foreground, background) in cases {
|
||
for retouchState in retouchStates {
|
||
var item = makeWiredTransferPhotoItem(state: retouchState)
|
||
item.status = uploadStatus
|
||
cell.apply(item: item, selectionMode: false, selected: false)
|
||
|
||
XCTAssertFalse(label.isHidden)
|
||
XCTAssertEqual(label.text, text)
|
||
XCTAssertEqual(label.textColor, foreground)
|
||
XCTAssertEqual(label.backgroundColor, background)
|
||
XCTAssertTrue(cell.accessibilityLabel?.hasPrefix("photo.JPG,\(text)") == true)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func makeWiredTransferPhotoItem(state: TravelAlbumAutoRetouchState) -> TravelAlbumOTGPhotoItem {
|
||
TravelAlbumOTGPhotoItem(
|
||
id: "photo", sourceId: "photo", fileName: "photo.JPG", thumbnailURL: nil,
|
||
capturedAt: "2026-08-27 12:00:00", fileSizeText: "1 KB", fileSizeBytes: 1024,
|
||
status: .uploaded, progress: 100, errorMessage: nil, localPath: "", remoteUrl: "",
|
||
autoRetouchState: state
|
||
)
|
||
}
|
||
|
||
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() }
|
||
}
|
||
}
|