// // 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() } } }