// // 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:本次显示 3 类风格。每张照片生成「精修后 + 氛围感」2 个结果,第一张照片另生成所选封面结果。" ) 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) 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) XCTAssertEqual(all.selectedAtmosphereTemplateId, 21) 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: 21 ), ]) } 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 testMissingRequiredTemplateDisablesMatchingWorkflow() async { let api = makeAPI() api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( refinedTemplates: [template(11, "清透")], atmosphereTemplates: [], coverTemplates: [] ) let viewModel = TravelAlbumAIRetouchTemplateViewModel( scenicId: 18, workflow: .reretouch(materialId: 7, batchId: 70, type: .all) ) 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.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 = { 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() } } }