feat: add AI retouch and album preview actions

This commit is contained in:
2026-08-10 16:00:32 +08:00
parent 24fa66281c
commit 439edf827c
14 changed files with 2110 additions and 118 deletions
@@ -0,0 +1,191 @@
//
// TravelAlbumAIRetouchTemplateViewModelTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// AI 修图模板选择状态测试。
@MainActor
final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
func testLoadDefaultsRequiredSelectionsAndLeavesAtmosphereEmpty() async {
let api = makeAPI()
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [4, 3, 2, 1]
)
await viewModel.loadTemplates(api: api)
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
XCTAssertEqual(viewModel.selectedCoverTemplateId, 31)
XCTAssertTrue(viewModel.showsCoverTemplates)
XCTAssertTrue(viewModel.canSubmit)
}
func testAtmosphereSelectionTogglesOffWhenTappedAgain() async {
let api = makeAPI()
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1]
)
await viewModel.loadTemplates(api: api)
viewModel.toggleTemplate(id: 21, category: .atmosphere)
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 21)
viewModel.toggleTemplate(id: 21, category: .atmosphere)
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
}
func testThreeMaterialsHideCoverAndOmitCoverFromSubmission() async {
let api = makeAPI()
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [3, 1, 2]
)
var submitted = false
viewModel.onSubmitted = { submitted = true }
await viewModel.loadTemplates(api: api)
XCTAssertFalse(viewModel.showsCoverTemplates)
XCTAssertNil(viewModel.selectedCoverTemplateId)
await viewModel.submit(api: api)
XCTAssertTrue(submitted)
XCTAssertEqual(api.aiRetouchRequests.count, 1)
XCTAssertEqual(api.aiRetouchRequests.first?.materialIds, [1, 2, 3])
XCTAssertNil(api.aiRetouchRequests.first?.aiPreviewTabs)
}
func testFourMaterialsSubmitAllSelectedTemplateIds() async {
let api = makeAPI()
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1, 2, 3, 4]
)
await viewModel.loadTemplates(api: api)
viewModel.toggleTemplate(id: 12, category: .refined)
viewModel.toggleTemplate(id: 21, category: .atmosphere)
await viewModel.submit(api: api)
XCTAssertEqual(
api.aiRetouchRequests.first,
TravelAlbumAIRetouchRequest(
userEquityTravelId: 8,
materialIds: [1, 2, 3, 4],
refinedTemplateId: 12,
atmosphereTemplateId: 21,
aiPreviewTabs: 31
)
)
}
func testMissingRequiredCoverTemplateDisablesSubmission() async {
let api = makeAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [template(11, "清透")],
atmosphereTemplates: [template(21, "暖阳")],
coverTemplates: []
)
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1, 2, 3, 4]
)
await viewModel.loadTemplates(api: api)
XCTAssertFalse(viewModel.canSubmit)
XCTAssertEqual(viewModel.validationMessage, "暂无可用的封面风格模板")
}
func testLoadFailureExposesRetryMessageAndKeepsSubmissionDisabled() async {
let api = makeAPI()
api.aiRetouchTemplatesError = APIError.serverCode(500, "模板服务繁忙")
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1]
)
await viewModel.loadTemplates(api: api)
XCTAssertEqual(viewModel.loadErrorMessage, "模板服务繁忙")
XCTAssertFalse(viewModel.canSubmit)
XCTAssertTrue(viewModel.refinedTemplates.isEmpty)
}
func testSubmitFailureKeepsSelectionAndAllowsRetry() async {
let api = makeAPI()
api.submitAIRetouchError = APIError.serverCode(500, "提交服务繁忙")
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1]
)
var message: String?
var submitted = false
viewModel.onShowMessage = { message = $0 }
viewModel.onSubmitted = { submitted = true }
await viewModel.loadTemplates(api: api)
await viewModel.submit(api: api)
XCTAssertEqual(api.aiRetouchRequests.count, 1)
XCTAssertEqual(message, "提交服务繁忙")
XCTAssertFalse(submitted)
XCTAssertFalse(viewModel.isSubmitting)
XCTAssertTrue(viewModel.canSubmit)
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
}
func testSubmittingPreventsDuplicateRequest() async {
let api = makeAPI()
api.submitAIRetouchDelayNanoseconds = 50_000_000
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1]
)
await viewModel.loadTemplates(api: api)
let firstSubmission = Task { await viewModel.submit(api: api) }
await waitUntil { viewModel.isSubmitting }
await viewModel.submit(api: api)
await firstSubmission.value
XCTAssertEqual(api.aiRetouchRequests.count, 1)
XCTAssertFalse(viewModel.isSubmitting)
}
private func makeAPI() -> TravelAlbumMockAPI {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [template(11, "清透"), template(12, "自然")],
atmosphereTemplates: [template(21, "暖阳")],
coverTemplates: [template(31, "杂志")]
)
return api
}
private func template(_ id: Int, _ name: String) -> TravelAlbumAIRetouchTemplate {
TravelAlbumAIRetouchTemplate(id: id, name: name, previewURL: "https://cdn.example.com/\(id).jpg")
}
private func waitUntil(_ condition: @escaping () -> Bool) async {
for _ in 0 ..< 100 {
if condition() { return }
await Task.yield()
}
}
}
+76 -2
View File
@@ -51,16 +51,27 @@ final class TravelAlbumAPITests: XCTestCase {
}
func testMaterialListAndDeleteAndMpCode() async throws {
let materialList = envelopeJSON(#"{"total":0,"list":[]}"#)
let materialList = envelopeJSON(
#"{"total":1,"list":[{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":2,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"ai_retouch_status":3,"ai_retouch_status_name":"AI已修","ai_refined_url":"https://cdn/refined.jpg","ai_atmosphere_url":"https://cdn/atmosphere.jpg","created_at":"","updated_at":""}]}"#
)
let empty = envelopeJSON("{}")
let code = envelopeJSON(#"{"mp_code_oss_url":"https://cdn/qr.png"}"#)
let session = MockURLSession(responses: [materialList, empty, code])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.materialList(userEquityTravelId: 3, page: 1, pageSize: 30, orderBy: 4, isPurchased: 1)
let materials = try await api.materialList(
userEquityTravelId: 3,
page: 1,
pageSize: 30,
orderBy: 4,
isPurchased: 1
)
try await api.deleteAlbum(id: 3)
let response = try await api.mpCode(id: 3)
XCTAssertEqual(materials.list.first?.aiRetouchStatus, 3)
XCTAssertEqual(materials.list.first?.aiRefinedURL, "https://cdn/refined.jpg")
XCTAssertEqual(materials.list.first?.aiAtmosphereURL, "https://cdn/atmosphere.jpg")
XCTAssertEqual(response.mpCodeOssUrl, "https://cdn/qr.png")
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/travel-album/material-list")
let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems
@@ -113,6 +124,69 @@ final class TravelAlbumAPITests: XCTestCase {
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
}
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"}]}"#
)
let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.aiRetouchTemplates(scenicId: 18)
XCTAssertEqual(response.refinedTemplates.first?.name, "清透")
XCTAssertEqual(response.atmosphereTemplates.first?.id, 2)
XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg")
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-templates")
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "scenic_id" }?.value, "18")
}
func testSubmitAIRetouchEncodesRequiredAndSelectedOptionalTemplates() async throws {
let session = MockURLSession(responses: [envelopeJSON(#"{"task_id":9}"#)])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
try await api.submitAIRetouch(
TravelAlbumAIRetouchRequest(
userEquityTravelId: 6,
materialIds: [11, 12, 13, 14],
refinedTemplateId: 21,
atmosphereTemplateId: 22,
aiPreviewTabs: 31
)
)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch")
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 6)
XCTAssertEqual(body?["material_ids"] as? [Int], [11, 12, 13, 14])
XCTAssertEqual(body?["refined_template_id"] as? Int, 21)
XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22)
XCTAssertEqual(body?["ai_preview_tabs"] as? Int, 31)
}
func testSubmitAIRetouchOmitsAllOptionalTemplatesWhenAbsent() async throws {
let session = MockURLSession(responses: [envelopeJSON(#"{"accepted":true}"#)])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
try await api.submitAIRetouch(
TravelAlbumAIRetouchRequest(
userEquityTravelId: 6,
materialIds: [11],
refinedTemplateId: 21,
atmosphereTemplateId: nil,
aiPreviewTabs: nil
)
)
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any]
XCTAssertNil(body?["atmosphere_template_id"])
XCTAssertNil(body?["ai_preview_tabs"])
}
private func envelopeJSON(_ dataJSON: String) -> Data {
"""
{"code":100000,"msg":"success","data":\(dataJSON)}
@@ -0,0 +1,374 @@
//
// TravelAlbumDetailViewControllerTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// 相册管理页选择态底部操作区测试。
@MainActor
final class TravelAlbumDetailViewControllerTests: XCTestCase {
func testPreviewDeleteHandlerCallsMaterialAPIAndPreservesServerFailure() async {
let api = TravelAlbumMockAPI()
let handler = TravelAlbumPreviewActionHandler(api: api)
let success = await handler.deleteProject(originalMaterialId: 8)
XCTAssertEqual(api.deletedMaterialIds, [8])
XCTAssertEqual(success, .success("删除成功"))
api.deleteMaterialError = APIError.serverCode(500, "服务暂不可用")
let failure = await handler.deleteProject(originalMaterialId: 9)
XCTAssertEqual(api.deletedMaterialIds, [8, 9])
XCTAssertEqual(failure, .failure("服务暂不可用"))
}
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.deletedMaterialIds, [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.deletedMaterialIds, [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.deletedMaterialIds, [1])
XCTAssertNil(host.presentedViewController)
window.isHidden = true
}
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: "")]
)
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 9,
scenicId: 18,
materialIds: [1, 2, 3, 4]
)
let controller = TravelAlbumAIRetouchTemplateViewController(
viewModel: viewModel,
api: api,
onSubmitted: {}
)
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()
XCTAssertTrue(collectionView.collectionViewLayout is UICollectionViewCompositionalLayout)
XCTAssertFalse(bottomBar.isDescendant(of: collectionView))
XCTAssertEqual(cancelButton.configuration?.title, "取消")
XCTAssertEqual(confirmButton.configuration?.title, "确定")
XCTAssertEqual(cancelButton.bounds.width, confirmButton.bounds.width, accuracy: 0.5)
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: "")]
)
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 9,
scenicId: 18,
materialIds: [1, 2, 3, 4]
)
let controller = TravelAlbumAIRetouchTemplateViewController(
viewModel: viewModel,
api: api,
onSubmitted: {}
)
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 accessibleLabels = controller.view.allAccessibilityLabels()
XCTAssertTrue(accessibleLabels.contains("原图精修"))
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
XCTAssertTrue(accessibleLabels.contains("封面风格模板"))
let modeCell = TravelAlbumAIRetouchModeCell(frame: .zero)
modeCell.apply()
XCTAssertEqual(modeCell.accessibilityLabel, "AI精修,按张收费,剩余张数暂不可用")
}
func testAIRetouchTemplateCellExposesSelectedState() {
let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero)
let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")
cell.apply(template: template, selected: true)
XCTAssertEqual(cell.contentView.layer.borderWidth, 2)
XCTAssertTrue(cell.accessibilityTraits.contains(.selected))
XCTAssertEqual(cell.accessibilityValue, "已选择")
cell.apply(template: template, selected: false)
XCTAssertEqual(cell.contentView.layer.borderWidth, 1)
XCTAssertFalse(cell.accessibilityTraits.contains(.selected))
XCTAssertEqual(cell.accessibilityValue, "未选择")
}
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws {
let cases: [(TravelAlbumMaterial, String, UInt)] = [
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
(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.materialStatusBadge"
}
)
let badgeLabel = try XCTUnwrap(
cell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadgeLabel"
} 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 hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
hiddenCell.apply(
material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"),
selectionMode: false,
selected: false
)
let hiddenBadge = try XCTUnwrap(
hiddenCell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
}
)
XCTAssertTrue(hiddenBadge.isHidden)
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("状态:") == true)
}
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() }
}
}
+138
View File
@@ -26,6 +26,46 @@ final class TravelAlbumModelsTests: XCTestCase {
XCTAssertFalse(project.hasVariants)
}
func testPreviewProjectMapsNonemptyAIResultURLsInCanonicalOrder() {
let material = TravelAlbumMaterial(
id: 8,
fileName: "IMG_0008.JPG",
fileUrl: "https://cdn.example.com/original.jpg",
fileSize: 4096,
coverUrl: "https://cdn.example.com/cover.jpg",
aiRefinedURL: " https://cdn.example.com/refined.jpg ",
aiAtmosphereURL: "https://cdn.example.com/atmosphere.jpg"
)
let project = TravelAlbumPreviewProject(material: material)
XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .retouched, .atmosphere])
XCTAssertEqual(
project.asset(for: .retouched)?.displayURL,
"https://cdn.example.com/refined.jpg"
)
XCTAssertEqual(
project.asset(for: .atmosphere)?.previewURL,
"https://cdn.example.com/atmosphere.jpg"
)
XCTAssertNil(project.asset(for: .cover))
XCTAssertTrue(project.hasVariants)
}
func testPreviewProjectIgnoresBlankAIResultURLs() {
let material = TravelAlbumMaterial(
id: 8,
fileUrl: "https://cdn.example.com/original.jpg",
aiRefinedURL: " \n ",
aiAtmosphereURL: "\t"
)
XCTAssertEqual(
TravelAlbumPreviewProject(material: material).orderedAssets.map(\.kind),
[.original]
)
}
func testPreviewAssetFallsBackToAvailableURLForBothQualityLevels() {
let missingOriginal = TravelAlbumPreviewAsset(
id: "cover-only",
@@ -91,6 +131,36 @@ final class TravelAlbumModelsTests: XCTestCase {
XCTAssertEqual(nodes[target], TravelAlbumPreviewNode(projectIndex: 0, kind: .original))
}
func testPreviewDeletionPrefersNextThenFallsBackToPreviousAndClosesWhenEmpty() {
XCTAssertEqual(
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
deletedProjectIndex: 0,
remainingProjectCount: 2
),
0
)
XCTAssertEqual(
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
deletedProjectIndex: 1,
remainingProjectCount: 2
),
1
)
XCTAssertEqual(
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
deletedProjectIndex: 2,
remainingProjectCount: 2
),
1
)
XCTAssertNil(
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
deletedProjectIndex: 0,
remainingProjectCount: 0
)
)
}
func testPlaceholderPreviewActionsReturnUnavailableWithoutMutation() async {
let handler = PlaceholderTravelAlbumPreviewActionHandler()
let aiResult = await handler.requestAIRetouch(originalMaterialId: 9)
@@ -153,6 +223,10 @@ final class TravelAlbumModelsTests: XCTestCase {
"file_size": 2048,
"cover_url": "",
"is_purchased": false,
"ai_retouch_status": 3,
"ai_retouch_status_name": "AI已修",
"ai_refined_url": "https://cdn/refined.jpg",
"ai_atmosphere_url": "https://cdn/atmosphere.jpg",
"created_at": "",
"updated_at": ""
}
@@ -162,6 +236,70 @@ final class TravelAlbumModelsTests: XCTestCase {
XCTAssertEqual(material.userEquityTravelId, 7)
XCTAssertEqual(material.fileName, "IMG_0001.JPG")
XCTAssertFalse(material.isPurchased)
XCTAssertEqual(material.aiRetouchStatus, 3)
XCTAssertEqual(material.aiRetouchStatusName, "AI已修")
XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg")
XCTAssertEqual(material.aiAtmosphereURL, "https://cdn/atmosphere.jpg")
}
func testTravelAlbumMaterialDefaultsInvalidAIFields() throws {
let json = """
{
"id": 11,
"user_equity_travel_id": 7,
"status": 1,
"order_number": "",
"user_id": 9,
"file_name": "IMG_0001.JPG",
"file_type": 2,
"file_url": "https://cdn/a.jpg",
"file_size": 2048,
"cover_url": "",
"is_purchased": false,
"ai_retouch_status": "invalid",
"ai_retouch_status_name": null,
"ai_refined_url": 123,
"created_at": "",
"updated_at": ""
}
""".data(using: .utf8)!
let material = try JSONDecoder().decode(TravelAlbumMaterial.self, from: json)
XCTAssertEqual(material.aiRetouchStatus, 0)
XCTAssertEqual(material.aiRetouchStatusName, "")
XCTAssertEqual(material.aiRefinedURL, "")
XCTAssertEqual(material.aiAtmosphereURL, "")
}
func testTravelAlbumMaterialBadgePriorityAndFallbacks() {
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 0).badgePresentation)
XCTAssertEqual(
TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 0, aiRetouchStatusName: "已上传")
.badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ").badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 3).badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面").badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 4).badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败")
)
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知").badgePresentation)
}
func testDisplayFormatters() {
@@ -173,6 +173,39 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.selectedMaterialIds, [1])
}
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
viewModel.completeAIRetouchSubmission()
XCTAssertFalse(viewModel.isSelectionMode)
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
}
func testPreviewDeletionRemovesMaterialAndSynchronizesCounts() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(
total: 3,
list: [
TravelAlbumMaterial(id: 1),
TravelAlbumMaterial(id: 2, isPurchased: true),
TravelAlbumMaterial(id: 3),
]
),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.loadMaterials(reset: true, api: api)
viewModel.removeMaterialAfterPreviewDeletion(id: 2)
XCTAssertEqual(viewModel.materials.map(\.id), [1, 3])
XCTAssertEqual(viewModel.allPhotoCount, 2)
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
}
func testDeleteAlbumCallsCallback() async {
let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
@@ -1015,7 +1048,13 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
var uploadMaterialResponse = TravelAlbumMaterial()
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse()
var createError: Error?
var aiRetouchTemplatesError: Error?
var submitAIRetouchError: Error?
var submitAIRetouchDelayNanoseconds: UInt64 = 0
var deleteMaterialError: Error?
var deleteMaterialDelayNanoseconds: UInt64 = 0
private(set) var availableOrdersCallCount = 0
private(set) var createRequests: [TravelAlbumCreateRequest] = []
@@ -1024,6 +1063,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
private(set) var materialClientPhotoIdsCallCount = 0
private(set) var deletedAlbumIds: [Int] = []
private(set) var deletedMaterialIds: [Int] = []
private(set) var aiRetouchTemplateScenicIds: [Int] = []
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
availableOrdersCallCount += 1
@@ -1084,9 +1125,27 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
func deleteMaterial(id: Int) async throws {
deletedMaterialIds.append(id)
if deleteMaterialDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: deleteMaterialDelayNanoseconds)
}
if let deleteMaterialError { throw deleteMaterialError }
}
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
mpCodeResponse
}
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse {
aiRetouchTemplateScenicIds.append(scenicId)
if let aiRetouchTemplatesError { throw aiRetouchTemplatesError }
return aiRetouchTemplatesResponse
}
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
aiRetouchRequests.append(request)
if submitAIRetouchDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: submitAIRetouchDelayNanoseconds)
}
if let submitAIRetouchError { throw submitAIRetouchError }
}
}