diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift index 0f9d881..9c9d3ae 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -22,6 +22,7 @@ final class TravelAlbumDetailViewController: BaseViewController { private let purchasedTabButton = UIButton(type: .system) private let sortButton = UIButton(type: .system) private let selectButton = UIButton(type: .system) + private let refreshControl = UIRefreshControl() private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private let bottomBar = UIView() @@ -97,6 +98,9 @@ final class TravelAlbumDetailViewController: BaseViewController { collectionView.alwaysBounceVertical = true collectionView.showsVerticalScrollIndicator = false collectionView.delegate = self + refreshControl.tintColor = TravelAlbumDetailStyle.primary + refreshControl.accessibilityIdentifier = "travelAlbum.refreshControl" + collectionView.refreshControl = refreshControl collectionView.register( TravelAlbumMaterialCell.self, forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier @@ -225,6 +229,7 @@ final class TravelAlbumDetailViewController: BaseViewController { aiRetouchButton.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside) deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside) uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) + refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } @@ -291,13 +296,20 @@ final class TravelAlbumDetailViewController: BaseViewController { } dataSource.apply(snapshot, animatingDifferences: true) - if viewModel.isLoading && viewModel.album == nil { + if !viewModel.isRefreshing { + refreshControl.endRefreshing() + } + if viewModel.isLoading && viewModel.album == nil && !refreshControl.isRefreshing { showLoading() } else { hideLoading() } } + @objc private func refreshPulled() { + Task { await viewModel.refreshAll(api: api) } + } + private func configureTabButtonBase(_ button: UIButton) { button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) button.accessibilityTraits.insert(.button) diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift index 8d7b9bf..70c99e0 100644 --- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -7,9 +7,50 @@ 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)