feat: add AI retouch task center

This commit is contained in:
2026-08-14 15:06:57 +08:00
parent 0f3b26991e
commit 30bfe0313f
26 changed files with 3679 additions and 50 deletions
@@ -10,6 +10,231 @@ import XCTest
/// 相册管理页刷新、预览与选择态交互测试。
@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: "测试相册")
@@ -483,7 +708,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
let controller = TravelAlbumAIRetouchTemplateViewController(
viewModel: viewModel,
api: api,
onSubmitted: {}
onSubmitted: { _ in }
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
@@ -538,7 +763,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(selectionCountLabel.text, "已选择 4 张照片")
XCTAssertEqual(
tipsLabel.text,
"每张照片生成精修结果,氛围感可选;本次可免费生成 1 张封面"
"Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。"
)
XCTAssertFalse(tipsContainer.isHidden)
XCTAssertEqual(tipsContainer.backgroundColor?.travelAlbumTestHexRGB, 0xF4F8FF)
@@ -568,7 +793,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
let controller = TravelAlbumAIRetouchTemplateViewController(
viewModel: viewModel,
api: api,
onSubmitted: {}
onSubmitted: { _ in }
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
@@ -729,7 +954,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48))
}
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws {
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndShowsTaskAction() async throws {
UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(true) }
let api = TravelAlbumMockAPI()
@@ -823,10 +1048,12 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertTrue(tipsLabel.isHidden)
confirmButton.sendActions(for: .touchUpInside)
await waitUntil { api.aiReretouchRequests.count == 1 }
await waitUntil { controller.presentedViewController == nil }
await waitUntil { controller.presentedViewController is UIAlertController }
XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined)
XCTAssertNil(controller.presentedViewController)
let successAlert = try XCTUnwrap(controller.presentedViewController as? UIAlertController)
XCTAssertEqual(successAlert.title, "AI修图任务已提交")
XCTAssertEqual(successAlert.actions.map(\.title), ["知道了", "查看任务"])
XCTAssertTrue(window.rootViewController === controller)
}