diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index ff9638f..5e507f3 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -386,10 +386,17 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable { } } - /// 当前分组是否允许不选择;仅首次修图的氛围感模板选填。 + /// 当前分组是否允许不选择;首次修图和原图 Tab 覆盖重修的氛围感模板选填。 func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool { - if case .initial = self, category == .atmosphere { return true } - return false + guard category == .atmosphere else { return false } + switch self { + case .initial: + return true + case .reretouch(_, _, .all): + return true + case .reretouch: + return false + } } /// 工作流目标是否满足接口的最小参数要求。 diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift index ca3e7c4..7bb4bba 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift @@ -66,9 +66,12 @@ final class TravelAlbumAIRetouchTemplateViewModel { return false } - /// 首次修图页固定展示的氛围感与封面生成规则说明。 + /// 首次修图页按照当前选图数量展示氛围感与封面生成规则。 var initialTipsText: String { - "Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。" + if visibleCategories.contains(.cover) { + return "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。" + } + return "Tips:氛围感修图为选填,选中后每张照片会额外生成1个独立结果。" } /// 当前分组是否为选填。 diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift index 6486e40..ee52f6f 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift @@ -284,8 +284,8 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { header.apply( title: category.title, badge: category == .cover - ? .gift - : (self.viewModel.isOptional(category) ? .optional : nil) + ? .requiredGift + : (self.viewModel.isOptional(category) ? .optional : .required) ) case .mode: return nil @@ -845,11 +845,12 @@ final class TravelAlbumAIRetouchModeCell: UICollectionViewCell { /// 模板分组标题右侧的业务标记。 fileprivate enum AIRetouchTemplateSectionBadge { + case required case optional - case gift + case requiredGift } -/// AI 修图模板分组标题,可附带选填或赠送标记。 +/// AI 修图模板分组标题,展示必选、选填或封面赠送规则。 final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView { static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader" @@ -896,12 +897,16 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView { titleLabel.text = title badgeContainer.isHidden = badge == nil switch badge { + case .required: + badgeLabel.text = "必选" + badgeLabel.textColor = AIRetouchTemplateStyle.danger + badgeContainer.backgroundColor = AIRetouchTemplateStyle.danger.withAlphaComponent(0.1) case .optional: badgeLabel.text = "选填" badgeLabel.textColor = AIRetouchTemplateStyle.primary badgeContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1) - case .gift: - badgeLabel.text = "赠送 · 不占额度" + case .requiredGift: + badgeLabel.text = "必选 · 赠送 · 不占额度" badgeLabel.textColor = AIRetouchTemplateStyle.gift badgeContainer.backgroundColor = AIRetouchTemplateStyle.giftBackground case nil: diff --git a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift index 656fa68..c384c04 100644 --- a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift +++ b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift @@ -25,7 +25,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { XCTAssertTrue(viewModel.shouldShowInitialTips) XCTAssertEqual( viewModel.initialTipsText, - "Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。" + "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。" ) XCTAssertEqual(viewModel.visibleCategories, [.refined, .atmosphere, .cover]) XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11) @@ -44,6 +44,14 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { ) 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) @@ -126,7 +134,10 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { await all.loadTemplates(api: api) XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere]) XCTAssertEqual(all.selectedRefinedTemplateId, 11) - XCTAssertEqual(all.selectedAtmosphereTemplateId, 21) + XCTAssertNil(all.selectedAtmosphereTemplateId) + XCTAssertTrue(all.isOptional(.atmosphere)) + XCTAssertEqual(all.requiredQuota, 1) + XCTAssertTrue(all.canSubmit) await all.submit(api: api) XCTAssertEqual(api.aiReretouchRequests, [ @@ -149,7 +160,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { aiRetouchBatchId: 90, type: .all, refinedTemplateId: 11, - atmosphereTemplateId: 21 + atmosphereTemplateId: nil ), ]) } @@ -172,12 +183,13 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { XCTAssertTrue(api.aiReretouchRequests.isEmpty) } - func testMissingRequiredTemplateDisablesMatchingWorkflow() async { + func testMissingOptionalAtmosphereDoesNotDisableAllReretouchWorkflow() async { let api = makeAPI() api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( refinedTemplates: [template(11, "清透")], atmosphereTemplates: [], - coverTemplates: [] + coverTemplates: [], + remainingQuota: 100 ) let viewModel = TravelAlbumAIRetouchTemplateViewModel( scenicId: 18, @@ -186,8 +198,42 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { await viewModel.loadTemplates(api: api) - XCTAssertFalse(viewModel.canSubmit) - XCTAssertEqual(viewModel.validationMessage, "暂无可用的氛围感修图模板") + 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 { diff --git a/suixinkanTests/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbumAPITests.swift index d91733d..c6dc4c1 100644 --- a/suixinkanTests/TravelAlbumAPITests.swift +++ b/suixinkanTests/TravelAlbumAPITests.swift @@ -243,6 +243,7 @@ final class TravelAlbumAPITests: XCTestCase { jobSubmissionJSON(batchId: 51, albumId: 6), jobSubmissionJSON(batchId: 52, albumId: 6), jobSubmissionJSON(batchId: 53, albumId: 6), + jobSubmissionJSON(batchId: 54, albumId: 6), ]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) @@ -273,13 +274,22 @@ final class TravelAlbumAPITests: XCTestCase { atmosphereTemplateId: 22 ) ) + try await api.submitAIReretouch( + TravelAlbumAIReretouchRequest( + id: 14, + aiRetouchBatchId: 54, + type: .all, + refinedTemplateId: 21, + atmosphereTemplateId: nil + ) + ) let bodies = try session.requests.map { request in try JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any] } XCTAssertEqual(session.requests.map { $0.url?.path }, Array( repeating: "/api/yf-handset-app/photog/travel-album/ai-reretouch", - count: 3 + count: 4 )) XCTAssertEqual(Set(bodies[0]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "refined_template_id"]) XCTAssertEqual(Set(bodies[1]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "atmosphere_template_id"]) @@ -290,9 +300,16 @@ final class TravelAlbumAPITests: XCTestCase { "refined_template_id", "atmosphere_template_id", ]) + XCTAssertEqual(Set(bodies[3]?.keys.map { $0 } ?? []), [ + "id", + "ai_retouch_batch_id", + "type", + "refined_template_id", + ]) XCTAssertEqual(bodies[0]?["type"] as? Int, 1) XCTAssertEqual(bodies[1]?["type"] as? Int, 2) XCTAssertEqual(bodies[2]?["type"] as? Int, 3) + XCTAssertEqual(bodies[3]?["type"] as? Int, 3) } func testAIJobListBuildsCursorQueryAndDecodesUnknownStatusSafely() async throws { diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift index ffb707f..fe1c23a 100644 --- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -763,7 +763,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertEqual(selectionCountLabel.text, "已选择 4 张照片") XCTAssertEqual( tipsLabel.text, - "Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。" + "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。" ) XCTAssertFalse(tipsContainer.isHidden) XCTAssertEqual(tipsContainer.backgroundColor?.travelAlbumTestHexRGB, 0xF4F8FF) @@ -806,10 +806,9 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { } as? UICollectionView ) let accessibleLabels = controller.view.allAccessibilityLabels() - XCTAssertTrue(accessibleLabels.contains("原图精修")) - XCTAssertFalse(accessibleLabels.contains("原图精修,必选")) + XCTAssertTrue(accessibleLabels.contains("原图精修,必选")) XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填")) - XCTAssertTrue(accessibleLabels.contains("封面风格,赠送 · 不占额度")) + XCTAssertTrue(accessibleLabels.contains("封面风格,必选 · 赠送 · 不占额度")) collectionView.scrollToItem( at: IndexPath(item: 0, section: 4), @@ -954,7 +953,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48)) } - func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndShowsTaskAction() async throws { + func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndNotifiesSubmission() async throws { UIView.setAnimationsEnabled(false) defer { UIView.setAnimationsEnabled(true) } let api = TravelAlbumMockAPI() @@ -985,13 +984,15 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { ), ] ) + var submissionNotified = false let controller = TravelAlbumPhotoPreviewViewController( projects: [project], totalCount: 1, startProjectIndex: 0, albumId: 8, scenicIdProvider: { 18 }, - aiRetouchAPI: api + aiRetouchAPI: api, + onAIRetouchSubmitted: { submissionNotified = true } ) let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) window.rootViewController = controller @@ -1048,12 +1049,10 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertTrue(tipsLabel.isHidden) confirmButton.sendActions(for: .touchUpInside) await waitUntil { api.aiReretouchRequests.count == 1 } - await waitUntil { controller.presentedViewController is UIAlertController } + await waitUntil { submissionNotified } XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined) - let successAlert = try XCTUnwrap(controller.presentedViewController as? UIAlertController) - XCTAssertEqual(successAlert.title, "AI修图任务已提交") - XCTAssertEqual(successAlert.actions.map(\.title), ["知道了", "查看任务"]) + XCTAssertTrue(submissionNotified) XCTAssertTrue(window.rootViewController === controller) }