feat: add AI retouch before-after comparison

This commit is contained in:
2026-08-13 17:46:06 +08:00
parent e190b5958b
commit 0f3b26991e
12 changed files with 953 additions and 145 deletions
+3 -1
View File
@@ -162,7 +162,7 @@ final class TravelAlbumAPITests: XCTestCase {
func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws {
let data = envelopeJSON(
#"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg"}],"remaining_quota":12}"#
#"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg","before_url":"https://cdn/refined-before.jpg","after_url":"https://cdn/refined-after.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg","before_url":"https://cdn/atmosphere-before.jpg","after_url":"https://cdn/atmosphere-after.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg","before_url":"https://cdn/cover-before.jpg","after_url":"https://cdn/cover-after.jpg"}],"remaining_quota":12}"#
)
let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
@@ -170,6 +170,8 @@ final class TravelAlbumAPITests: XCTestCase {
let response = try await api.aiRetouchTemplates(scenicId: 18)
XCTAssertEqual(response.refinedTemplates.first?.name, "清透")
XCTAssertEqual(response.refinedTemplates.first?.beforeURL, "https://cdn/refined-before.jpg")
XCTAssertEqual(response.refinedTemplates.first?.afterURL, "https://cdn/refined-after.jpg")
XCTAssertEqual(response.atmosphereTemplates.first?.id, 2)
XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg")
XCTAssertEqual(response.remainingQuota, 12)
@@ -637,35 +637,96 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(cell.accessibilityValue, "未选择")
}
func testAIRetouchTemplatePreviewUsesImmersiveZoomableLayout() throws {
let template = TravelAlbumAIRetouchTemplate(
id: 2,
name: "简约留白",
previewURL: ""
func testBeforeAfterComparisonUsesImmersiveAccessibleLayout() throws {
let controller = BeforeAfterComparisonViewController(
viewModel: BeforeAfterComparisonViewModel(
content: BeforeAfterComparisonContent(
beforeURL: "https://cdn.example.com/before.jpg",
afterURL: "https://cdn.example.com/after.jpg"
)
)
)
let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.view.layoutIfNeeded()
let scrollView = try XCTUnwrap(
let imageContainer = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewScrollView"
} as? UIScrollView
$0.accessibilityIdentifier == "travelAlbum.beforeAfterImageContainer"
}
)
let backButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewBackButton"
$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
)
XCTAssertEqual(controller.view.backgroundColor, .black)
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "简约留白" })
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "双指缩放查看细节" })
XCTAssertEqual(scrollView.minimumZoomScale, 1)
XCTAssertEqual(scrollView.maximumZoomScale, 4)
XCTAssertGreaterThanOrEqual(backButton.bounds.width, 44)
XCTAssertGreaterThanOrEqual(backButton.bounds.height, 44)
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 testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws {
+105
View File
@@ -8,6 +8,40 @@ import XCTest
/// 旅拍相册模型与展示工具测试。
final class TravelAlbumModelsTests: XCTestCase {
func testBeforeAfterComparisonMotionMatchesShipSwiftInteractionRange() {
XCTAssertEqual(BeforeAfterComparisonMotion.automaticFraction(elapsed: 0), 0.5, accuracy: 0.001)
XCTAssertEqual(
BeforeAfterComparisonMotion.automaticFraction(
elapsed: .pi / (2 * BeforeAfterComparisonMotion.automaticSpeed)
),
0.8,
accuracy: 0.001
)
XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0), 0.05)
XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(1), 0.95)
XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0.42), 0.42)
}
func testBeforeAfterComparisonLayoutFitsVariableRatiosWithoutCropping() {
let landscape = BeforeAfterComparisonLayout.fittedSize(
aspectRatio: 16.0 / 9.0,
maximumSize: CGSize(width: 358, height: 580)
)
let portrait = BeforeAfterComparisonLayout.fittedSize(
aspectRatio: 2.0 / 3.0,
maximumSize: CGSize(width: 358, height: 580)
)
XCTAssertEqual(landscape.width, 358, accuracy: 0.001)
XCTAssertEqual(landscape.height, 201.375, accuracy: 0.001)
XCTAssertEqual(portrait.width, 358, accuracy: 0.001)
XCTAssertEqual(portrait.height, 537, accuracy: 0.001)
XCTAssertEqual(
BeforeAfterComparisonLayout.aspectRatio(for: CGSize(width: 4000, height: 3000)),
4.0 / 3.0
)
}
func testPreviewProjectMapsMaterialToOriginalAsset() {
let material = TravelAlbumMaterial(
id: 8,
@@ -68,6 +102,77 @@ final class TravelAlbumModelsTests: XCTestCase {
)
}
func testPreviewProjectBuildsComparisonOnlyForValidAIResults() {
let project = makePreviewProject(id: 8, kinds: [.original, .retouched, .atmosphere, .cover])
XCTAssertEqual(
project.comparisonContent(for: .retouched),
BeforeAfterComparisonContent(
beforeURL: "https://cdn.example.com/8-0.jpg",
afterURL: "https://cdn.example.com/8-1.jpg"
)
)
XCTAssertNotNil(project.comparisonContent(for: .atmosphere))
XCTAssertNil(project.comparisonContent(for: .original))
XCTAssertNil(project.comparisonContent(for: .cover))
let missingResultURL = TravelAlbumPreviewProject(
originalMaterialId: 9,
assets: [
TravelAlbumPreviewAsset(
id: "original-9",
kind: .original,
fileURL: "https://cdn.example.com/original.jpg",
coverURL: "",
fileName: "原图.jpg",
fileSize: 1
),
TravelAlbumPreviewAsset(
id: "retouched-9",
kind: .retouched,
fileURL: " ",
coverURL: "",
fileName: "精修.jpg",
fileSize: 1
),
]
)
XCTAssertNil(missingResultURL.comparisonContent(for: .retouched))
}
func testTemplateComparisonContentRequiresBothURLs() {
let valid = TravelAlbumAIRetouchTemplate(
id: 1,
name: "清透",
previewURL: "https://cdn.example.com/preview.jpg",
beforeURL: " https://cdn.example.com/before.jpg ",
afterURL: "https://cdn.example.com/after.jpg"
)
let missingAfter = TravelAlbumAIRetouchTemplate(
id: 2,
name: "暖阳",
previewURL: "https://cdn.example.com/preview.jpg",
beforeURL: "https://cdn.example.com/before.jpg"
)
XCTAssertEqual(valid.comparisonContent?.beforeURL, "https://cdn.example.com/before.jpg")
XCTAssertEqual(valid.comparisonContent?.afterLabel, "效果图")
XCTAssertNil(missingAfter.comparisonContent)
}
func testTemplateDecodingDefaultsMissingComparisonURLsToEmptyStrings() throws {
let data = Data(
#"{"id":3,"name":"旧模板","preview_url":"https://cdn.example.com/preview.jpg"}"#.utf8
)
let template = try JSONDecoder().decode(TravelAlbumAIRetouchTemplate.self, from: data)
XCTAssertEqual(template.previewURL, "https://cdn.example.com/preview.jpg")
XCTAssertEqual(template.beforeURL, "")
XCTAssertEqual(template.afterURL, "")
XCTAssertNil(template.comparisonContent)
}
func testPreviewAssetFallsBackToAvailableURLForBothQualityLevels() {
let missingOriginal = TravelAlbumPreviewAsset(
id: "cover-only",