feat: add AI retouch and album preview actions
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user