Integrate CYLTabBar center scan button and unify UIKit navigation.

Add CYLTabBarController with a global scan entry, promote MainTabBarController to the window root after login, replace RouterPath-based navigation with AppNavigator, and fix Profile diffable cell reconfiguration crashes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 16:36:18 +08:00
parent 24a7339b68
commit a1c031c9b7
31 changed files with 838 additions and 517 deletions

View File

@ -0,0 +1,58 @@
//
// MainScanPlusButtonTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan_ios
/// TabBar CYL PlusButton
@MainActor
final class MainScanPlusButtonTests: XCTestCase {
/// Tab
func testIndexOfPlusButtonInTabBarIsBetweenOrdersAndStatistics() {
XCTAssertEqual(MainScanPlusButton.indexOfPlusButtonInTabBar(), 2)
}
/// accessibility
func testPlusButtonAccessibilityIdentifier() {
guard let button = MainScanPlusButton.plusButton() as? UIButton else {
XCTFail("plusButton 应返回 UIButton 实例")
return
}
XCTAssertEqual(button.accessibilityIdentifier, "main.scan")
XCTAssertEqual(button.accessibilityLabel, "扫码核销")
}
/// Tab CYL Tab
func testMainTabBarControllerLoadsWithMatchingCYLAttributes() {
let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession()))
let controller = MainTabBarController(services: services)
controller.loadViewIfNeeded()
for tab in AppTab.allCases {
XCTAssertNotNil(controller.navigationController(for: tab))
XCTAssertEqual(
controller.navigationController(for: tab)?.tabBarItem.accessibilityIdentifier,
"main.tab.\(tab.rawValue)"
)
}
}
}
/// Tab 使
private final class MainTabRecordingURLSession: URLSessionProtocol {
///
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let response = HTTPURLResponse(
url: request.url ?? URL(string: "https://example.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (Data(), response)
}
}

View File

@ -5,93 +5,167 @@
// Created by Codex on 2026/6/22.
//
import UIKit
import XCTest
@testable import suixinkan_ios
/// AppRoute
/// UIKit Tab 宿 push
@MainActor
final class NavigationRouterTests: XCTestCase {
/// NavigationStack push TabBar
func testPushedRoutesHideTabBarByDefault() {
XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.home(.paymentCollection).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.orders(.multiTravelTaskUpload(orderNumber: "MT001")).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.orders(.orderTrailer(orderNumber: "MT001", title: "尾片上传")).hidesTabBarWhenPushed)
final class AppNavigatorTests: XCTestCase {
/// push TabBar
func testPushUsesCurrentNavigationStack() {
let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession()))
let host = AppNavigatorTestHost(services: services)
let navigator = AppNavigator()
navigator.attach(host: host)
navigator.push(.home(.paymentCollection), from: nil, animated: false)
let homeStack = host.navigationController(for: .home)!
XCTAssertEqual(homeStack.viewControllers.count, 2)
XCTAssertTrue(homeStack.viewControllers.last?.hidesBottomBarWhenPushed == true)
XCTAssertTrue(homeStack.viewControllers.last is PaymentCollectionViewController)
}
/// Tab
func testSelectOrdersUpdatesTabAndEntry() {
let router = AppRouter()
/// tabRoot Tab
func testTabRootSelectsTabResetsStackAndPushesRoute() {
let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession()))
let host = AppNavigatorTestHost(services: services)
let navigator = AppNavigator()
navigator.attach(host: host)
host.navigationController(for: .home)?.push(route: .home(.wallet), animated: false)
router.selectOrders(entry: .verificationOrders)
navigator.openHome(.taskManagement, policy: .tabRoot(.home), animated: false)
XCTAssertEqual(router.selectedTab, .orders)
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
let homeStack = host.navigationController(for: .home)!
XCTAssertEqual(host.selectedTab, .home)
XCTAssertEqual(homeStack.viewControllers.count, 2)
XCTAssertTrue(homeStack.viewControllers.last is TaskManagementViewController)
}
///
func testRouteToOrderVerificationStoresPendingScanCode() {
let router = AppRouter()
/// preserveTab Tab
func testPreserveTabKeepsExistingStack() {
let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession()))
let host = AppNavigatorTestHost(services: services)
let navigator = AppNavigator()
navigator.attach(host: host)
host.navigationController(for: .profile)?.push(route: .profile(.settings), animated: false)
router.routeToOrderVerification(scannedCode: "order_number=VERIFY001")
navigator.openProfile(.realNameAuth, policy: .preserveTab(.profile), animated: false)
XCTAssertEqual(router.selectedTab, .orders)
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
XCTAssertEqual(router.pendingOrderScanCode, "order_number=VERIFY001")
let profileStack = host.navigationController(for: .profile)!
XCTAssertEqual(host.selectedTab, .profile)
XCTAssertEqual(profileStack.viewControllers.count, 3)
XCTAssertTrue(profileStack.viewControllers[1] is SettingsViewController)
XCTAssertTrue(profileStack.viewControllers.last is RealNameAuthViewController)
}
///
func testConsumePendingOrderScanCodeClearsValueAfterFirstRead() {
let router = AppRouter()
router.routeToOrderVerification(scannedCode: "VERIFY001")
/// 宿 attach
func testPendingActionsRunAfterAttachInFIFOOrder() {
let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession()))
let host = AppNavigatorTestHost(services: services)
let navigator = AppNavigator()
XCTAssertEqual(router.consumePendingOrderScanCode(), "VERIFY001")
XCTAssertNil(router.consumePendingOrderScanCode())
navigator.openHome(.paymentCollection, policy: .tabRoot(.home), animated: false)
navigator.openHome(.messageCenter, policy: .preserveTab(.home), animated: false)
navigator.attach(host: host)
let homeStack = host.navigationController(for: .home)!
XCTAssertEqual(homeStack.viewControllers.count, 3)
XCTAssertTrue(homeStack.viewControllers[1] is PaymentCollectionViewController)
XCTAssertTrue(homeStack.viewControllers.last is MessageCenterViewController)
}
///
func testResetClearsPendingOrderScanCode() {
let router = AppRouter()
router.routeToOrderVerification(scannedCode: "VERIFY001")
/// resetAllStacks 宿
func testResetAllStacksClearsPendingAndMountedStacks() {
let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession()))
let host = AppNavigatorTestHost(services: services)
let navigator = AppNavigator()
router.reset()
navigator.openHome(.paymentCollection, policy: .tabRoot(.home), animated: false)
navigator.resetAllStacks()
navigator.attach(host: host)
XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 1)
XCTAssertEqual(router.selectedTab, .home)
XCTAssertEqual(router.selectedOrdersEntry, .storeOrders)
XCTAssertNil(router.pendingOrderScanCode)
navigator.openHome(.wallet, policy: .tabRoot(.home), animated: false)
navigator.resetAllStacks()
XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 1)
XCTAssertEqual(host.selectedTab, .home)
}
///
func testOrderRoutesCanBePushedIntoRouterPath() throws {
let router = RouterPath()
let order = try Self.firstOrder()
let writeOff = try Self.firstWriteOffOrder()
/// Tab
func testOpenOrdersEntryAppliesRootStateAndScanCode() {
let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession()))
let host = AppNavigatorTestHost(services: services)
let navigator = AppNavigator()
navigator.attach(host: host)
router.navigate(to: .orders(.storeDetail(order)))
router.navigate(to: .orders(.writeOffDetail(writeOff)))
router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: "MT001")))
router.navigate(to: .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
navigator.openOrdersEntry(.verificationOrders, scannedCode: "VERIFY001")
XCTAssertEqual(router.path.count, 4)
XCTAssertEqual(router.path[0], .orders(.storeDetail(order)))
XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff)))
XCTAssertEqual(router.path[2], .orders(.multiTravelTaskUpload(orderNumber: "MT001")))
XCTAssertEqual(router.path[3], .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
}
///
private static func firstOrder() throws -> OrderEntity {
let payload = try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")
return try XCTUnwrap(payload.list.first)
}
///
private static func firstWriteOffOrder() throws -> WriteOffOrderItem {
let payload = try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")
return try XCTUnwrap(payload.list.first)
XCTAssertEqual(host.selectedTab, .orders)
XCTAssertEqual(host.appliedOrdersEntry, .verificationOrders)
XCTAssertEqual(host.appliedScanCode, "VERIFY001")
XCTAssertEqual(host.navigationController(for: .orders)?.viewControllers.count, 1)
}
}
/// AppNavigator 宿 Tab
@MainActor
private final class AppNavigatorTestHost: AppNavigationHost {
private let services: AppServices
private var controllers: [AppTab: TabNavigationController] = [:]
var selectedTab: AppTab = .home
var appliedOrdersEntry: OrdersEntry?
var appliedScanCode: String?
/// Tab
init(services: AppServices) {
self.services = services
for tab in AppTab.allCases {
controllers[tab] = TabNavigationController(tab: tab, services: services)
}
}
/// Tab
func selectTab(_ tab: AppTab) {
selectedTab = tab
}
/// Tab
func navigationController(for tab: AppTab) -> TabNavigationController? {
controllers[tab]
}
/// Tab
func selectedNavigationController() -> TabNavigationController? {
controllers[selectedTab]
}
/// Tab
func resetAllStacks() {
for controller in controllers.values {
controller.popToRootViewController(animated: false)
}
selectedTab = .home
}
///
func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?) {
appliedOrdersEntry = entry
appliedScanCode = scannedCode
}
}
///
private final class AppNavigatorRecordingURLSession: URLSessionProtocol {
///
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let response = HTTPURLResponse(
url: request.url ?? URL(string: "https://example.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (Data(), response)
}
}

View File

@ -65,51 +65,98 @@ final class PushNotificationTests: XCTestCase {
}
func testPushManagerRoutesHomeDestinationsThroughHomeNavigationStack() {
let router = AppRouter()
let manager = pushManager(router: router)
let services = AppServices(apiClient: APIClient(session: PushRecordingURLSession()))
let host = PushNavigationHost(services: services)
let manager = pushManager(navigator: services.appNavigator)
services.appNavigator.attach(host: host)
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "payment_qr"]))
XCTAssertEqual(router.selectedTab, .home)
XCTAssertEqual(router.router(for: .home).path, [.home(.paymentCollection)])
XCTAssertEqual(host.selectedTab, .home)
XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is PaymentCollectionViewController)
router.router(for: .home).reset()
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "task_management"]))
XCTAssertEqual(router.router(for: .home).path, [.home(.taskManagement)])
XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is TaskManagementViewController)
XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 2)
router.router(for: .home).reset()
manager.handleRemoteNotification(PushPayload(userInfo: ["uri": "/scenic-queue"]))
XCTAssertEqual(router.router(for: .home).path, [.home(.queueManagement)])
XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is QueueManagementViewController)
XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 2)
router.router(for: .home).reset()
manager.handleRemoteNotification(PushPayload(userInfo: ["title": "未知通知"]))
XCTAssertEqual(router.router(for: .home).path, [.home(.messageCenter)])
XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is MessageCenterViewController)
XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 2)
}
func testPushManagerRoutesOrdersEntry() {
let router = AppRouter()
let manager = pushManager(router: router)
let services = AppServices(apiClient: APIClient(session: PushRecordingURLSession()))
let host = PushNavigationHost(services: services)
let manager = pushManager(navigator: services.appNavigator)
services.appNavigator.attach(host: host)
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "photographer_orders"]))
XCTAssertEqual(router.selectedTab, .orders)
XCTAssertEqual(router.selectedOrdersEntry, .storeOrders)
XCTAssertEqual(host.selectedTab, .orders)
XCTAssertEqual(host.appliedOrdersEntry, .storeOrders)
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"]))
XCTAssertEqual(router.selectedTab, .orders)
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
XCTAssertEqual(host.selectedTab, .orders)
XCTAssertEqual(host.appliedOrdersEntry, .verificationOrders)
}
private func pushManager(router: AppRouter) -> PushNotificationManager {
private func pushManager(navigator: AppNavigator) -> PushNotificationManager {
let session = AppSession()
let manager = PushNotificationManager()
manager.configure(
api: PushAPI(client: APIClient(session: PushRecordingURLSession())),
session: session,
router: router
navigator: navigator
)
return manager
}
}
/// 宿
@MainActor
private final class PushNavigationHost: AppNavigationHost {
private var controllers: [AppTab: TabNavigationController] = [:]
var selectedTab: AppTab = .home
var appliedOrdersEntry: OrdersEntry?
/// Tab
init(services: AppServices) {
for tab in AppTab.allCases {
controllers[tab] = TabNavigationController(tab: tab, services: services)
}
}
/// Tab
func selectTab(_ tab: AppTab) {
selectedTab = tab
}
/// Tab
func navigationController(for tab: AppTab) -> TabNavigationController? {
controllers[tab]
}
/// Tab
func selectedNavigationController() -> TabNavigationController? {
controllers[selectedTab]
}
/// Tab
func resetAllStacks() {
for controller in controllers.values {
controller.popToRootViewController(animated: false)
}
selectedTab = .home
}
///
func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?) {
appliedOrdersEntry = entry
}
}
private final class PushRecordingURLSession: URLSessionProtocol {
private(set) var requests: [URLRequest] = []