为 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 selectedTab: AppTab = .home
|
||||||
@Published var selectedOrdersEntry: OrdersEntry = .storeOrders
|
@Published var selectedOrdersEntry: OrdersEntry = .storeOrders
|
||||||
@Published private(set) var pendingOrderScanCode: String?
|
@Published private(set) var pendingOrderScanCode: String?
|
||||||
@Published private var routers: [AppTab: RouterPath] = [:]
|
private let routers: [AppTab: RouterPath]
|
||||||
|
|
||||||
/// 获取指定 Tab 对应的路由路径容器,不存在时自动创建。
|
init() {
|
||||||
func router(for tab: AppTab) -> RouterPath {
|
routers = Dictionary(
|
||||||
if let router = routers[tab] {
|
uniqueKeysWithValues: AppTab.allCases.map { tab in
|
||||||
return router
|
(tab, RouterPath())
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let router = RouterPath()
|
/// 获取指定 Tab 对应的路由路径容器。
|
||||||
routers[tab] = router
|
func router(for tab: AppTab) -> RouterPath {
|
||||||
|
guard let router = routers[tab] else {
|
||||||
|
assertionFailure("Missing router path for tab: \(tab)")
|
||||||
|
return RouterPath()
|
||||||
|
}
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// iOS 16 兼容的空状态视图;iOS 17+ 自动使用系统 AppContentUnavailableView。
|
/// iOS 16 兼容的空状态视图;iOS 17+ 自动使用系统 ContentUnavailableView。
|
||||||
struct AppContentUnavailableView<LabelContent: View, DescriptionContent: View, ActionsContent: View>: View {
|
struct AppContentUnavailableView<LabelContent: View, DescriptionContent: View, ActionsContent: View>: View {
|
||||||
private let label: LabelContent
|
private let label: LabelContent
|
||||||
private let description: DescriptionContent
|
private let description: DescriptionContent
|
||||||
@ -25,7 +25,7 @@ struct AppContentUnavailableView<LabelContent: View, DescriptionContent: View, A
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
AppContentUnavailableView {
|
ContentUnavailableView {
|
||||||
label
|
label
|
||||||
} description: {
|
} description: {
|
||||||
description
|
description
|
||||||
|
|||||||
@ -106,6 +106,7 @@ private struct CustomMainTabBarItem: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(item.title)
|
.accessibilityLabel(item.title)
|
||||||
|
.accessibilityIdentifier("main.tab.\(item.tab.rawValue)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,6 +129,7 @@ private struct CustomMainTabBarCenterAction: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("扫码核销")
|
.accessibilityLabel("扫码核销")
|
||||||
|
.accessibilityIdentifier("main.scan")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,23 @@ struct OrderScannerPage: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
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) {
|
ZStack(alignment: .bottom) {
|
||||||
OrderCodeScannerView { result in
|
OrderCodeScannerView { result in
|
||||||
switch result {
|
switch result {
|
||||||
@ -53,18 +70,40 @@ struct OrderScannerPage: View {
|
|||||||
.background(.black.opacity(0.55), in: Capsule())
|
.background(.black.opacity(0.55), in: Capsule())
|
||||||
.padding(.bottom, AppMetrics.Spacing.xxLarge)
|
.padding(.bottom, AppMetrics.Spacing.xxLarge)
|
||||||
}
|
}
|
||||||
.navigationTitle("扫码核销")
|
} else {
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
ScannerUnavailableContent()
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button("关闭") {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier("scanner.close")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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。
|
/// AVFoundation 扫码桥接视图,负责把相机识别结果回传给 SwiftUI。
|
||||||
@ -229,7 +268,7 @@ final class OrderScannerViewController: UIViewController, AVCaptureMetadataOutpu
|
|||||||
session.addOutput(metadataOutput)
|
session.addOutput(metadataOutput)
|
||||||
|
|
||||||
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||||
metadataOutput.metadataObjectTypes = [
|
let desiredTypes: [AVMetadataObject.ObjectType] = [
|
||||||
.qr,
|
.qr,
|
||||||
.ean8,
|
.ean8,
|
||||||
.ean13,
|
.ean13,
|
||||||
@ -242,6 +281,12 @@ final class OrderScannerViewController: UIViewController, AVCaptureMetadataOutpu
|
|||||||
.itf14,
|
.itf14,
|
||||||
.upce
|
.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)
|
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||||
preview.videoGravity = .resizeAspectFill
|
preview.videoGravity = .resizeAspectFill
|
||||||
|
|||||||
@ -63,7 +63,7 @@ final class OrdersFlowUITests: AuthenticatedUITestCase {
|
|||||||
func testScannerCanOpenAndClose() {
|
func testScannerCanOpenAndClose() {
|
||||||
TabBarNavigator.select(.orders, app: app)
|
TabBarNavigator.select(.orders, app: app)
|
||||||
|
|
||||||
let scanButton = app.application.buttons["扫码核销"]
|
let scanButton = app.application.buttons["main.scan"]
|
||||||
XCTAssertTrue(scanButton.waitForExistence(timeout: 6))
|
XCTAssertTrue(scanButton.waitForExistence(timeout: 6))
|
||||||
scanButton.tap()
|
scanButton.tap()
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import XCTest
|
|||||||
final class SuixinkanApp {
|
final class SuixinkanApp {
|
||||||
let application: XCUIApplication
|
let application: XCUIApplication
|
||||||
private let resetState: Bool
|
private let resetState: Bool
|
||||||
|
private var hasLaunched = false
|
||||||
|
|
||||||
/// 创建并配置 UI Test 启动参数的应用实例。
|
/// 创建并配置 UI Test 启动参数的应用实例。
|
||||||
init(
|
init(
|
||||||
@ -31,10 +32,15 @@ final class SuixinkanApp {
|
|||||||
/// 启动 App 并等待首屏稳定。
|
/// 启动 App 并等待首屏稳定。
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func launch(waitForLoading: Bool = true) -> Self {
|
func launch(waitForLoading: Bool = true) -> Self {
|
||||||
if application.state == .runningForeground {
|
if hasLaunched, application.state == .runningForeground {
|
||||||
application.activate()
|
application.activate()
|
||||||
} else {
|
} else {
|
||||||
|
if application.state != .notRunning {
|
||||||
|
application.terminate()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
}
|
||||||
application.launch()
|
application.launch()
|
||||||
|
hasLaunched = true
|
||||||
}
|
}
|
||||||
dismissSystemAlertsIfNeeded()
|
dismissSystemAlertsIfNeeded()
|
||||||
if waitForLoading {
|
if waitForLoading {
|
||||||
@ -72,12 +78,10 @@ final class SuixinkanApp {
|
|||||||
|
|
||||||
/// 判断主 Tab 是否已出现。
|
/// 判断主 Tab 是否已出现。
|
||||||
var isOnMainTabs: Bool {
|
var isOnMainTabs: Bool {
|
||||||
TabBarNavigator.Tab.allCases.contains { tab in
|
application.buttons[TabBarNavigator.Tab.home.identifier].exists
|
||||||
let button = application.buttons.matching(
|
|| application.buttons[TabBarNavigator.Tab.orders.identifier].exists
|
||||||
NSPredicate(format: "label == %@", tab.rawValue)
|
|| application.buttons[TabBarNavigator.Tab.statistics.identifier].exists
|
||||||
).firstMatch
|
|| application.buttons[TabBarNavigator.Tab.profile.identifier].exists
|
||||||
return button.waitForExistence(timeout: 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 等待冷启动后进入登录页或主 Tab。
|
/// 等待冷启动后进入登录页或主 Tab。
|
||||||
|
|||||||
@ -14,6 +14,19 @@ enum TabBarNavigator {
|
|||||||
case orders = "订单"
|
case orders = "订单"
|
||||||
case statistics = "数据"
|
case statistics = "数据"
|
||||||
case profile = "我的"
|
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 并等待页面稳定。
|
/// 切换到指定 Tab 并等待页面稳定。
|
||||||
@ -26,6 +39,13 @@ enum TabBarNavigator {
|
|||||||
|
|
||||||
/// 在底部自定义 TabBar 中定位唯一可点击的 Tab 按钮。
|
/// 在底部自定义 TabBar 中定位唯一可点击的 Tab 按钮。
|
||||||
private static func bottomTabButton(named title: String, in app: SuixinkanApp) -> XCUIElement {
|
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 candidates = app.application.buttons.matching(NSPredicate(format: "label == %@", title))
|
||||||
let screenHeight = app.application.windows.element(boundBy: 0).frame.height
|
let screenHeight = app.application.windows.element(boundBy: 0).frame.height
|
||||||
var bestMatch: XCUIElement?
|
var bestMatch: XCUIElement?
|
||||||
|
|||||||
Reference in New Issue
Block a user