feat: 优化自动修图设置与模板网格交互
同页切换修图方式并展开三列模板,复用模板卡片与对比预览,保留草稿和滚动位置,统一卡片与固定底栏样式并补充回归测试。
This commit is contained in:
@@ -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: "")
|
||||
|
||||
@@ -455,6 +455,51 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
/// 自动修图配置 ViewModel 测试。
|
||||
@MainActor
|
||||
final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase {
|
||||
func testModeToggleRetainsDraftButDisabledSubmissionOmitsTemplate() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(refinedTemplates: [
|
||||
TravelAlbumAIRetouchTemplate(id: 12, name: "清透", previewURL: ""),
|
||||
])
|
||||
let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: true)
|
||||
await viewModel.loadTemplates(api: api)
|
||||
viewModel.selectMode(enabled: true)
|
||||
XCTAssertFalse(viewModel.canConfirm)
|
||||
viewModel.selectTemplate(id: 12)
|
||||
viewModel.selectMode(enabled: false)
|
||||
XCTAssertEqual(viewModel.pendingConfiguration, .disabled)
|
||||
XCTAssertEqual(viewModel.selectedTemplateId, 12)
|
||||
viewModel.selectMode(enabled: true)
|
||||
XCTAssertEqual(viewModel.pendingConfiguration?.refinedTemplateId, 12)
|
||||
XCTAssertEqual(viewModel.selectedTemplateName, "清透")
|
||||
XCTAssertTrue(viewModel.canConfirm)
|
||||
}
|
||||
|
||||
func testOriginalModeRemainsConfirmableDuringTemplateLoadingAndFailure() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiRetouchTemplatesError = APIError.httpStatus(500, "模板服务不可用")
|
||||
let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: true)
|
||||
var couldConfirmWhileLoading = false
|
||||
viewModel.onStateChange = {
|
||||
if viewModel.isLoading { couldConfirmWhileLoading = viewModel.canConfirm }
|
||||
}
|
||||
await viewModel.loadTemplates(api: api)
|
||||
viewModel.onStateChange = nil
|
||||
XCTAssertTrue(couldConfirmWhileLoading)
|
||||
XCTAssertTrue(viewModel.canConfirm)
|
||||
XCTAssertEqual(viewModel.pendingConfiguration, .disabled)
|
||||
viewModel.selectMode(enabled: true)
|
||||
XCTAssertFalse(viewModel.canConfirm)
|
||||
}
|
||||
|
||||
func testTemplateOnlyEntryRequiresAISelectionAndCannotDisable() {
|
||||
let viewModel = TravelAlbumAutoRetouchSettingViewModel(scenicId: 18, configuration: .disabled, allowsModeSelection: false)
|
||||
XCTAssertTrue(viewModel.isEnabled)
|
||||
XCTAssertFalse(viewModel.canConfirm)
|
||||
XCTAssertNil(viewModel.pendingConfiguration)
|
||||
viewModel.selectMode(enabled: false)
|
||||
XCTAssertTrue(viewModel.isEnabled)
|
||||
}
|
||||
|
||||
func testLoadsOnlyRefinedTemplatesAndKeepsSingleSelection() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
@@ -470,7 +515,7 @@ final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase {
|
||||
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
|
||||
scenicId: 18,
|
||||
configuration: .disabled,
|
||||
startsWithModeSelection: true
|
||||
allowsModeSelection: true
|
||||
)
|
||||
|
||||
viewModel.selectMode(enabled: true)
|
||||
@@ -490,7 +535,7 @@ final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase {
|
||||
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
|
||||
scenicId: 18,
|
||||
configuration: TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 12),
|
||||
startsWithModeSelection: false
|
||||
allowsModeSelection: false
|
||||
)
|
||||
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
Reference in New Issue
Block a user