为 TabBar 和扫码页补充 accessibilityIdentifier,并在模拟器/UI Test 中禁用真实相机采集。
预初始化各 Tab 路由容器,修正 iOS 17 ContentUnavailableView 调用,提升自定义 TabBar 与扫码流程的 UI Test 稳定性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,18 +70,40 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// AVFoundation 扫码桥接视图,负责把相机识别结果回传给 SwiftUI。
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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?
|
||||
|
||||
Reference in New Issue
Block a user