为 TabBar 和扫码页补充 accessibilityIdentifier,并在模拟器/UI Test 中禁用真实相机采集。

预初始化各 Tab 路由容器,修正 iOS 17 ContentUnavailableView 调用,提升自定义 TabBar 与扫码流程的 UI Test 稳定性。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 09:34:09 +08:00
parent d27438b264
commit 79e735f628
7 changed files with 105 additions and 28 deletions

View File

@ -81,16 +81,22 @@ final class AppRouter: ObservableObject {
@Published var selectedTab: AppTab = .home
@Published var selectedOrdersEntry: OrdersEntry = .storeOrders
@Published private(set) var pendingOrderScanCode: String?
@Published private var routers: [AppTab: RouterPath] = [:]
private let routers: [AppTab: RouterPath]
/// Tab
func router(for tab: AppTab) -> RouterPath {
if let router = routers[tab] {
return router
init() {
routers = Dictionary(
uniqueKeysWithValues: AppTab.allCases.map { tab in
(tab, RouterPath())
}
)
}
let router = RouterPath()
routers[tab] = router
/// Tab
func router(for tab: AppTab) -> RouterPath {
guard let router = routers[tab] else {
assertionFailure("Missing router path for tab: \(tab)")
return RouterPath()
}
return router
}

View File

@ -7,7 +7,7 @@
import SwiftUI
/// iOS 16 iOS 17+ 使 AppContentUnavailableView
/// iOS 16 iOS 17+ 使 ContentUnavailableView
struct AppContentUnavailableView<LabelContent: View, DescriptionContent: View, ActionsContent: View>: View {
private let label: LabelContent
private let description: DescriptionContent
@ -25,7 +25,7 @@ struct AppContentUnavailableView<LabelContent: View, DescriptionContent: View, A
var body: some View {
if #available(iOS 17.0, *) {
AppContentUnavailableView {
ContentUnavailableView {
label
} description: {
description

View File

@ -106,6 +106,7 @@ private struct CustomMainTabBarItem: View {
}
.buttonStyle(.plain)
.accessibilityLabel(item.title)
.accessibilityIdentifier("main.tab.\(item.tab.rawValue)")
}
}
@ -128,6 +129,7 @@ private struct CustomMainTabBarCenterAction: View {
}
.buttonStyle(.plain)
.accessibilityLabel("扫码核销")
.accessibilityIdentifier("main.scan")
}
}

View File

@ -34,6 +34,23 @@ struct OrderScannerPage: View {
var body: some View {
NavigationStack {
content
.navigationTitle("扫码核销")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") {
onClose()
}
.accessibilityIdentifier("scanner.close")
}
}
}
}
@ViewBuilder
private var content: some View {
if shouldUseCameraPreview {
ZStack(alignment: .bottom) {
OrderCodeScannerView { result in
switch result {
@ -53,17 +70,39 @@ struct OrderScannerPage: View {
.background(.black.opacity(0.55), in: Capsule())
.padding(.bottom, AppMetrics.Spacing.xxLarge)
}
.navigationTitle("扫码核销")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") {
onClose()
}
.accessibilityIdentifier("scanner.close")
} else {
ScannerUnavailableContent()
}
}
private var shouldUseCameraPreview: Bool {
#if targetEnvironment(simulator)
return false
#else
guard !AppUITestLaunchState.isRunningUITests else { return false }
return AVCaptureDevice.default(for: .video) != nil
#endif
}
}
/// UI Test
private struct ScannerUnavailableContent: View {
var body: some View {
VStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "camera.viewfinder")
.font(.system(size: 48, weight: .semibold))
.foregroundStyle(.white)
Text("当前环境不可用相机")
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(.white)
Text("请在真机上使用扫码核销")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(.white.opacity(0.72))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
}
}
@ -229,7 +268,7 @@ final class OrderScannerViewController: UIViewController, AVCaptureMetadataOutpu
session.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
metadataOutput.metadataObjectTypes = [
let desiredTypes: [AVMetadataObject.ObjectType] = [
.qr,
.ean8,
.ean13,
@ -242,6 +281,12 @@ final class OrderScannerViewController: UIViewController, AVCaptureMetadataOutpu
.itf14,
.upce
]
let availableTypes = Set(metadataOutput.availableMetadataObjectTypes)
let supportedTypes = desiredTypes.filter { availableTypes.contains($0) }
guard !supportedTypes.isEmpty else {
throw OrderScannerError.invalidMetadata
}
metadataOutput.metadataObjectTypes = supportedTypes
let preview = AVCaptureVideoPreviewLayer(session: session)
preview.videoGravity = .resizeAspectFill

View File

@ -63,7 +63,7 @@ final class OrdersFlowUITests: AuthenticatedUITestCase {
func testScannerCanOpenAndClose() {
TabBarNavigator.select(.orders, app: app)
let scanButton = app.application.buttons["扫码核销"]
let scanButton = app.application.buttons["main.scan"]
XCTAssertTrue(scanButton.waitForExistence(timeout: 6))
scanButton.tap()

View File

@ -11,6 +11,7 @@ import XCTest
final class SuixinkanApp {
let application: XCUIApplication
private let resetState: Bool
private var hasLaunched = false
/// UI Test
init(
@ -31,10 +32,15 @@ final class SuixinkanApp {
/// App
@discardableResult
func launch(waitForLoading: Bool = true) -> Self {
if application.state == .runningForeground {
if hasLaunched, application.state == .runningForeground {
application.activate()
} else {
if application.state != .notRunning {
application.terminate()
Thread.sleep(forTimeInterval: 0.5)
}
application.launch()
hasLaunched = true
}
dismissSystemAlertsIfNeeded()
if waitForLoading {
@ -72,12 +78,10 @@ final class SuixinkanApp {
/// Tab
var isOnMainTabs: Bool {
TabBarNavigator.Tab.allCases.contains { tab in
let button = application.buttons.matching(
NSPredicate(format: "label == %@", tab.rawValue)
).firstMatch
return button.waitForExistence(timeout: 1)
}
application.buttons[TabBarNavigator.Tab.home.identifier].exists
|| application.buttons[TabBarNavigator.Tab.orders.identifier].exists
|| application.buttons[TabBarNavigator.Tab.statistics.identifier].exists
|| application.buttons[TabBarNavigator.Tab.profile.identifier].exists
}
/// Tab

View File

@ -14,6 +14,19 @@ enum TabBarNavigator {
case orders = "订单"
case statistics = "数据"
case profile = "我的"
var identifier: String {
switch self {
case .home:
return "main.tab.home"
case .orders:
return "main.tab.orders"
case .statistics:
return "main.tab.statistics"
case .profile:
return "main.tab.profile"
}
}
}
/// Tab
@ -26,6 +39,13 @@ enum TabBarNavigator {
/// TabBar Tab
private static func bottomTabButton(named title: String, in app: SuixinkanApp) -> XCUIElement {
if let tab = Tab(rawValue: title) {
let identifiedButton = app.application.buttons[tab.identifier]
if identifiedButton.exists {
return identifiedButton
}
}
let candidates = app.application.buttons.matching(NSPredicate(format: "label == %@", title))
let screenHeight = app.application.windows.element(boundBy: 0).frame.height
var bestMatch: XCUIElement?