470 lines
19 KiB
Swift
470 lines
19 KiB
Swift
//
|
|
// TravelAlbumDetailViewControllerTests.swift
|
|
// suixinkanTests
|
|
//
|
|
|
|
import UIKit
|
|
import XCTest
|
|
@testable import suixinkan
|
|
|
|
/// 相册管理页刷新、预览与选择态交互测试。
|
|
@MainActor
|
|
final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|
func testPullToRefreshReloadsGridAndEndsRefreshing() async throws {
|
|
let api = TravelAlbumMockAPI()
|
|
api.infoResponse = TravelAlbum(id: 2, name: "测试相册")
|
|
api.materialListResponses = [
|
|
TravelAlbumListResponse(total: 1, list: []),
|
|
TravelAlbumListResponse(total: 0, list: []),
|
|
TravelAlbumListResponse(total: 1, list: [TravelAlbumMaterial(id: 1)]),
|
|
TravelAlbumListResponse(total: 2, list: []),
|
|
TravelAlbumListResponse(total: 1, list: []),
|
|
TravelAlbumListResponse(
|
|
total: 2,
|
|
list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]
|
|
),
|
|
]
|
|
let controller = TravelAlbumDetailViewController(albumId: 2, api: api)
|
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
|
window.rootViewController = controller
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
controller.loadViewIfNeeded()
|
|
controller.view.layoutIfNeeded()
|
|
let collectionView = try XCTUnwrap(
|
|
controller.view.findSubview { $0 is UICollectionView } as? UICollectionView
|
|
)
|
|
let refreshControl = try XCTUnwrap(collectionView.refreshControl)
|
|
await waitUntil { api.materialRequests.count == 3 }
|
|
|
|
refreshControl.beginRefreshing()
|
|
XCTAssertTrue(refreshControl.isRefreshing)
|
|
refreshControl.sendActions(for: .valueChanged)
|
|
await waitUntil {
|
|
api.materialRequests.count == 6
|
|
&& !refreshControl.isRefreshing
|
|
&& collectionView.numberOfItems(inSection: 0) == 2
|
|
}
|
|
|
|
XCTAssertEqual(refreshControl.accessibilityIdentifier, "travelAlbum.refreshControl")
|
|
XCTAssertEqual(collectionView.numberOfItems(inSection: 0), 2)
|
|
XCTAssertFalse(refreshControl.isRefreshing)
|
|
}
|
|
|
|
func testPreviewDeleteHandlerCallsMaterialAPIAndPreservesServerFailure() async {
|
|
let api = TravelAlbumMockAPI()
|
|
let handler = TravelAlbumPreviewActionHandler(api: api)
|
|
|
|
let success = await handler.deleteProject(originalMaterialId: 8)
|
|
|
|
XCTAssertEqual(api.deletedMaterialIds, [8])
|
|
XCTAssertEqual(success, .success("删除成功"))
|
|
|
|
api.deleteMaterialError = APIError.serverCode(500, "服务暂不可用")
|
|
let failure = await handler.deleteProject(originalMaterialId: 9)
|
|
|
|
XCTAssertEqual(api.deletedMaterialIds, [8, 9])
|
|
XCTAssertEqual(failure, .failure("服务暂不可用"))
|
|
}
|
|
|
|
func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws {
|
|
let api = TravelAlbumMockAPI()
|
|
api.deleteMaterialDelayNanoseconds = 1_000_000
|
|
var deletedIds: [Int] = []
|
|
let controller = TravelAlbumPhotoPreviewViewController(
|
|
projects: [
|
|
makePreviewProject(id: 1, fileName: "第一张.jpg"),
|
|
makePreviewProject(id: 2, fileName: "第二张.jpg"),
|
|
makePreviewProject(id: 3, fileName: "第三张.jpg"),
|
|
],
|
|
totalCount: 3,
|
|
startProjectIndex: 0,
|
|
actionHandler: TravelAlbumPreviewActionHandler(api: api),
|
|
onProjectDeleted: { deletedIds.append($0) }
|
|
)
|
|
controller.loadViewIfNeeded()
|
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
controller.view.layoutIfNeeded()
|
|
|
|
controller.deleteCurrentProjectAfterConfirmation()
|
|
controller.deleteCurrentProjectAfterConfirmation()
|
|
await waitUntil {
|
|
controller.view.allAccessibilityLabels().contains("第 1 张,共 2 张")
|
|
}
|
|
|
|
XCTAssertEqual(api.deletedMaterialIds, [1])
|
|
XCTAssertEqual(deletedIds, [1])
|
|
XCTAssertTrue(
|
|
controller.view.allLabels().contains { $0.text == "第二张.jpg" }
|
|
)
|
|
}
|
|
|
|
func testPreviewDeletingLastProjectShowsPreviousProject() async {
|
|
let api = TravelAlbumMockAPI()
|
|
let controller = TravelAlbumPhotoPreviewViewController(
|
|
projects: [
|
|
makePreviewProject(id: 1, fileName: "第一张.jpg"),
|
|
makePreviewProject(id: 2, fileName: "第二张.jpg"),
|
|
makePreviewProject(id: 3, fileName: "第三张.jpg"),
|
|
],
|
|
totalCount: 3,
|
|
startProjectIndex: 2,
|
|
actionHandler: TravelAlbumPreviewActionHandler(api: api)
|
|
)
|
|
controller.loadViewIfNeeded()
|
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
controller.view.layoutIfNeeded()
|
|
|
|
controller.deleteCurrentProjectAfterConfirmation()
|
|
await waitUntil {
|
|
controller.view.allAccessibilityLabels().contains("第 2 张,共 2 张")
|
|
}
|
|
|
|
XCTAssertEqual(api.deletedMaterialIds, [3])
|
|
XCTAssertTrue(
|
|
controller.view.allLabels().contains { $0.text == "第二张.jpg" }
|
|
)
|
|
}
|
|
|
|
func testPreviewDeletingOnlyProjectDismissesPage() async {
|
|
UIView.setAnimationsEnabled(false)
|
|
defer { UIView.setAnimationsEnabled(true) }
|
|
let api = TravelAlbumMockAPI()
|
|
let host = UIViewController()
|
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
|
window.rootViewController = host
|
|
window.makeKeyAndVisible()
|
|
let controller = TravelAlbumPhotoPreviewViewController(
|
|
projects: [makePreviewProject(id: 1, fileName: "仅有一张.jpg")],
|
|
totalCount: 1,
|
|
startProjectIndex: 0,
|
|
actionHandler: TravelAlbumPreviewActionHandler(api: api)
|
|
)
|
|
host.present(controller, animated: false)
|
|
await waitUntil { controller.presentingViewController === host }
|
|
|
|
controller.deleteCurrentProjectAfterConfirmation()
|
|
await waitUntil { host.presentedViewController == nil }
|
|
|
|
XCTAssertEqual(api.deletedMaterialIds, [1])
|
|
XCTAssertNil(host.presentedViewController)
|
|
window.isHidden = true
|
|
}
|
|
|
|
func testPreviewImageViewKeepsViewportConstraintsWhenSwitchingVariant() throws {
|
|
UIView.setAnimationsEnabled(false)
|
|
defer { UIView.setAnimationsEnabled(true) }
|
|
let project = TravelAlbumPreviewProject(
|
|
originalMaterialId: 1,
|
|
assets: [
|
|
TravelAlbumPreviewAsset(
|
|
id: "original-1",
|
|
kind: .original,
|
|
fileURL: "",
|
|
coverURL: "",
|
|
fileName: "原图.jpg",
|
|
fileSize: 0
|
|
),
|
|
TravelAlbumPreviewAsset(
|
|
id: "retouched-1",
|
|
kind: .retouched,
|
|
fileURL: "",
|
|
coverURL: "",
|
|
fileName: "精修后.jpg",
|
|
fileSize: 0
|
|
),
|
|
]
|
|
)
|
|
let controller = TravelAlbumPhotoPreviewViewController(
|
|
projects: [project],
|
|
totalCount: 1,
|
|
startProjectIndex: 0
|
|
)
|
|
controller.loadViewIfNeeded()
|
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
controller.view.layoutIfNeeded()
|
|
|
|
let retouchedButton = try XCTUnwrap(
|
|
controller.view.findSubview {
|
|
($0 as? UIButton)?.accessibilityLabel == "精修后"
|
|
} as? UIButton
|
|
)
|
|
retouchedButton.sendActions(for: .touchUpInside)
|
|
controller.view.layoutIfNeeded()
|
|
|
|
let imageView = try XCTUnwrap(
|
|
controller.view.findSubview {
|
|
$0.accessibilityIdentifier == "travelAlbum.previewImageView"
|
|
} as? UIImageView
|
|
)
|
|
let scrollView = try XCTUnwrap(imageView.superview as? UIScrollView)
|
|
scrollView.layoutIfNeeded()
|
|
|
|
XCTAssertEqual(imageView.bounds.size, scrollView.bounds.size)
|
|
XCTAssertFalse(imageView.constraintsAffectingLayout(for: .horizontal).isEmpty)
|
|
XCTAssertFalse(imageView.constraintsAffectingLayout(for: .vertical).isEmpty)
|
|
}
|
|
|
|
func testSelectionModeShowsAIRetouchOnLeftAndDeleteOnRight() async throws {
|
|
let controller = TravelAlbumDetailViewController(albumId: 0)
|
|
controller.loadViewIfNeeded()
|
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
|
|
let selectButton = try XCTUnwrap(
|
|
controller.view.findSubview {
|
|
($0 as? UIButton)?.accessibilityLabel == "选择照片"
|
|
} as? UIButton
|
|
)
|
|
selectButton.sendActions(for: .touchUpInside)
|
|
await Task.yield()
|
|
controller.view.layoutIfNeeded()
|
|
|
|
let actionStack = try XCTUnwrap(
|
|
controller.view.findSubview {
|
|
$0.accessibilityIdentifier == "travelAlbum.selectionActionStack"
|
|
} as? UIStackView
|
|
)
|
|
let aiRetouchButton = try XCTUnwrap(
|
|
controller.view.findSubview {
|
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchButton"
|
|
} as? UIButton
|
|
)
|
|
let deleteButton = try XCTUnwrap(
|
|
controller.view.findSubview {
|
|
$0.accessibilityIdentifier == "travelAlbum.deleteButton"
|
|
} as? UIButton
|
|
)
|
|
|
|
XCTAssertFalse(actionStack.isHidden)
|
|
XCTAssertEqual(actionStack.arrangedSubviews, [aiRetouchButton, deleteButton])
|
|
XCTAssertEqual(actionStack.distribution, .fillEqually)
|
|
XCTAssertEqual(actionStack.spacing, 12)
|
|
XCTAssertEqual(aiRetouchButton.configuration?.title, "AI修图")
|
|
XCTAssertEqual(deleteButton.configuration?.title, "删除")
|
|
XCTAssertEqual(aiRetouchButton.bounds.width, deleteButton.bounds.width, accuracy: 0.5)
|
|
XCTAssertLessThan(aiRetouchButton.frame.minX, deleteButton.frame.minX)
|
|
}
|
|
|
|
func testAIRetouchTemplateSheetUsesLargeDetentAndFixedBottomActions() async throws {
|
|
let api = TravelAlbumMockAPI()
|
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
|
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
|
|
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
|
|
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")]
|
|
)
|
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
|
albumId: 9,
|
|
scenicId: 18,
|
|
materialIds: [1, 2, 3, 4]
|
|
)
|
|
let controller = TravelAlbumAIRetouchTemplateViewController(
|
|
viewModel: viewModel,
|
|
api: api,
|
|
onSubmitted: {}
|
|
)
|
|
controller.loadViewIfNeeded()
|
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 }
|
|
controller.view.layoutIfNeeded()
|
|
|
|
let sheet = try XCTUnwrap(controller.sheetPresentationController)
|
|
XCTAssertEqual(sheet.detents.count, 1)
|
|
XCTAssertEqual(sheet.selectedDetentIdentifier, .large)
|
|
XCTAssertTrue(sheet.prefersGrabberVisible)
|
|
|
|
let collectionView = try XCTUnwrap(
|
|
controller.view.findSubview {
|
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection"
|
|
} as? UICollectionView
|
|
)
|
|
let bottomBar = try XCTUnwrap(
|
|
controller.view.findSubview {
|
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchBottomBar"
|
|
}
|
|
)
|
|
let cancelButton = try XCTUnwrap(
|
|
controller.view.findSubview {
|
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchCancelButton"
|
|
} as? UIButton
|
|
)
|
|
let confirmButton = try XCTUnwrap(
|
|
controller.view.findSubview {
|
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
|
|
} as? UIButton
|
|
)
|
|
await waitUntil { confirmButton.isEnabled }
|
|
controller.view.layoutIfNeeded()
|
|
|
|
XCTAssertTrue(collectionView.collectionViewLayout is UICollectionViewCompositionalLayout)
|
|
XCTAssertFalse(bottomBar.isDescendant(of: collectionView))
|
|
XCTAssertEqual(cancelButton.configuration?.title, "取消")
|
|
XCTAssertEqual(confirmButton.configuration?.title, "确定")
|
|
XCTAssertEqual(cancelButton.bounds.width, confirmButton.bounds.width, accuracy: 0.5)
|
|
XCTAssertTrue(confirmButton.isEnabled)
|
|
}
|
|
|
|
func testAIRetouchSheetShowsOptionalAtmosphereCoverAndModeCopy() async throws {
|
|
let api = TravelAlbumMockAPI()
|
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
|
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
|
|
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
|
|
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")]
|
|
)
|
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
|
albumId: 9,
|
|
scenicId: 18,
|
|
materialIds: [1, 2, 3, 4]
|
|
)
|
|
let controller = TravelAlbumAIRetouchTemplateViewController(
|
|
viewModel: viewModel,
|
|
api: api,
|
|
onSubmitted: {}
|
|
)
|
|
controller.loadViewIfNeeded()
|
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 }
|
|
controller.view.layoutIfNeeded()
|
|
|
|
let accessibleLabels = controller.view.allAccessibilityLabels()
|
|
XCTAssertTrue(accessibleLabels.contains("原图精修"))
|
|
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
|
|
XCTAssertTrue(accessibleLabels.contains("封面风格模板"))
|
|
|
|
let modeCell = TravelAlbumAIRetouchModeCell(frame: .zero)
|
|
modeCell.apply()
|
|
XCTAssertEqual(modeCell.accessibilityLabel, "AI精修,按张收费,剩余张数暂不可用")
|
|
}
|
|
|
|
func testAIRetouchTemplateCellExposesSelectedState() {
|
|
let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero)
|
|
let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")
|
|
|
|
cell.apply(template: template, selected: true)
|
|
|
|
XCTAssertEqual(cell.contentView.layer.borderWidth, 2)
|
|
XCTAssertTrue(cell.accessibilityTraits.contains(.selected))
|
|
XCTAssertEqual(cell.accessibilityValue, "已选择")
|
|
|
|
cell.apply(template: template, selected: false)
|
|
|
|
XCTAssertEqual(cell.contentView.layer.borderWidth, 1)
|
|
XCTAssertFalse(cell.accessibilityTraits.contains(.selected))
|
|
XCTAssertEqual(cell.accessibilityValue, "未选择")
|
|
}
|
|
|
|
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws {
|
|
let cases: [(TravelAlbumMaterial, String, UInt)] = [
|
|
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
|
|
(TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309),
|
|
(TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8),
|
|
(TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857),
|
|
(
|
|
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面"),
|
|
"AI封面",
|
|
0x6D28D9
|
|
),
|
|
(TravelAlbumMaterial(aiRetouchStatus: 4), "失败", 0xB91C1C),
|
|
]
|
|
|
|
for (material, expectedText, expectedColor) in cases {
|
|
let cell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
|
cell.apply(material: material, selectionMode: true, selected: false)
|
|
cell.layoutIfNeeded()
|
|
|
|
let badgeView = try XCTUnwrap(
|
|
cell.findSubview {
|
|
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
|
|
}
|
|
)
|
|
let badgeLabel = try XCTUnwrap(
|
|
cell.findSubview {
|
|
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadgeLabel"
|
|
} as? UILabel
|
|
)
|
|
let selectionCheck = try XCTUnwrap(
|
|
cell.findSubview {
|
|
$0.accessibilityIdentifier == "travelAlbum.materialSelectionCheck"
|
|
}
|
|
)
|
|
|
|
XCTAssertFalse(badgeView.isHidden)
|
|
XCTAssertEqual(badgeLabel.text, expectedText)
|
|
XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor)
|
|
XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX)
|
|
XCTAssertTrue(cell.accessibilityLabel?.contains("状态:\(expectedText)") == 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(
|
|
hiddenCell.findSubview {
|
|
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
|
|
}
|
|
)
|
|
XCTAssertTrue(hiddenBadge.isHidden)
|
|
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("状态:") == true)
|
|
}
|
|
|
|
private func waitUntil(_ condition: @escaping () -> Bool) async {
|
|
for _ in 0 ..< 200 {
|
|
if condition() { return }
|
|
try? await Task.sleep(nanoseconds: 1_000_000)
|
|
}
|
|
}
|
|
|
|
private func makePreviewProject(id: Int, fileName: String) -> TravelAlbumPreviewProject {
|
|
TravelAlbumPreviewProject(
|
|
originalMaterialId: id,
|
|
assets: [
|
|
TravelAlbumPreviewAsset(
|
|
id: "original-\(id)",
|
|
kind: .original,
|
|
fileURL: "",
|
|
coverURL: "",
|
|
fileName: fileName,
|
|
fileSize: 0
|
|
),
|
|
]
|
|
)
|
|
}
|
|
}
|
|
|
|
private extension UIColor {
|
|
var travelAlbumTestHexRGB: UInt {
|
|
var red: CGFloat = 0
|
|
var green: CGFloat = 0
|
|
var blue: CGFloat = 0
|
|
var alpha: CGFloat = 0
|
|
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
|
return UInt(round(red * 255)) << 16
|
|
| UInt(round(green * 255)) << 8
|
|
| UInt(round(blue * 255))
|
|
}
|
|
}
|
|
|
|
private extension UIView {
|
|
func findSubview(where predicate: (UIView) -> Bool) -> UIView? {
|
|
if predicate(self) { return self }
|
|
for subview in subviews {
|
|
if let match = subview.findSubview(where: predicate) {
|
|
return match
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func allAccessibilityLabels() -> [String] {
|
|
let current = accessibilityLabel.map { [$0] } ?? []
|
|
return current + subviews.flatMap { $0.allAccessibilityLabels() }
|
|
}
|
|
|
|
func allLabels() -> [UILabel] {
|
|
let current = (self as? UILabel).map { [$0] } ?? []
|
|
return current + subviews.flatMap { $0.allLabels() }
|
|
}
|
|
}
|