339 lines
13 KiB
Swift
339 lines
13 KiB
Swift
//
|
|
// TravelAlbumAIRetouchTemplateViewModelTests.swift
|
|
// suixinkanTests
|
|
//
|
|
|
|
import XCTest
|
|
@testable import suixinkan
|
|
|
|
/// AI 修图模板工作流、选择状态与请求分流测试。
|
|
@MainActor
|
|
final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|
func testInitialWorkflowDefaultsRequiredSelectionsAndLeavesAtmosphereEmpty() async {
|
|
let api = makeAPI()
|
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
|
albumId: 8,
|
|
scenicId: 18,
|
|
materialIds: [4, 3, 2, 1, 1]
|
|
)
|
|
|
|
await viewModel.loadTemplates(api: api)
|
|
|
|
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
|
|
XCTAssertEqual(viewModel.workflow, .initial(albumId: 8, materialIds: [1, 2, 3, 4]))
|
|
XCTAssertEqual(viewModel.selectedPhotoCount, 4)
|
|
XCTAssertTrue(viewModel.shouldShowInitialTips)
|
|
XCTAssertEqual(
|
|
viewModel.initialTipsText,
|
|
"Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
|
|
)
|
|
XCTAssertEqual(viewModel.visibleCategories, [.refined, .atmosphere, .cover])
|
|
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
|
|
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
|
XCTAssertEqual(viewModel.selectedCoverTemplateId, 31)
|
|
XCTAssertTrue(viewModel.isOptional(.atmosphere))
|
|
XCTAssertTrue(viewModel.canSubmit)
|
|
}
|
|
|
|
func testInitialAtmosphereSelectionTogglesOffWhenTappedAgain() async {
|
|
let api = makeAPI()
|
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
|
albumId: 8,
|
|
scenicId: 18,
|
|
materialIds: [1]
|
|
)
|
|
await viewModel.loadTemplates(api: api)
|
|
|
|
XCTAssertEqual(
|
|
viewModel.initialTipsText,
|
|
"Tips:氛围感修图为选填,选中后每张照片会额外生成1个独立结果。"
|
|
)
|
|
|
|
viewModel.toggleTemplate(id: 11, category: .refined)
|
|
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
|
|
|
|
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
|
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 21)
|
|
|
|
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
|
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
|
}
|
|
|
|
func testInitialSubmissionUsesCoverTemplateOnlyForFourOrMoreMaterials() async {
|
|
let api = makeAPI()
|
|
let threePhotoViewModel = TravelAlbumAIRetouchTemplateViewModel(
|
|
albumId: 8,
|
|
scenicId: 18,
|
|
materialIds: [3, 1, 2]
|
|
)
|
|
await threePhotoViewModel.loadTemplates(api: api)
|
|
await threePhotoViewModel.submit(api: api)
|
|
|
|
XCTAssertEqual(
|
|
api.aiRetouchRequests[0],
|
|
TravelAlbumAIRetouchRequest(
|
|
userEquityTravelId: 8,
|
|
materialIds: [1, 2, 3],
|
|
refinedTemplateId: 11,
|
|
atmosphereTemplateId: nil,
|
|
coverTemplateId: nil
|
|
)
|
|
)
|
|
|
|
let fourPhotoViewModel = TravelAlbumAIRetouchTemplateViewModel(
|
|
albumId: 8,
|
|
scenicId: 18,
|
|
materialIds: [1, 2, 3, 4]
|
|
)
|
|
await fourPhotoViewModel.loadTemplates(api: api)
|
|
fourPhotoViewModel.toggleTemplate(id: 12, category: .refined)
|
|
fourPhotoViewModel.toggleTemplate(id: 21, category: .atmosphere)
|
|
await fourPhotoViewModel.submit(api: api)
|
|
|
|
XCTAssertEqual(
|
|
api.aiRetouchRequests[1],
|
|
TravelAlbumAIRetouchRequest(
|
|
userEquityTravelId: 8,
|
|
materialIds: [1, 2, 3, 4],
|
|
refinedTemplateId: 12,
|
|
atmosphereTemplateId: 21,
|
|
coverTemplateId: 31
|
|
)
|
|
)
|
|
}
|
|
|
|
func testReretouchWorkflowShowsAndSubmitsOnlyRequiredCategories() async {
|
|
let api = makeAPI()
|
|
let refined = TravelAlbumAIRetouchTemplateViewModel(
|
|
scenicId: 18,
|
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
|
|
)
|
|
await refined.loadTemplates(api: api)
|
|
XCTAssertEqual(refined.selectedPhotoCount, 1)
|
|
XCTAssertFalse(refined.shouldShowInitialTips)
|
|
XCTAssertEqual(refined.visibleCategories, [.refined])
|
|
XCTAssertEqual(refined.selectedRefinedTemplateId, 11)
|
|
XCTAssertNil(refined.selectedAtmosphereTemplateId)
|
|
await refined.submit(api: api)
|
|
|
|
let atmosphere = TravelAlbumAIRetouchTemplateViewModel(
|
|
scenicId: 18,
|
|
workflow: .reretouch(materialId: 8, batchId: 80, type: .atmosphere)
|
|
)
|
|
await atmosphere.loadTemplates(api: api)
|
|
XCTAssertEqual(atmosphere.visibleCategories, [.atmosphere])
|
|
XCTAssertEqual(atmosphere.selectedAtmosphereTemplateId, 21)
|
|
atmosphere.toggleTemplate(id: 21, category: .atmosphere)
|
|
XCTAssertEqual(atmosphere.selectedAtmosphereTemplateId, 21)
|
|
await atmosphere.submit(api: api)
|
|
|
|
let all = TravelAlbumAIRetouchTemplateViewModel(
|
|
scenicId: 18,
|
|
workflow: .reretouch(materialId: 9, batchId: 90, type: .all)
|
|
)
|
|
await all.loadTemplates(api: api)
|
|
XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere])
|
|
XCTAssertEqual(all.selectedRefinedTemplateId, 11)
|
|
XCTAssertNil(all.selectedAtmosphereTemplateId)
|
|
XCTAssertTrue(all.isOptional(.atmosphere))
|
|
XCTAssertEqual(all.requiredQuota, 1)
|
|
XCTAssertTrue(all.canSubmit)
|
|
await all.submit(api: api)
|
|
|
|
XCTAssertEqual(api.aiReretouchRequests, [
|
|
TravelAlbumAIReretouchRequest(
|
|
id: 7,
|
|
aiRetouchBatchId: 70,
|
|
type: .refined,
|
|
refinedTemplateId: 11,
|
|
atmosphereTemplateId: nil
|
|
),
|
|
TravelAlbumAIReretouchRequest(
|
|
id: 8,
|
|
aiRetouchBatchId: 80,
|
|
type: .atmosphere,
|
|
refinedTemplateId: nil,
|
|
atmosphereTemplateId: 21
|
|
),
|
|
TravelAlbumAIReretouchRequest(
|
|
id: 9,
|
|
aiRetouchBatchId: 90,
|
|
type: .all,
|
|
refinedTemplateId: 11,
|
|
atmosphereTemplateId: nil
|
|
),
|
|
])
|
|
}
|
|
|
|
func testInvalidReretouchBatchDisablesSubmission() async {
|
|
let api = makeAPI()
|
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
|
scenicId: 18,
|
|
workflow: .reretouch(materialId: 7, batchId: 0, type: .refined)
|
|
)
|
|
var message: String?
|
|
viewModel.onShowMessage = { message = $0 }
|
|
|
|
await viewModel.loadTemplates(api: api)
|
|
await viewModel.submit(api: api)
|
|
|
|
XCTAssertFalse(viewModel.canSubmit)
|
|
XCTAssertEqual(viewModel.validationMessage, "当前图片缺少修图批次,请刷新后重试")
|
|
XCTAssertEqual(message, "当前图片缺少修图批次,请刷新后重试")
|
|
XCTAssertTrue(api.aiReretouchRequests.isEmpty)
|
|
}
|
|
|
|
func testMissingOptionalAtmosphereDoesNotDisableAllReretouchWorkflow() async {
|
|
let api = makeAPI()
|
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
|
refinedTemplates: [template(11, "清透")],
|
|
atmosphereTemplates: [],
|
|
coverTemplates: [],
|
|
remainingQuota: 100
|
|
)
|
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
|
scenicId: 18,
|
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .all)
|
|
)
|
|
|
|
await viewModel.loadTemplates(api: api)
|
|
|
|
XCTAssertTrue(viewModel.canSubmit)
|
|
XCTAssertNil(viewModel.validationMessage)
|
|
XCTAssertEqual(viewModel.requiredQuota, 1)
|
|
}
|
|
|
|
func testMissingRequiredRefinedAndCoverTemplatesDisableMatchingWorkflows() async {
|
|
let refinedAPI = makeAPI()
|
|
refinedAPI.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
|
refinedTemplates: [],
|
|
atmosphereTemplates: [template(21, "暖阳")],
|
|
coverTemplates: [template(31, "杂志")],
|
|
remainingQuota: 100
|
|
)
|
|
let reretouch = TravelAlbumAIRetouchTemplateViewModel(
|
|
scenicId: 18,
|
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .all)
|
|
)
|
|
await reretouch.loadTemplates(api: refinedAPI)
|
|
XCTAssertFalse(reretouch.canSubmit)
|
|
XCTAssertEqual(reretouch.validationMessage, "暂无可用的原图精修模板")
|
|
|
|
let coverAPI = makeAPI()
|
|
coverAPI.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
|
refinedTemplates: [template(11, "清透")],
|
|
atmosphereTemplates: [template(21, "暖阳")],
|
|
coverTemplates: [],
|
|
remainingQuota: 100
|
|
)
|
|
let batch = TravelAlbumAIRetouchTemplateViewModel(
|
|
albumId: 8,
|
|
scenicId: 18,
|
|
materialIds: [1, 2, 3, 4]
|
|
)
|
|
await batch.loadTemplates(api: coverAPI)
|
|
XCTAssertFalse(batch.canSubmit)
|
|
XCTAssertEqual(batch.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.submitAIReretouchError = APIError.serverCode(500, "提交服务繁忙")
|
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
|
scenicId: 18,
|
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
|
|
)
|
|
var message: String?
|
|
var submitted = false
|
|
viewModel.onShowMessage = { message = $0 }
|
|
viewModel.onSubmitted = { _ in submitted = true }
|
|
await viewModel.loadTemplates(api: api)
|
|
|
|
await viewModel.submit(api: api)
|
|
|
|
XCTAssertEqual(api.aiReretouchRequests.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)
|
|
}
|
|
|
|
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, "杂志")],
|
|
remainingQuota: remainingQuota
|
|
)
|
|
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()
|
|
}
|
|
}
|
|
}
|