diff --git a/Podfile b/Podfile index f070b23..6472b8f 100644 --- a/Podfile +++ b/Podfile @@ -5,7 +5,6 @@ target 'suixinkan_ios' do pod 'SnapKit', '~> 5.7' pod 'Kingfisher', '~> 8.0' - pod 'CYLTabBarController', '~> 1.99.38' # 高德地图 SDK 仅支持真机;模拟器通过下方 post_install 剥离链接 pod 'AMap3DMap-NO-IDFA', '~> 11.1' @@ -20,7 +19,7 @@ end # 模拟器构建时不链接高德(真机专用 framework),仅保留通用 Pod 依赖 SIMULATOR_OTHER_LDFLAGS = <<~FLAGS.squish -ObjC -l"c++" -l"swiftCoreGraphics" -l"z" - -framework "Accelerate" -framework "CFNetwork" -framework "CYLTabBarController" + -framework "Accelerate" -framework "CFNetwork" -framework "Kingfisher" -framework "SnapKit" -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony" -framework "CoreText" -framework "ExternalAccessory" -framework "GLKit" diff --git a/Podfile.lock b/Podfile.lock index 481df75..f1969d7 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -6,9 +6,6 @@ PODS: - AMapFoundation-NO-IDFA (>= 1.9.0) - AMapSearch-NO-IDFA (9.8.0): - AMapFoundation-NO-IDFA (>= 1.9.0) - - CYLTabBarController (1.99.38): - - CYLTabBarController/Core (= 1.99.38) - - CYLTabBarController/Core (1.99.38) - Kingfisher (8.10.0) - SnapKit (5.7.1) @@ -16,7 +13,6 @@ DEPENDENCIES: - AMap3DMap-NO-IDFA (~> 11.1) - AMapLocation-NO-IDFA (~> 2.11) - AMapSearch-NO-IDFA (~> 9.7) - - CYLTabBarController (~> 1.99.38) - Kingfisher (~> 8.0) - SnapKit (~> 5.7) @@ -26,7 +22,6 @@ SPEC REPOS: - AMapFoundation-NO-IDFA - AMapLocation-NO-IDFA - AMapSearch-NO-IDFA - - CYLTabBarController - Kingfisher - SnapKit @@ -35,10 +30,9 @@ SPEC CHECKSUMS: AMapFoundation-NO-IDFA: a2e3c895398d7ee757278e1a0a8f9359da4b146e AMapLocation-NO-IDFA: 7cd8fc837ea41edfbf4d937cd20572e277b77d18 AMapSearch-NO-IDFA: c0afd2a69a076d4228becda4401dbe4a279a03ef - CYLTabBarController: 8d46d501757c00e36499ea6b95e86e492fab3a61 Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a -PODFILE CHECKSUM: 9659fc63d0173f0286a7da18725e548d86c24ca6 +PODFILE CHECKSUM: 0755512570f68a2459c7ca54e87c58e5a09f215a COCOAPODS: 1.16.2 diff --git a/suixinkan_ios/App/Navigation/AppTab.swift b/suixinkan_ios/App/Navigation/AppTab.swift index bff9595..acff3d8 100644 --- a/suixinkan_ios/App/Navigation/AppTab.swift +++ b/suixinkan_ios/App/Navigation/AppTab.swift @@ -66,6 +66,7 @@ enum AppTab: String, CaseIterable, Identifiable, Hashable { image: unselectedImage, selectedImage: selectedImage ) + item.accessibilityIdentifier = "main.tab.\(rawValue)" return item } diff --git a/suixinkan_ios/App/ViewControllers/RootViewController.swift b/suixinkan_ios/App/ViewControllers/RootViewController.swift index e3e0237..8d38313 100644 --- a/suixinkan_ios/App/ViewControllers/RootViewController.swift +++ b/suixinkan_ios/App/ViewControllers/RootViewController.swift @@ -106,7 +106,7 @@ final class RootViewController: UIViewController { } } - /// 登录后将 `MainTabBarController` 设为 window 根控制器,满足 CYLTabBarController 挂载要求。 + /// 登录后将 `MainTabBarController` 设为 window 根控制器。 private func showMainTabBarAsWindowRoot() { removeCurrentAuthChild() diff --git a/suixinkan_ios/Features/Main/Main.md b/suixinkan_ios/Features/Main/Main.md index 0a12357..fcf95e5 100644 --- a/suixinkan_ios/Features/Main/Main.md +++ b/suixinkan_ios/Features/Main/Main.md @@ -4,7 +4,7 @@ Main 模块负责登录后的主界面 Tab 容器。 -主界面由 `MainTabBarController` 承载,使用 **CYLTabBarController** 展示首页、订单、数据、我的四个 Tab,并在中间提供扫码核销按钮。每个 Tab 内部包裹独立的 `TabNavigationController`,页面路径以 UIKit 导航栈为唯一事实源。 +主界面由 `MainTabBarController` 承载,使用系统 `UITabBarController` 展示首页、订单、扫码、数据、我的五个底部入口。首页、订单、数据、我的四个业务 Tab 内部包裹独立的 `TabNavigationController`,页面路径以 UIKit 导航栈为唯一事实源;中间扫码入口只触发全局扫码,不切换选中页面。 ## Tab 结构 @@ -15,6 +15,7 @@ Main 模块负责登录后的主界面 Tab 容器。 - `profile`:我的 各 Tab 根页面已接入真实业务页面:`HomeViewController`、`OrdersViewController`、`StatisticsViewController`、`ProfileViewController`。 +系统 TabBar 中间额外插入扫码槽位,显示 `MainScanTabItem` 构建的扫码 item。 ## 导航流程 @@ -32,7 +33,7 @@ Main 模块负责登录后的主界面 Tab 容器。 ## 扫码核销 -TabBar 中间通过 `MainScanPlusButton`(CYLTabBarController PlusButton)展示全局扫码入口,点击后全屏打开 `OrderCodeScannerViewController`: +TabBar 中间通过 `MainScanTabItem` 展示全局扫码入口,`MainTabBarController` 在 `shouldSelect` 中拦截点击并全屏打开 `OrderCodeScannerViewController`,因此关闭扫码页后仍显示点击前的页面: 1. 扫码成功 → `AppNavigator.openOrdersEntry(.verificationOrders, scannedCode:)` 切换至订单核销入口并传入扫码结果。 2. `OrdersViewController` 本地保存并消费扫码结果后进入确认核销流程。 diff --git a/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift b/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift index a436494..1a28275 100644 --- a/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift +++ b/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift @@ -3,26 +3,60 @@ // suixinkan // -import CYLTabBarController import UIKit @MainActor -/// 主 Tab 容器,使用 CYLTabBarController 展示四个一级入口及中间扫码按钮。 -final class MainTabBarController: CYLTabBarController { +/// 主 Tab 容器,使用系统 TabBar 展示四个一级入口及中间扫码按钮。 +final class MainTabBarController: UITabBarController { private let services: AppServices private let badgeViewModel = MainTabBadgeViewModel() private var tabNavigationControllers: [AppTab: TabNavigationController] = [:] private var isSyncingTabSelection = false + /// 测试时可注入扫码点击处理,生产环境为空时执行真实扫码展示。 + var onScanTabSelected: (() -> Void)? + + /// 主 TabBar 固定槽位,扫码槽位只负责触发全局扫码,不参与业务路由。 + private enum TabSlot: CaseIterable { + case home + case orders + case scan + case statistics + case profile + + /// 返回槽位对应的业务 Tab,扫码槽位无业务根页面。 + var appTab: AppTab? { + switch self { + case .home: + return .home + case .orders: + return .orders + case .scan: + return nil + case .statistics: + return .statistics + case .profile: + return .profile + } + } + + /// 返回指定业务 Tab 在系统 TabBar 中的槽位索引。 + static func index(for tab: AppTab) -> Int? { + allCases.firstIndex { $0.appTab == tab } + } + + /// 返回扫码槽位在系统 TabBar 中的索引。 + static var scanIndex: Int? { + allCases.firstIndex(of: .scan) + } + } + /// 初始化实例。 init(services: AppServices) { self.services = services super.init(nibName: nil, bundle: nil) - MainScanPlusButton.onScanTap = { [weak self] in - self?.presentGlobalScanner() - } -// delegate = self + delegate = self } @available(*, unavailable) @@ -44,13 +78,6 @@ final class MainTabBarController: CYLTabBarController { #endif } - /// 控制器释放前清理全局扫码按钮回调。 - deinit { - Task { @MainActor in - MainScanPlusButton.onScanTap = nil - } - } - /// 配置 TabBar 外观,对齐参考工程 CustomMainTabBar 样式。 private func configureTabBarAppearance() { tabBar.tintColor = AppDesign.primary @@ -76,26 +103,22 @@ final class MainTabBarController: CYLTabBarController { tabBar.scrollEdgeAppearance = appearance } - /// 为每个 Tab 创建独立导航栈,并按 CYLTabBarController 要求先配置 Tab 属性再设置控制器。 + /// 为每个业务 Tab 创建独立导航栈,并在中间插入系统扫码槽位。 private func configureTabs() { - let tabs = AppTab.allCases - tabBarItemsAttributes = tabs.map { makeTabBarItemAttributes(for: $0) } - viewControllers = tabs.map { tab in - let navigationController = TabNavigationController(tab: tab, services: services) - tabNavigationControllers[tab] = navigationController - return navigationController + tabNavigationControllers.removeAll() + viewControllers = TabSlot.allCases.map { slot in + if let tab = slot.appTab { + let navigationController = TabNavigationController(tab: tab, services: services) + navigationController.tabBarItem = tab.makeTabBarItem() + tabNavigationControllers[tab] = navigationController + return navigationController + } + let scannerPlaceholder = UIViewController() + scannerPlaceholder.tabBarItem = MainScanTabItem.makeTabBarItem() + return scannerPlaceholder } } - /// 构建 CYLTabBarController 识别的 TabBarItem 属性字典。 - private func makeTabBarItemAttributes(for tab: AppTab) -> [String: String] { - [ - CYLTabBarItemTitle: tab.title, - CYLTabBarItemImage: tab.unselectedImageName, - CYLTabBarItemSelectedImage: tab.selectedImageName - ] - } - /// 绑定角标 ViewModel 回调。 private func bindBadgeViewModel() { badgeViewModel.onChange = { [weak self] in @@ -132,6 +155,8 @@ final class MainTabBarController: CYLTabBarController { /// 从 TabBar 中间按钮打开全局扫码页,成功后路由至订单核销入口。 private func presentGlobalScanner() { + guard presentedViewController == nil else { return } + let scanner = OrderCodeScannerViewController() scanner.onScanResult = { [weak self, weak scanner] result in scanner?.dismiss(animated: true) @@ -147,9 +172,25 @@ final class MainTabBarController: CYLTabBarController { navigationController.modalPresentationStyle = .fullScreen present(navigationController, animated: true) } +} + +extension MainTabBarController: UITabBarControllerDelegate { + /// 用户选择 TabBar 槽位前拦截扫码入口,使关闭扫码页后保留原页面。 + func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { + guard viewControllers?.firstIndex(of: viewController) == TabSlot.scanIndex else { + return true + } + + if let onScanTabSelected { + onScanTabSelected() + } else { + presentGlobalScanner() + } + return false + } /// 用户点击 TabBar 时刷新订单角标。 - override func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { + func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { guard !isSyncingTabSelection, let navigationController = viewController as? TabNavigationController else { return } @@ -162,7 +203,7 @@ final class MainTabBarController: CYLTabBarController { extension MainTabBarController: AppNavigationHost { /// 选中指定主 Tab。 func selectTab(_ tab: AppTab) { - guard let index = AppTab.allCases.firstIndex(of: tab) else { return } + guard let index = TabSlot.index(for: tab) else { return } guard selectedIndex != index else { return } isSyncingTabSelection = true selectedIndex = index diff --git a/suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift b/suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift deleted file mode 100644 index 6df5d96..0000000 --- a/suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// MainScanPlusButton.swift -// suixinkan -// - -import CYLTabBarController -import UIKit - -@MainActor -@objcMembers -/// 主 TabBar 中间扫码核销按钮,基于 CYLTabBarController 的 PlusButton 机制实现。 -final class MainScanPlusButton: CYLPlusButton, CYLPlusButtonSubclassing { - - /// 点击扫码按钮时的回调,由 `MainTabBarController` 注入。 - static var onScanTap: (() -> Void)? - - /// 构建中间扫码按钮实例,供 CYLTabBarController 注册使用。 - static func plusButton() -> Any { - let button = MainScanPlusButton() - button.setImage(UIImage(named: "icon_scan"), for: .normal) - button.titleLabel?.textAlignment = .center - - button.titleLabel?.font = UIFont.systemFont(ofSize: 10) - - button.setTitle("发布", for: .normal) - button.setTitleColor(UIColor.gray, for: .normal) - - button.setTitle("选中", for: .selected) - button.setTitleColor(UIColor(red:255.0/255.0,green:102.0/255.0,blue:0,alpha:1.0), for: .selected) - - button.adjustsImageWhenHighlighted = false - button.sizeToFit() - return button - } - - /// 指定扫码按钮在 TabBar 中的视觉槽位(位于订单与数据之间)。 - static func indexOfPlusButtonInTabBar() -> UInt { - 2 - } - - /// 调整扫码按钮垂直位置,使其略微凸出 TabBar 顶部。 - static func constantOfPlusButtonCenterYOffset(forTabBarHeight tabBarHeight: CGFloat) -> CGFloat { - -6 - } - - /// 配置圆形背景与扫码图标,对齐参考工程 CustomMainTabBarCenterAction。 - private func configureAppearance() { - backgroundColor = .clear - - let circleSize: CGFloat = 58 - let circleView = UIView() - circleView.isUserInteractionEnabled = false - circleView.backgroundColor = AppDesign.primary - circleView.layer.cornerRadius = circleSize / 2 - addSubview(circleView) - - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 30, weight: .bold) - let iconView = UIImageView( - image: UIImage(named: "icon_scan") - ) - iconView.tintColor = .white - iconView.contentMode = .scaleAspectFit - iconView.isUserInteractionEnabled = false - addSubview(iconView) - - circleView.translatesAutoresizingMaskIntoConstraints = false - iconView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - circleView.centerXAnchor.constraint(equalTo: centerXAnchor), - circleView.centerYAnchor.constraint(equalTo: centerYAnchor), - circleView.widthAnchor.constraint(equalToConstant: circleSize), - circleView.heightAnchor.constraint(equalToConstant: circleSize), - iconView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - iconView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), - iconView.widthAnchor.constraint(equalToConstant: 30), - iconView.heightAnchor.constraint(equalToConstant: 30) - ]) - - accessibilityLabel = "扫码核销" - accessibilityIdentifier = "main.scan" - } - - /// 响应用户点击,触发全局扫码流程。 - @objc private func handleScanTap() { - Self.onScanTap?() - } -} diff --git a/suixinkan_ios/Features/Main/Views/MainScanTabItem.swift b/suixinkan_ios/Features/Main/Views/MainScanTabItem.swift new file mode 100644 index 0000000..84b2905 --- /dev/null +++ b/suixinkan_ios/Features/Main/Views/MainScanTabItem.swift @@ -0,0 +1,21 @@ +// +// MainScanTabItem.swift +// suixinkan +// + +import UIKit + +@MainActor +/// 主 TabBar 中间扫码核销入口,负责构建系统 TabBarItem。 +enum MainScanTabItem { + + /// 构建系统扫码 TabBarItem,点击行为由 `MainTabBarController` 拦截处理。 + static func makeTabBarItem() -> UITabBarItem { + let image = UIImage(named: "icon_scan")?.withRenderingMode(.alwaysOriginal) + let selectedImage = UIImage(named: "icon_scan")?.withRenderingMode(.alwaysOriginal) + let item = UITabBarItem(title: "扫码", image: image, selectedImage: selectedImage) + item.accessibilityLabel = "扫码核销" + item.accessibilityIdentifier = "main.scan" + return item + } +} diff --git a/suixinkan_ios/SceneDelegate.swift b/suixinkan_ios/SceneDelegate.swift index 6b74e0b..f3edfe3 100644 --- a/suixinkan_ios/SceneDelegate.swift +++ b/suixinkan_ios/SceneDelegate.swift @@ -6,7 +6,6 @@ // import UIKit -import CYLTabBarController /// 窗口 Scene 代理,负责创建根窗口并挂载会话协调器。 class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -24,8 +23,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) { guard let windowScene = scene as? UIWindowScene else { return } - MainScanPlusButton.register() - AppUITestLaunchState.resetIfNeeded() let rootViewController = RootViewController() diff --git a/suixinkan_iosTests/MainScanPlusButtonTests.swift b/suixinkan_iosTests/MainScanPlusButtonTests.swift deleted file mode 100644 index 0b4bebb..0000000 --- a/suixinkan_iosTests/MainScanPlusButtonTests.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// 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 的图标资源可被加载并挂载到 tabBarItem。 - func testMainTabBarControllerTabBarItemsHaveImages() { - let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession())) - let controller = MainTabBarController(services: services) - controller.loadViewIfNeeded() - - for tab in AppTab.allCases { - let item = controller.navigationController(for: tab)?.tabBarItem - XCTAssertNotNil(item?.image, "tab \(tab.rawValue) image should not be nil") - XCTAssertNotNil(item?.selectedImage, "tab \(tab.rawValue) selectedImage should not be nil") - XCTAssertGreaterThan(item?.image?.size.width ?? 0, 0) - XCTAssertGreaterThan(item?.selectedImage?.size.width ?? 0, 0) - } - } -} - -/// 主 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) - } -} diff --git a/suixinkan_iosTests/MainScanTabItemTests.swift b/suixinkan_iosTests/MainScanTabItemTests.swift new file mode 100644 index 0000000..c58d07d --- /dev/null +++ b/suixinkan_iosTests/MainScanTabItemTests.swift @@ -0,0 +1,115 @@ +// +// MainScanTabItemTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan_ios + +/// 主 TabBar 中间扫码入口测试,覆盖系统 TabBar 槽位配置。 +@MainActor +final class MainScanTabItemTests: XCTestCase { + + /// 测试主 Tab 容器加载后包含四个业务 Tab 和中间扫码入口。 + func testMainTabBarControllerLoadsFiveSystemTabItems() { + let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession())) + let controller = MainTabBarController(services: services) + + controller.loadViewIfNeeded() + + XCTAssertEqual(controller.viewControllers?.count, 5) + for tab in AppTab.allCases { + XCTAssertNotNil(controller.navigationController(for: tab)) + XCTAssertEqual( + controller.navigationController(for: tab)?.tabBarItem.accessibilityIdentifier, + "main.tab.\(tab.rawValue)" + ) + } + } + + /// 测试扫码入口位于订单与数据之间,并保留指定无障碍标识。 + func testScanTabItemIsCenteredBetweenOrdersAndStatistics() throws { + let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession())) + let controller = MainTabBarController(services: services) + + controller.loadViewIfNeeded() + + let scanItem = try XCTUnwrap(controller.viewControllers?[2].tabBarItem) + XCTAssertEqual(scanItem.title, "扫码") + XCTAssertEqual(scanItem.accessibilityIdentifier, "main.scan") + XCTAssertEqual(scanItem.accessibilityLabel, "扫码核销") + XCTAssertNotNil(scanItem.image) + XCTAssertNotNil(scanItem.selectedImage) + } + + /// 测试点击扫码槽位会被拦截且不改变当前选中页面。 + func testSelectingScanTabPresentsScannerWithoutSelectingPlaceholder() throws { + let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession())) + let controller = MainTabBarController(services: services) + var scanTapCount = 0 + controller.onScanTabSelected = { + scanTapCount += 1 + } + + controller.loadViewIfNeeded() + controller.selectedIndex = 1 + + let scanController = try XCTUnwrap(controller.viewControllers?[2]) + XCTAssertFalse(controller.tabBarController(controller, shouldSelect: scanController)) + XCTAssertEqual(scanTapCount, 1) + XCTAssertEqual(controller.selectedIndex, 1) + } + + /// 测试业务 Tab 仍允许系统 TabBar 正常选中。 + func testSelectingBusinessTabIsAllowed() throws { + let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession())) + let controller = MainTabBarController(services: services) + + controller.loadViewIfNeeded() + + let ordersController = try XCTUnwrap(controller.viewControllers?[1]) + XCTAssertTrue(controller.tabBarController(controller, shouldSelect: ordersController)) + } + + /// 测试各 Tab 的图标资源可被加载并挂载到 tabBarItem。 + func testMainTabBarControllerTabBarItemsHaveImages() { + let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession())) + let controller = MainTabBarController(services: services) + controller.loadViewIfNeeded() + + for tab in AppTab.allCases { + let item = controller.navigationController(for: tab)?.tabBarItem + XCTAssertNotNil(item?.image, "tab \(tab.rawValue) image should not be nil") + XCTAssertNotNil(item?.selectedImage, "tab \(tab.rawValue) selectedImage should not be nil") + XCTAssertGreaterThan(item?.image?.size.width ?? 0, 0) + XCTAssertGreaterThan(item?.selectedImage?.size.width ?? 0, 0) + } + } + + /// 测试订单角标仍挂载在订单业务导航控制器的系统 TabBarItem 上。 + func testOrderBadgeUsesOrdersNavigationTabBarItem() throws { + let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession())) + let controller = MainTabBarController(services: services) + + controller.loadViewIfNeeded() + + let ordersNavigationController = try XCTUnwrap(controller.navigationController(for: .orders)) + ordersNavigationController.tabBarItem.badgeValue = "3" + + XCTAssertEqual(controller.viewControllers?[1].tabBarItem.badgeValue, "3") + } +} + +/// 主 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) + } +} diff --git a/功能同步Checklist.md b/功能同步Checklist.md index 6429f1e..00f32b9 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -29,7 +29,7 @@ | 状态 | 模块 | UIKit 实现 | 剩余事项 | | --- | --- | --- | --- | | 完成 | App 根结构 | `RootViewController` 监听登录阶段;登录后 `MainTabBarController` 作为 window 根控制器 | 持续随路由扩展 | -| 完成 | Tab 导航 | CYLTabBarController 四 Tab + 中间扫码按钮 + 独立 `TabNavigationController`;push 子页隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | +| 完成 | Tab 导航 | 系统 UITabBarController 五入口(首页、订单、扫码、数据、我的)+ 独立 `TabNavigationController`;扫码入口拦截后 present 扫码页,push 子页隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | | 完成 | 全局状态 | `AppServices.shared` 注入 Session / Context / Router / Toast / Loading | 避免新增 monolith 状态 | | 完成 | 登录态缓存 | Keychain token + UserDefaults 快照 / 偏好 + 冷启动恢复 | — | | 完成 | 网络框架 | `APIClient` / `APIEnvelope` / 统一错误与 token | — |