diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index 5e507f3..a9b0e6e 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -224,7 +224,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab } } -/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。 +/// 相册素材网格角标类别,用于稳定映射独立业务状态和语义颜色。 enum TravelAlbumMaterialBadgeKind: Sendable, Equatable { case purchased case pending @@ -241,14 +241,15 @@ struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable { } extension TravelAlbumMaterial { - /// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。 - var badgePresentation: TravelAlbumMaterialBadgePresentation? { - if aiRetouchStatus == 0 { - return isPurchased - ? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购") - : nil - } + /// 生成购买状态角标;购买状态与修图状态互不覆盖。 + var purchaseBadgePresentation: TravelAlbumMaterialBadgePresentation? { + isPurchased + ? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购") + : nil + } + /// 生成 AI 修图状态角标;未进入修图流程时返回 nil。 + var aiRetouchBadgePresentation: TravelAlbumMaterialBadgePresentation? { let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines) switch aiRetouchStatus { case 1: diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift index 0a4db7a..efd505a 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift @@ -48,6 +48,7 @@ final class TravelAlbumDetailViewModel { private(set) var isRefreshing = false private(set) var isSelectionMode = false private(set) var selectedMaterialIds: Set = [] + private var selectedMaterialPurchaseStates: [Int: Bool] = [:] var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? @@ -142,12 +143,29 @@ final class TravelAlbumDetailViewModel { selectedTab == .all ? allPhotoCount : purchasedPhotoCount } + /// 当前选中素材中的已购买数量。 + var selectedPurchasedMaterialCount: Int { + selectedMaterialIds.count - selectedDeletableMaterialIds.count + } + + /// 删除确认弹窗文案;选中已购素材时明确说明保护规则与实际删除数量。 + var deleteSelectedConfirmationMessage: String { + let selectedCount = selectedMaterialIds.count + let purchasedCount = selectedPurchasedMaterialCount + guard purchasedCount > 0 else { + return "确定删除选中的 \(selectedCount) 张素材吗?" + } + let deletableCount = selectedCount - purchasedCount + return "已选择 \(selectedCount) 张素材,其中 \(purchasedCount) 张已购买。" + + "不会删除已购买的项目,只会删除 \(deletableCount) 张未购买的项目。" + } + /// 切换素材 tab。 func selectTab(_ tab: Tab, api: any TravelAlbumServing) async { guard selectedTab != tab else { return } selectedTab = tab isSelectionMode = false - selectedMaterialIds = [] + clearMaterialSelection() notifyStateChange() await loadMaterials(reset: true, api: api) } @@ -223,10 +241,9 @@ final class TravelAlbumDetailViewModel { /// 切换选择模式。 func toggleSelectionMode() { - guard selectedTab == .all else { return } isSelectionMode.toggle() if !isSelectionMode { - selectedMaterialIds = [] + clearMaterialSelection() } notifyStateChange() } @@ -234,14 +251,12 @@ final class TravelAlbumDetailViewModel { /// 切换素材选中状态。 func toggleMaterialSelection(_ material: TravelAlbumMaterial) { guard isSelectionMode else { return } - guard material.status == 1 else { - onShowMessage?("仅未购买素材可删除") - return - } if selectedMaterialIds.contains(material.id) { selectedMaterialIds.remove(material.id) + selectedMaterialPurchaseStates.removeValue(forKey: material.id) } else { selectedMaterialIds.insert(material.id) + selectedMaterialPurchaseStates[material.id] = material.isPurchased } notifyStateChange() } @@ -249,7 +264,7 @@ final class TravelAlbumDetailViewModel { /// AI 修图任务提交成功后退出选择模式并清空当前选择。 func completeAIRetouchSubmission() { isSelectionMode = false - selectedMaterialIds = [] + clearMaterialSelection() notifyStateChange() } @@ -264,6 +279,7 @@ final class TravelAlbumDetailViewModel { guard let index = materials.firstIndex(where: { $0.id == id }) else { return } let material = materials.remove(at: index) selectedMaterialIds.remove(id) + selectedMaterialPurchaseStates.removeValue(forKey: id) allPhotoCount = max(0, allPhotoCount - 1) if material.isPurchased { purchasedPhotoCount = max(0, purchasedPhotoCount - 1) @@ -273,16 +289,20 @@ final class TravelAlbumDetailViewModel { /// 删除已选素材。 func deleteSelectedMaterials(api: any TravelAlbumServing) async { - let ids = selectedMaterialIds.sorted() - guard !ids.isEmpty else { + guard !selectedMaterialIds.isEmpty else { onShowMessage?("请选择要删除的素材") return } + let ids = selectedDeletableMaterialIds + guard !ids.isEmpty else { + onShowMessage?("选中的素材均已购买,未删除任何项目") + return + } do { try await api.batchDeleteMaterials(ids: ids) onShowMessage?("删除成功") isSelectionMode = false - selectedMaterialIds = [] + clearMaterialSelection() await refreshAll(api: api) } catch is CancellationError { return @@ -307,4 +327,18 @@ final class TravelAlbumDetailViewModel { private func notifyStateChange() { onStateChange?() } + + private func clearMaterialSelection() { + selectedMaterialIds = [] + selectedMaterialPurchaseStates = [:] + } + + private var selectedDeletableMaterialIds: [Int] { + selectedMaterialIds + .filter { id in + guard selectedMaterialPurchaseStates[id] == false else { return false } + return materials.first(where: { $0.id == id })?.isPurchased != true + } + .sorted() + } } diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift index 26d91ef..dea84ef 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -293,14 +293,11 @@ final class TravelAlbumDetailViewController: BaseViewController { sortButton.accessibilityValue = viewModel.sortOption.title sortButton.menu = makeSortMenu() - let canSelectMaterials = viewModel.selectedTab == .all selectButton.isHidden = false - selectButton.isEnabled = canSelectMaterials - selectButton.alpha = canSelectMaterials ? 1 : 0.45 + selectButton.isEnabled = true + selectButton.alpha = 1 selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal) - selectButton.accessibilityValue = canSelectMaterials - ? (viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭") - : "已购照片不可删除" + selectButton.accessibilityValue = viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭" let selectedCount = viewModel.selectedMaterialIds.count let hasSelection = selectedCount > 0 @@ -487,7 +484,11 @@ final class TravelAlbumDetailViewController: BaseViewController { @objc private func deleteSelectedTapped() { let count = viewModel.selectedMaterialIds.count guard count > 0 else { return } - let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert) + let alert = UIAlertController( + title: "删除素材", + message: viewModel.deleteSelectedConfirmationMessage, + preferredStyle: .alert + ) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in guard let self else { return } @@ -694,14 +695,16 @@ private final class TravelAlbumInfoCard: UIView { } } -/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。 +/// 旅拍相册素材网格单元,独立展示购买、修图和选择状态。 final class TravelAlbumMaterialCell: UICollectionViewCell { static let reuseIdentifier = "TravelAlbumMaterialCell" private let imageView = UIImageView() private let checkImageView = UIImageView() - private let badgeView = UIView() - private let badgeLabel = UILabel() + private let purchaseBadgeView = UIView() + private let purchaseBadgeLabel = UILabel() + private let aiRetouchBadgeView = UIView() + private let aiRetouchBadgeLabel = UILabel() private let nameLabel = UILabel() private let sizeLabel = UILabel() @@ -715,18 +718,18 @@ final class TravelAlbumMaterialCell: UICollectionViewCell { checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35) checkImageView.layer.cornerRadius = 11 checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck" - badgeView.layer.cornerRadius = 5 - badgeView.clipsToBounds = true - badgeView.isHidden = true - badgeView.isAccessibilityElement = false - badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge" - badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold) - badgeLabel.textColor = .white - badgeLabel.textAlignment = .center - badgeLabel.isAccessibilityElement = false - badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel" - badgeLabel.setContentHuggingPriority(.required, for: .horizontal) - badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + configureBadge( + purchaseBadgeView, + label: purchaseBadgeLabel, + viewIdentifier: "travelAlbum.materialPurchaseBadge", + labelIdentifier: "travelAlbum.materialPurchaseBadgeLabel" + ) + configureBadge( + aiRetouchBadgeView, + label: aiRetouchBadgeLabel, + viewIdentifier: "travelAlbum.materialAIRetouchBadge", + labelIdentifier: "travelAlbum.materialAIRetouchBadgeLabel" + ) nameLabel.font = .systemFont(ofSize: 12, weight: .medium) nameLabel.textColor = TravelAlbumDetailStyle.textPrimary nameLabel.lineBreakMode = .byTruncatingMiddle @@ -734,8 +737,8 @@ final class TravelAlbumMaterialCell: UICollectionViewCell { sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary contentView.addSubview(imageView) - imageView.addSubview(badgeView) - badgeView.addSubview(badgeLabel) + imageView.addSubview(purchaseBadgeView) + imageView.addSubview(aiRetouchBadgeView) imageView.addSubview(checkImageView) contentView.addSubview(nameLabel) contentView.addSubview(sizeLabel) @@ -747,14 +750,13 @@ final class TravelAlbumMaterialCell: UICollectionViewCell { make.top.trailing.equalToSuperview().inset(6) make.size.equalTo(22) } - badgeView.snp.makeConstraints { make in + aiRetouchBadgeView.snp.makeConstraints { make in make.top.leading.equalToSuperview().inset(6) make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4) } - badgeLabel.snp.makeConstraints { make in - make.edges.equalToSuperview().inset( - UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6) - ) + purchaseBadgeView.snp.makeConstraints { make in + make.leading.bottom.equalToSuperview().inset(6) + make.trailing.lessThanOrEqualToSuperview().inset(6) } nameLabel.snp.makeConstraints { make in make.top.equalTo(imageView.snp.bottom).offset(6) @@ -784,14 +786,54 @@ final class TravelAlbumMaterialCell: UICollectionViewCell { checkImageView.isHidden = !selectionMode checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white - let badge = material.badgePresentation - badgeView.isHidden = badge == nil - badgeLabel.text = badge?.text - badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) } + let purchaseBadge = material.purchaseBadgePresentation + let aiRetouchBadge = material.aiRetouchBadgePresentation + applyBadge(purchaseBadge, to: purchaseBadgeView, label: purchaseBadgeLabel) + applyBadge(aiRetouchBadge, to: aiRetouchBadgeView, label: aiRetouchBadgeLabel) nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize) - let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? "" - accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)" + let purchaseAccessibilityText = purchaseBadge.map { ",购买状态:\($0.text)" } ?? "" + let aiRetouchAccessibilityText = aiRetouchBadge.map { ",修图状态:\($0.text)" } ?? "" + accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")" + + purchaseAccessibilityText + + aiRetouchAccessibilityText accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil } + + private func configureBadge( + _ badgeView: UIView, + label: UILabel, + viewIdentifier: String, + labelIdentifier: String + ) { + badgeView.layer.cornerRadius = 5 + badgeView.clipsToBounds = true + badgeView.isHidden = true + badgeView.isAccessibilityElement = false + badgeView.accessibilityIdentifier = viewIdentifier + label.font = .systemFont(ofSize: 10, weight: .semibold) + label.textColor = .white + label.textAlignment = .center + label.lineBreakMode = .byTruncatingTail + label.isAccessibilityElement = false + label.accessibilityIdentifier = labelIdentifier + label.setContentHuggingPriority(.required, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + badgeView.addSubview(label) + label.snp.makeConstraints { make in + make.edges.equalToSuperview().inset( + UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6) + ) + } + } + + private func applyBadge( + _ presentation: TravelAlbumMaterialBadgePresentation?, + to badgeView: UIView, + label: UILabel + ) { + badgeView.isHidden = presentation == nil + label.text = presentation?.text + badgeView.backgroundColor = presentation.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) } + } } diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift index fe1c23a..b7b948e 100644 --- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -1149,9 +1149,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新失败" }) } - func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws { + func testMaterialCellShowsIndependentPurchaseAndAIRetouchBadgesWithoutOverlappingSelectionCheck() throws { let cases: [(TravelAlbumMaterial, String, UInt)] = [ - (TravelAlbumMaterial(isPurchased: true), "已购", 0x475569), (TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309), (TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8), (TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857), @@ -1170,12 +1169,12 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { let badgeView = try XCTUnwrap( cell.findSubview { - $0.accessibilityIdentifier == "travelAlbum.materialStatusBadge" + $0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge" } ) let badgeLabel = try XCTUnwrap( cell.findSubview { - $0.accessibilityIdentifier == "travelAlbum.materialStatusBadgeLabel" + $0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadgeLabel" } as? UILabel ) let selectionCheck = try XCTUnwrap( @@ -1188,22 +1187,61 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertEqual(badgeLabel.text, expectedText) XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor) XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX) - XCTAssertTrue(cell.accessibilityLabel?.contains("状态:\(expectedText)") == true) + XCTAssertTrue(cell.accessibilityLabel?.contains("修图状态:\(expectedText)") == true) } + let combinedCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220)) + combinedCell.apply( + material: TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2), + selectionMode: true, + selected: false + ) + combinedCell.layoutIfNeeded() + let purchaseBadge = try XCTUnwrap( + combinedCell.findSubview { + $0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadge" + } + ) + let purchaseLabel = try XCTUnwrap( + combinedCell.findSubview { + $0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadgeLabel" + } as? UILabel + ) + let aiRetouchBadge = try XCTUnwrap( + combinedCell.findSubview { + $0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge" + } + ) + XCTAssertFalse(purchaseBadge.isHidden) + XCTAssertFalse(aiRetouchBadge.isHidden) + XCTAssertEqual(purchaseLabel.text, "已购") + XCTAssertEqual(purchaseBadge.backgroundColor?.travelAlbumTestHexRGB, 0x475569) + XCTAssertGreaterThan(purchaseBadge.frame.minY, aiRetouchBadge.frame.maxY) + XCTAssertEqual(purchaseBadge.frame.minX, 6, accuracy: 0.5) + XCTAssertEqual(purchaseBadge.frame.maxY, 170, accuracy: 0.5) + XCTAssertTrue(combinedCell.accessibilityLabel?.contains("购买状态:已购") == true) + XCTAssertTrue(combinedCell.accessibilityLabel?.contains("修图状态:修图中") == true) + let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220)) hiddenCell.apply( material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"), selectionMode: false, selected: false ) - let hiddenBadge = try XCTUnwrap( + let hiddenPurchaseBadge = try XCTUnwrap( hiddenCell.findSubview { - $0.accessibilityIdentifier == "travelAlbum.materialStatusBadge" + $0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadge" } ) - XCTAssertTrue(hiddenBadge.isHidden) - XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("状态:") == true) + let hiddenAIRetouchBadge = try XCTUnwrap( + hiddenCell.findSubview { + $0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge" + } + ) + XCTAssertTrue(hiddenPurchaseBadge.isHidden) + XCTAssertTrue(hiddenAIRetouchBadge.isHidden) + XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("购买状态:") == true) + XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("修图状态:") == true) } private func waitUntil(_ condition: @escaping () -> Bool) async { diff --git a/suixinkanTests/TravelAlbumModelsTests.swift b/suixinkanTests/TravelAlbumModelsTests.swift index aea046b..befbcbe 100644 --- a/suixinkanTests/TravelAlbumModelsTests.swift +++ b/suixinkanTests/TravelAlbumModelsTests.swift @@ -399,34 +399,48 @@ final class TravelAlbumModelsTests: XCTestCase { XCTAssertEqual(material.aiAtmosphereURL, "") } - func testTravelAlbumMaterialBadgePriorityAndFallbacks() { - XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 0).badgePresentation) + func testTravelAlbumMaterialPurchaseAndAIRetouchBadgesAreIndependent() { + XCTAssertNil(TravelAlbumMaterial().purchaseBadgePresentation) XCTAssertEqual( - TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 0, aiRetouchStatusName: "已上传") - .badgePresentation, + TravelAlbumMaterial(isPurchased: true).purchaseBadgePresentation, + TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购") + ) + XCTAssertNil(TravelAlbumMaterial(isPurchased: true).aiRetouchBadgePresentation) + let purchasedAndProcessing = TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2) + XCTAssertEqual( + purchasedAndProcessing.purchaseBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购") ) XCTAssertEqual( - TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ").badgePresentation, - TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中") - ) - XCTAssertEqual( - TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").badgePresentation, + purchasedAndProcessing.aiRetouchBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中") ) XCTAssertEqual( - TravelAlbumMaterial(aiRetouchStatus: 3).badgePresentation, + TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ") + .aiRetouchBadgePresentation, + TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中") + ) + XCTAssertEqual( + TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").aiRetouchBadgePresentation, + TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中") + ) + XCTAssertEqual( + TravelAlbumMaterial(aiRetouchStatus: 3).aiRetouchBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修") ) XCTAssertEqual( - TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面").badgePresentation, + TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面") + .aiRetouchBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面") ) XCTAssertEqual( - TravelAlbumMaterial(aiRetouchStatus: 4).badgePresentation, + TravelAlbumMaterial(aiRetouchStatus: 4).aiRetouchBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败") ) - XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知").badgePresentation) + XCTAssertNil( + TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知") + .aiRetouchBadgePresentation + ) } func testDisplayFormatters() { diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index f40efb7..79a16aa 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -279,14 +279,22 @@ final class TravelAlbumDetailViewModelTests: XCTestCase { XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--") } - func testOnlyUnpurchasedMaterialCanBeSelected() { + func testAllMaterialsCanBeSelectedAndPurchasedTabSupportsSelectionMode() async { + let api = TravelAlbumMockAPI() + api.materialListResponses = [TravelAlbumListResponse(total: 1, list: [])] let viewModel = TravelAlbumDetailViewModel(albumId: 2) + await viewModel.selectTab(.purchased, api: api) viewModel.toggleSelectionMode() - viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 2)) - XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty) + viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, isPurchased: true)) + viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2, isPurchased: false)) - viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1)) - XCTAssertEqual(viewModel.selectedMaterialIds, [1]) + XCTAssertTrue(viewModel.isSelectionMode) + XCTAssertEqual(viewModel.selectedMaterialIds, [1, 2]) + XCTAssertEqual(viewModel.selectedPurchasedMaterialCount, 1) + XCTAssertEqual( + viewModel.deleteSelectedConfirmationMessage, + "已选择 2 张素材,其中 1 张已购买。不会删除已购买的项目,只会删除 1 张未购买的项目。" + ) } func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() { @@ -405,6 +413,42 @@ final class TravelAlbumDetailViewModelTests: XCTestCase { XCTAssertFalse(viewModel.isSelectionMode) XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty) } + + func testDeleteSelectedMaterialsFiltersPurchasedItemsFromBatchRequest() async { + let api = TravelAlbumMockAPI() + api.materialListResponses = [ + TravelAlbumListResponse(total: 0, list: []), + TravelAlbumListResponse(total: 0, list: []), + TravelAlbumListResponse(total: 0, list: []), + ] + let viewModel = TravelAlbumDetailViewModel(albumId: 5) + viewModel.toggleSelectionMode() + viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 10, isPurchased: true)) + viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 11, isPurchased: false)) + viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 12, isPurchased: true)) + + await viewModel.deleteSelectedMaterials(api: api) + + XCTAssertEqual(api.deletedMaterialIDBatches, [[11]]) + XCTAssertFalse(viewModel.isSelectionMode) + XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty) + } + + func testDeleteSelectedMaterialsDoesNotRequestAPIWhenAllSelectedItemsArePurchased() async { + let api = TravelAlbumMockAPI() + let viewModel = TravelAlbumDetailViewModel(albumId: 5) + var message = "" + viewModel.onShowMessage = { message = $0 } + viewModel.toggleSelectionMode() + viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 10, isPurchased: true)) + + await viewModel.deleteSelectedMaterials(api: api) + + XCTAssertTrue(api.deletedMaterialIDBatches.isEmpty) + XCTAssertEqual(message, "选中的素材均已购买,未删除任何项目") + XCTAssertTrue(viewModel.isSelectionMode) + XCTAssertEqual(viewModel.selectedMaterialIds, [10]) + } } /// 有线传输 ViewModel 测试。