fix: 优化相册素材状态与删除保护

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