feat: 完善相册 AI 修图模板选择流程
This commit is contained in:
@@ -240,12 +240,33 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||
XCTAssertFalse(viewModel.isSubmitting)
|
||||
}
|
||||
|
||||
private func makeAPI() -> TravelAlbumMockAPI {
|
||||
func testRemainingQuotaDisablesSubmissionWhenSelectedOutputsExceedBalance() async {
|
||||
let api = makeAPI(remainingQuota: 2)
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 8,
|
||||
scenicId: 18,
|
||||
materialIds: [1, 2]
|
||||
)
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.remainingQuota, 2)
|
||||
XCTAssertEqual(viewModel.requiredQuota, 2)
|
||||
XCTAssertTrue(viewModel.canSubmit)
|
||||
|
||||
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
||||
|
||||
XCTAssertEqual(viewModel.requiredQuota, 4)
|
||||
XCTAssertFalse(viewModel.canSubmit)
|
||||
XCTAssertEqual(viewModel.validationMessage, "剩余修图次数不足,需要4次,当前剩余2次")
|
||||
}
|
||||
|
||||
private func makeAPI(remainingQuota: Int = 100) -> TravelAlbumMockAPI {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: [template(11, "清透"), template(12, "自然")],
|
||||
atmosphereTemplates: [template(21, "暖阳")],
|
||||
coverTemplates: [template(31, "杂志")]
|
||||
coverTemplates: [template(31, "杂志")],
|
||||
remainingQuota: remainingQuota
|
||||
)
|
||||
return api
|
||||
}
|
||||
|
||||
@@ -84,6 +84,26 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code")
|
||||
}
|
||||
|
||||
func testMaterialInfoBuildsQueryAndDecodesMaterial() async throws {
|
||||
let data = envelopeJSON(
|
||||
#"{"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_retouch_batch_id":60,"ai_refined_url":"https://cdn/refined.jpg","ai_atmosphere_url":"https://cdn/atmosphere.jpg","created_at":"","updated_at":""}"#
|
||||
)
|
||||
let session = MockURLSession(responses: [data])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
let material = try await api.materialInfo(userEquityTravelId: 3, materialId: 6)
|
||||
|
||||
XCTAssertEqual(material.id, 6)
|
||||
XCTAssertEqual(material.aiRetouchBatchId, 60)
|
||||
XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg")
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/material-info")
|
||||
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
|
||||
XCTAssertEqual(query?.first { $0.name == "material_id" }?.value, "6")
|
||||
}
|
||||
|
||||
func testUploadMaterialBuildsPathAndBody() async throws {
|
||||
let material = envelopeJSON(#"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":1,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"created_at":"2026-07-08 10:00:00","updated_at":"2026-07-08 10:00:00"}"#)
|
||||
let session = MockURLSession(responses: [material])
|
||||
@@ -126,7 +146,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"}]}"#
|
||||
#"{"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}"#
|
||||
)
|
||||
let session = MockURLSession(responses: [data])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
@@ -136,6 +156,7 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
XCTAssertEqual(response.refinedTemplates.first?.name, "清透")
|
||||
XCTAssertEqual(response.atmosphereTemplates.first?.id, 2)
|
||||
XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg")
|
||||
XCTAssertEqual(response.remainingQuota, 12)
|
||||
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")
|
||||
|
||||
@@ -250,7 +250,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
|
||||
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
|
||||
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")]
|
||||
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")],
|
||||
remainingQuota: 8
|
||||
)
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 9,
|
||||
@@ -308,7 +309,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
|
||||
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
|
||||
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")]
|
||||
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")],
|
||||
remainingQuota: 8
|
||||
)
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 9,
|
||||
@@ -330,9 +332,13 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
|
||||
XCTAssertTrue(accessibleLabels.contains("封面风格模板"))
|
||||
|
||||
let modeCell = TravelAlbumAIRetouchModeCell(frame: .zero)
|
||||
modeCell.apply()
|
||||
XCTAssertEqual(modeCell.accessibilityLabel, "AI精修,按张收费,剩余张数暂不可用")
|
||||
let quotaLabel = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchRemainingQuotaLabel"
|
||||
} as? UILabel
|
||||
)
|
||||
XCTAssertEqual(quotaLabel.text, "剩余 8 次")
|
||||
XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8次修图额度")
|
||||
}
|
||||
|
||||
func testAIRetouchTemplateCellExposesSelectedState() {
|
||||
@@ -358,7 +364,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 11, name: "清透", previewURL: "")],
|
||||
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "")]
|
||||
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "")],
|
||||
remainingQuota: 8
|
||||
)
|
||||
let project = TravelAlbumPreviewProject(
|
||||
originalMaterialId: 7,
|
||||
@@ -451,10 +458,11 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
projects: [TravelAlbumPreviewProject(originalMaterialId: 7, assets: [original, retouched])],
|
||||
totalCount: 1,
|
||||
startProjectIndex: 0,
|
||||
reload: {
|
||||
reload: { materialId in
|
||||
XCTAssertEqual(materialId, 7)
|
||||
reloadCount += 1
|
||||
let assets = reloadCount == 1 ? [original, retouched] : [original]
|
||||
return ([TravelAlbumPreviewProject(originalMaterialId: 7, assets: assets)], 1)
|
||||
return TravelAlbumPreviewProject(originalMaterialId: 7, assets: assets)
|
||||
}
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
@@ -487,6 +495,43 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "原图.jpg" })
|
||||
}
|
||||
|
||||
func testPreviewRefreshUsesGlobalLoadingWithoutToast() async throws {
|
||||
GlobalLoadingManager.shared.hideAll()
|
||||
defer { GlobalLoadingManager.shared.hideAll() }
|
||||
let original = TravelAlbumPreviewAsset(
|
||||
id: "original-7",
|
||||
kind: .original,
|
||||
fileURL: "",
|
||||
coverURL: "",
|
||||
fileName: "原图.jpg",
|
||||
fileSize: 0
|
||||
)
|
||||
let project = TravelAlbumPreviewProject(originalMaterialId: 7, assets: [original])
|
||||
let controller = TravelAlbumPhotoPreviewViewController(
|
||||
projects: [project],
|
||||
totalCount: 1,
|
||||
startProjectIndex: 0,
|
||||
reload: { _ in
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
return project
|
||||
}
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
let refreshButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.previewRefreshButton"
|
||||
} as? UIButton
|
||||
)
|
||||
|
||||
refreshButton.sendActions(for: .touchUpInside)
|
||||
|
||||
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
|
||||
await waitUntil { !GlobalLoadingManager.shared.isShowing && refreshButton.isEnabled }
|
||||
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
||||
XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新成功" })
|
||||
XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新失败" })
|
||||
}
|
||||
|
||||
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws {
|
||||
let cases: [(TravelAlbumMaterial, String, UInt)] = [
|
||||
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
|
||||
|
||||
@@ -206,39 +206,29 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
|
||||
}
|
||||
|
||||
func testPreviewReloadRefetchesAllLoadedPagesAndReplacesMaterialsById() async throws {
|
||||
func testRefreshMaterialUpdatesOnlyMatchingCachedMaterial() async throws {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.materialListResponses = [
|
||||
TravelAlbumListResponse(
|
||||
total: 35,
|
||||
list: (1 ... 30).map { TravelAlbumMaterial(id: $0) }
|
||||
),
|
||||
TravelAlbumListResponse(
|
||||
total: 35,
|
||||
list: (31 ... 35).map { TravelAlbumMaterial(id: $0) }
|
||||
),
|
||||
TravelAlbumListResponse(
|
||||
total: 35,
|
||||
list: (1 ... 30).map {
|
||||
TravelAlbumMaterial(id: $0, aiRetouchBatchId: $0 == 7 ? 700 : 0)
|
||||
}
|
||||
),
|
||||
TravelAlbumListResponse(
|
||||
total: 35,
|
||||
list: (31 ... 35).map { TravelAlbumMaterial(id: $0) }
|
||||
total: 2,
|
||||
list: [TravelAlbumMaterial(id: 7), TravelAlbumMaterial(id: 8)]
|
||||
),
|
||||
]
|
||||
api.materialInfoResponse = TravelAlbumMaterial(
|
||||
id: 7,
|
||||
aiRetouchBatchId: 700,
|
||||
aiRefinedURL: "https://cdn/refined.jpg"
|
||||
)
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||
await viewModel.loadMaterials(reset: true, api: api)
|
||||
await viewModel.loadMaterials(reset: false, api: api)
|
||||
|
||||
let response = try await viewModel.reloadLoadedMaterials(api: api)
|
||||
let material = try await viewModel.refreshMaterial(id: 7, api: api)
|
||||
|
||||
XCTAssertEqual(response.total, 35)
|
||||
XCTAssertEqual(response.list.count, 35)
|
||||
XCTAssertEqual(response.list.first { $0.id == 7 }?.aiRetouchBatchId, 700)
|
||||
XCTAssertEqual(api.materialRequests.suffix(2).map(\.page), [1, 2])
|
||||
XCTAssertEqual(api.materialRequests.suffix(2).map(\.pageSize), [30, 30])
|
||||
XCTAssertEqual(material.aiRetouchBatchId, 700)
|
||||
XCTAssertEqual(viewModel.materials.first { $0.id == 7 }?.aiRefinedURL, "https://cdn/refined.jpg")
|
||||
XCTAssertEqual(viewModel.materials.first { $0.id == 8 }?.aiRetouchBatchId, 0)
|
||||
XCTAssertEqual(api.materialInfoRequests, [.init(userEquityTravelId: 2, materialId: 7)])
|
||||
XCTAssertEqual(api.materialRequests.count, 1)
|
||||
}
|
||||
|
||||
func testDeleteAlbumCallsCallback() async {
|
||||
@@ -1074,12 +1064,19 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
let isPurchased: Int?
|
||||
}
|
||||
|
||||
struct MaterialInfoRequest: Equatable {
|
||||
let userEquityTravelId: Int
|
||||
let materialId: Int
|
||||
}
|
||||
|
||||
var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
|
||||
var listResponse = TravelAlbumListResponse<TravelAlbum>()
|
||||
var createResponse = TravelAlbumCreateResponse(id: 0)
|
||||
var infoResponse = TravelAlbum()
|
||||
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
|
||||
var materialListFailingCallIndexes: Set<Int> = []
|
||||
var materialInfoResponse = TravelAlbumMaterial()
|
||||
var materialInfoError: Error?
|
||||
var uploadMaterialResponse = TravelAlbumMaterial()
|
||||
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
|
||||
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
|
||||
@@ -1096,6 +1093,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
private(set) var availableOrdersCallCount = 0
|
||||
private(set) var createRequests: [TravelAlbumCreateRequest] = []
|
||||
private(set) var materialRequests: [MaterialRequest] = []
|
||||
private(set) var materialInfoRequests: [MaterialInfoRequest] = []
|
||||
private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
|
||||
private(set) var materialClientPhotoIdsCallCount = 0
|
||||
private(set) var deletedAlbumIds: [Int] = []
|
||||
@@ -1147,6 +1145,14 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
return materialListResponses.removeFirst()
|
||||
}
|
||||
|
||||
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial {
|
||||
materialInfoRequests.append(
|
||||
MaterialInfoRequest(userEquityTravelId: userEquityTravelId, materialId: materialId)
|
||||
)
|
||||
if let materialInfoError { throw materialInfoError }
|
||||
return materialInfoResponse
|
||||
}
|
||||
|
||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
||||
uploadMaterialRequests.append(request)
|
||||
return uploadMaterialResponse
|
||||
|
||||
Reference in New Issue
Block a user