feat: 优化自动修图设置与模板网格交互

同页切换修图方式并展开三列模板,复用模板卡片与对比预览,保留草稿和滚动位置,统一卡片与固定底栏样式并补充回归测试。
This commit is contained in:
2026-08-27 17:15:50 +08:00
parent d04641b623
commit 396597a160
6 changed files with 538 additions and 259 deletions
@@ -955,6 +955,213 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
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: "")