新增完整 UI Test 回归套件,并完善启动隔离逻辑。

按模块拆分 XCUITest 用例与 Support 基础设施,UI Test 运行时跳过推送与排队 WebSocket,并将 Podfile 最低版本对齐 iOS 16。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 10:45:00 +08:00
parent 703078352c
commit 0c01ee26c3
39 changed files with 1533 additions and 231 deletions

View File

@ -0,0 +1,25 @@
//
// AuthFlowUITests.swift
// suixinkanUITests
//
import XCTest
final class AuthFlowUITests: AuthenticatedUITestCase {
/// Tab
func testLoginReachesMainTabs() {
XCTAssertTrue(app.isOnMainTabs)
}
/// 访 Tab
func testLoggedInUserCanSwitchPrimaryTabs() {
TabBarNavigator.select(.home, app: app)
XCTAssertTrue(
app.application.staticTexts["常用应用"].waitForExistence(timeout: 8)
|| app.application.staticTexts["请选择景区"].exists
)
TabBarNavigator.select(.profile, app: app)
NavigationAssertions.waitForNavigationTitle("个人信息", in: app)
}
}

View File

@ -0,0 +1,60 @@
//
// LoginSmokeUITests.swift
// suixinkanUITests
//
import XCTest
final class LoginSmokeUITests: XCTestCase {
override func setUp() {
super.setUp()
continueAfterFailure = false
}
///
func testFreshLaunchShowsLoginScreen() {
let app = SuixinkanApp(resetState: true).launch(waitForLoading: false)
XCTAssertTrue(app.application.staticTexts["login.title"].waitForExistence(timeout: 8))
XCTAssertTrue(app.application.textFields["login.username"].exists)
XCTAssertTrue(
app.application.secureTextFields["login.password"].exists
|| app.application.textFields["login.password"].exists
)
XCTAssertTrue(app.application.buttons["login.submit"].exists)
XCTAssertTrue(app.application.buttons["login.userAgreement"].exists)
XCTAssertTrue(app.application.buttons["login.privacyPolicy"].exists)
}
///
func testLoginRequiresPrivacyAgreement() throws {
let app = SuixinkanApp(resetState: true).launch(waitForLoading: false)
guard UITestLaunchConfiguration.credentials != nil else {
throw XCTSkip("缺少 UI Test 账号。")
}
let usernameField = app.application.textFields["login.username"]
XCTAssertTrue(usernameField.waitForExistence(timeout: 8))
XCTAssertFalse(app.application.buttons["login.submit"].isEnabled)
}
///
func testEmptyPasswordDoesNotLeaveLoginScreen() throws {
let app = SuixinkanApp(resetState: true).launch(waitForLoading: false)
let credentials = try UITestLaunchConfiguration.requireCredentials()
let usernameField = app.application.textFields["login.username"]
XCTAssertTrue(usernameField.waitForExistence(timeout: 8))
usernameField.tap()
usernameField.typeText(credentials.phone)
let privacyToggle = app.application.buttons["login.privacy"]
if privacyToggle.exists {
privacyToggle.tap()
}
app.application.buttons["login.submit"].tap()
app.waitForGlobalLoadingToDisappear(timeout: 5)
XCTAssertTrue(app.isOnLoginScreen)
}
}

View File

@ -0,0 +1,33 @@
//
// HomeRoutesUITests.swift
// suixinkanUITests
//
import XCTest
final class HomeRoutesUITests: AuthenticatedUITestCase {
/// Home
func testAllHomeRoutesFromDebugMenu() {
for entry in UITestHomeMenuCatalog.entries {
XCTContext.runActivity(named: entry.menuTitle) { _ in
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: entry.menuTitle, app: app)
UITestExpectationVerifier.verify(entry.expectation, app: app)
restoreAfterRoute(entry.expectation)
}
}
}
/// 便
private func restoreAfterRoute(_ expectation: UITestHomeMenuCatalog.Expectation) {
switch expectation {
case .tab:
DebugMenuNavigator.open(app: app)
case .staticText:
BackNavigator.dismissCustomTopBarIfNeeded(app: app)
DebugMenuNavigator.returnToMenu(app: app)
default:
DebugMenuNavigator.returnToMenu(app: app)
}
}
}

View File

@ -1,28 +0,0 @@
//
// LoginSmokeUITests.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
final class LoginSmokeUITests: XCTestCase {
override func setUp() {
super.setUp()
continueAfterFailure = false
}
func testFreshLaunchShowsLoginScreen() {
let app = XCUIApplication()
app.launchArguments.append("-suixinkan-ui-tests-reset-state")
app.launch()
XCTAssertTrue(app.staticTexts["login.title"].waitForExistence(timeout: 5))
XCTAssertTrue(app.textFields["login.username"].exists)
XCTAssertTrue(app.secureTextFields["login.password"].exists || app.textFields["login.password"].exists)
XCTAssertTrue(app.buttons["login.submit"].exists)
XCTAssertTrue(app.buttons["login.userAgreement"].exists)
XCTAssertTrue(app.buttons["login.privacyPolicy"].exists)
}
}

View File

@ -0,0 +1,26 @@
//
// InviteUITests.swift
// suixinkanUITests
//
import XCTest
final class InviteUITests: AuthenticatedUITestCase {
///
func testInviteAndRecordPages() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "注册邀请", app: app)
NavigationAssertions.waitForNavigationTitle("摄影师邀请", in: app)
let recordEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '邀请记录'")).firstMatch
if recordEntry.waitForExistence(timeout: 6) {
recordEntry.tap()
NavigationAssertions.waitForNavigationTitle("邀请记录", in: app)
} else {
BackNavigator.goBack(app: app)
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "邀请记录", app: app)
NavigationAssertions.waitForNavigationTitle("邀请记录", in: app)
}
}
}

View File

@ -0,0 +1,29 @@
//
// LiveUITests.swift
// suixinkanUITests
//
import XCTest
final class LiveUITests: AuthenticatedUITestCase {
///
func testLiveManagementAndCreatePage() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "直播管理", app: app)
NavigationAssertions.waitForNavigationTitle("直播管理", in: app)
let addButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '添加'")).firstMatch
if addButton.waitForExistence(timeout: 6) {
addButton.tap()
NavigationAssertions.waitForNavigationTitle("添加直播", in: app)
BackNavigator.goBack(app: app)
}
}
///
func testLiveAlbumPage() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "直播相册", app: app)
NavigationAssertions.waitForNavigationTitle("直播相册", in: app)
}
}

View File

@ -0,0 +1,26 @@
//
// LocationReportUITests.swift
// suixinkanUITests
//
import XCTest
final class LocationReportUITests: AuthenticatedUITestCase {
///
func testLocationReportAndHistory() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "位置上报", app: app)
NavigationAssertions.waitForNavigationTitle("位置上报", in: app)
let historyEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '历史'")).firstMatch
if historyEntry.waitForExistence(timeout: 6) {
historyEntry.tap()
NavigationAssertions.waitForNavigationTitle("位置上报历史", in: app)
} else {
BackNavigator.goBack(app: app)
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "定位上报历史", app: app)
NavigationAssertions.waitForNavigationTitle("位置上报历史", in: app)
}
}
}

View File

@ -0,0 +1,21 @@
//
// MessageCenterUITests.swift
// suixinkanUITests
//
import XCTest
final class MessageCenterUITests: AuthenticatedUITestCase {
///
func testMessageCenterListAndDetail() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "消息中心", app: app)
NavigationAssertions.waitForNavigationTitle("消息中心", in: app)
let firstCell = app.application.cells.element(boundBy: 0)
if firstCell.waitForExistence(timeout: 8) {
firstCell.tap()
NavigationAssertions.waitForNavigationTitle("消息详情", in: app)
}
}
}

View File

@ -0,0 +1,28 @@
//
// PilotCertificationUITests.swift
// suixinkanUITests
//
import XCTest
final class PilotCertificationUITests: AuthenticatedUITestCase {
///
func testPilotCertificationValidation() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "飞手认证", app: app)
NavigationAssertions.waitForNavigationTitle("飞手认证", in: app)
let submitButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '提交'")).firstMatch
if submitButton.waitForExistence(timeout: 6) {
submitButton.tap()
}
XCTAssertTrue(
app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '请'")).firstMatch
.waitForExistence(timeout: 4)
)
let smsButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '验证码'")).firstMatch
XCTAssertTrue(smsButton.exists)
}
}

View File

@ -0,0 +1,29 @@
//
// ProjectsUITests.swift
// suixinkanUITests
//
import XCTest
final class ProjectsUITests: AuthenticatedUITestCase {
///
func testProjectManagementCreatePage() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "项目管理", app: app)
NavigationAssertions.waitForNavigationTitle("项目管理", in: app)
let createButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '新建'")).firstMatch
if createButton.waitForExistence(timeout: 6) {
createButton.tap()
NavigationAssertions.waitForNavigationTitle("新建项目", in: app)
BackNavigator.goBack(app: app)
}
}
///
func testStoreProjectManagementPage() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "店铺项目管理", app: app)
NavigationAssertions.waitForNavigationTitle("店铺项目管理", in: app)
}
}

View File

@ -0,0 +1,21 @@
//
// PunchPointUITests.swift
// suixinkanUITests
//
import XCTest
final class PunchPointUITests: AuthenticatedUITestCase {
///
func testPunchPointListAndCreatePage() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "打卡点管理", app: app)
NavigationAssertions.waitForNavigationTitle("打卡点管理", in: app)
let createButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '新增'")).firstMatch
if createButton.waitForExistence(timeout: 6) {
createButton.tap()
NavigationAssertions.waitForNavigationTitle("新增打卡点", in: app)
}
}
}

View File

@ -0,0 +1,21 @@
//
// QueueManagementUITests.swift
// suixinkanUITests
//
import XCTest
final class QueueManagementUITests: AuthenticatedUITestCase {
///
func testQueueManagementAndSettings() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "排队管理", app: app)
NavigationAssertions.waitForNavigationTitle("排队管理", in: app)
let settingsEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '设置'")).firstMatch
if settingsEntry.waitForExistence(timeout: 6) {
settingsEntry.tap()
NavigationAssertions.waitForNavigationTitle("排队设置", in: app)
}
}
}

View File

@ -0,0 +1,21 @@
//
// ScheduleUITests.swift
// suixinkanUITests
//
import XCTest
final class ScheduleUITests: AuthenticatedUITestCase {
///
func testScheduleManagementAndAddPage() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "日程管理", app: app)
NavigationAssertions.waitForNavigationTitle("日程管理", in: app)
let addButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '添加'")).firstMatch
if addButton.waitForExistence(timeout: 6) {
addButton.tap()
NavigationAssertions.waitForNavigationTitle("添加日程", in: app)
}
}
}

View File

@ -0,0 +1,32 @@
//
// TasksUITests.swift
// suixinkanUITests
//
import XCTest
final class TasksUITests: AuthenticatedUITestCase {
///
func testTaskListDetailAndCreateValidation() throws {
openTaskManagement()
let firstCell = app.application.cells.element(boundBy: 0)
if firstCell.waitForExistence(timeout: 10) {
firstCell.tap()
NavigationAssertions.waitForNavigationTitle("任务详情", in: app)
BackNavigator.goBack(app: app)
} else {
throw XCTSkip("测试账号暂无任务数据,跳过详情流测试。")
}
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "发布任务", app: app)
NavigationAssertions.waitForNavigationTitle("发布任务", in: app)
}
private func openTaskManagement() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "任务管理", app: app)
NavigationAssertions.waitForNavigationTitle("任务管理", in: app)
}
}

View File

@ -0,0 +1,42 @@
//
// WalletUITests.swift
// suixinkanUITests
//
import XCTest
final class WalletUITests: AuthenticatedUITestCase {
///
func testWalletWithdrawValidation() {
openWallet()
app.application.staticTexts["提现"].tap()
NavigationAssertions.waitForNavigationTitle("提现申请", in: app)
let submitButton = app.application.buttons["提交提现申请"]
XCTAssertTrue(submitButton.waitForExistence(timeout: 6))
submitButton.tap()
XCTAssertTrue(
app.application.staticTexts["请输入提现金额"].waitForExistence(timeout: 4)
|| app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '金额'")).firstMatch.exists
)
}
///
func testWalletSecondaryPages() {
openWallet()
app.application.staticTexts["银行卡"].tap()
NavigationAssertions.waitForNavigationTitle("银行卡设置", in: app)
BackNavigator.goBack(app: app)
app.application.staticTexts["积分兑换"].tap()
NavigationAssertions.waitForNavigationTitle("积分兑换", in: app)
}
private func openWallet() {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "我的钱包", app: app)
NavigationAssertions.waitForNavigationTitle("我的钱包", in: app)
}
}

View File

@ -0,0 +1,76 @@
//
// OrdersFlowUITests.swift
// suixinkanUITests
//
import XCTest
final class OrdersFlowUITests: AuthenticatedUITestCase {
///
func testStoreOrderDetailFlow() throws {
TabBarNavigator.select(.orders, app: app)
app.application.staticTexts["订单管理"].tap()
app.waitForGlobalLoadingToDisappear()
let firstOrderCard = app.application.otherElements
.matching(NSPredicate(format: "identifier BEGINSWITH 'orders.store.card.'"))
.firstMatch
if !firstOrderCard.waitForExistence(timeout: 10) {
throw XCTSkip("测试账号当前景区下暂无门店订单,跳过详情流测试。")
}
firstOrderCard.tap()
NavigationAssertions.waitForNavigationTitle("订单详情", in: app)
BackNavigator.goBack(app: app)
}
///
func testWriteOffOrderDetailFlow() throws {
TabBarNavigator.select(.orders, app: app)
app.application.staticTexts["核销订单"].tap()
app.waitForGlobalLoadingToDisappear()
let firstDetailButton = app.application.buttons
.matching(NSPredicate(format: "identifier BEGINSWITH 'orders.writeoff.detail.'"))
.firstMatch
if !firstDetailButton.waitForExistence(timeout: 10) {
throw XCTSkip("测试账号当前景区下暂无核销订单,跳过详情流测试。")
}
firstDetailButton.tap()
NavigationAssertions.waitForNavigationTitle("核销订单详情", in: app)
BackNavigator.goBack(app: app)
}
///
func testDepositOrderFlow() throws {
DebugMenuNavigator.open(app: app)
DebugMenuNavigator.tapMenu(title: "押金订单详情", app: app)
NavigationAssertions.waitForNavigationTitle("押金订单", in: app)
let firstCell = app.application.cells.element(boundBy: 0)
if firstCell.waitForExistence(timeout: 8) {
firstCell.tap()
NavigationAssertions.waitForNavigationTitle("押金订单详情", in: app)
BackNavigator.goBack(app: app)
} else {
throw XCTSkip("测试账号暂无押金订单,跳过详情流测试。")
}
}
///
func testScannerCanOpenAndClose() {
TabBarNavigator.select(.orders, app: app)
let scanButton = app.application.buttons["扫码核销"]
XCTAssertTrue(scanButton.waitForExistence(timeout: 6))
scanButton.tap()
let closeButton = app.application.buttons["scanner.close"]
XCTAssertTrue(closeButton.waitForExistence(timeout: 8))
closeButton.tap()
NavigationAssertions.waitForNavigationTitle("订单", in: app)
}
}

View File

@ -0,0 +1,49 @@
//
// ProfileRoutesUITests.swift
// suixinkanUITests
//
import XCTest
final class ProfileRoutesUITests: AuthenticatedUITestCase {
///
func testSettingsAndAgreementPages() {
TabBarNavigator.select(.profile, app: app)
app.application.staticTexts["系统设置"].tap()
NavigationAssertions.waitForNavigationTitle("设置", in: app)
app.application.staticTexts["关于我们"].tap()
NavigationAssertions.waitForNavigationTitle("关于我们", in: app)
BackNavigator.goBack(app: app)
app.application.staticTexts["用户协议"].tap()
NavigationAssertions.waitForNavigationTitle("用户协议", in: app)
BackNavigator.goBack(app: app)
app.application.staticTexts["隐私政策"].tap()
NavigationAssertions.waitForNavigationTitle("隐私政策", in: app)
BackNavigator.goBack(app: app)
BackNavigator.goBack(app: app)
}
///
func testRealNameAuthPage() {
TabBarNavigator.select(.profile, app: app)
app.application.staticTexts["认证状态"].tap()
NavigationAssertions.waitForNavigationTitle("实名认证", in: app)
XCTAssertTrue(app.application.textFields.count > 0 || app.application.secureTextFields.count > 0)
BackNavigator.goBack(app: app)
}
///
func testAccountSwitchPage() {
TabBarNavigator.select(.profile, app: app)
app.application.staticTexts["当前账号"].tap()
NavigationAssertions.waitForNavigationTitle("账号切换", in: app)
BackNavigator.goBack(app: app)
NavigationAssertions.waitForNavigationTitle("个人信息", in: app)
}
}

View File

@ -0,0 +1,88 @@
# suixinkan UI Tests
本目录包含 App 的 XCUITest 回归套件,通过 **api-test 真实接口** 验证页面可达性与关键业务流程。
## 约束
- 测试代码仅位于 `suixinkanUITests/`App 侧仅扩展 `suixinkan/App/` 基础设施(如 `AppUITestLaunchState`)。
- **不修改** `Features/``Core/` 业务代码,不在 App 内注入 Mock 网络。
- DEBUG 构建下通过「我的 → 首页调试」批量覆盖首页路由。
## 环境变量
在 Scheme **Environment Variables**`xcodebuild` 中配置:
| 变量 | 必填 | 说明 |
| --- | --- | --- |
| `SUI_TEST_PHONE` | 是 | 登录手机号 |
| `SUI_TEST_PASSWORD` | 是 | 登录密码 |
| `SUI_TEST_ACCOUNT_NAME` | 否 | 多账号选择页要点的账号展示名 |
**本机快捷配置(推荐):**`suixinkanUITests/UITestCredentials.local.plist` 中配置 `SUI_TEST_PHONE``SUI_TEST_PASSWORD` 和可选的 `SUI_TEST_ACCOUNT_NAME`。该文件已加入 `.gitignore`,不会提交到 Git。环境变量优先级高于本地 plist。
缺少账号时,需要登录的用例会自动 `XCTSkip`
已登录用例默认复用 Keychain 会话,每个测试类只完整登录一次;仅 `LoginSmokeUITests` 会显式清空本地状态。
## 测试数据约定api-test
以下数据建议为 UI Test 专用账号预置,否则部分「列表 → 详情」用例会跳过:
- 至少 1 条门店订单、1 条核销订单
- 至少 1 条任务、项目、打卡点、押金订单(可选)
- 账号需有景区/门店上下文
## 运行命令
```bash
# 仅跑 UI Tests需先配置环境变量
xcodebuild test \
-workspace suixinkan.xcworkspace \
-scheme suixinkan \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:suixinkanUITests
# 带环境变量示例
SUI_TEST_PHONE=13800138000 \
SUI_TEST_PASSWORD='your-password' \
xcodebuild test \
-workspace suixinkan.xcworkspace \
-scheme suixinkan \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:suixinkanUITests
```
## 启动参数
UI Test 进程会自动传入:
- `-suixinkan-ui-tests`主开关App 侧跳过推送注册与排队 WebSocket
- `-suixinkan-ui-tests-reset-state`:冷启动清理 Keychain / UserDefaults
## 目录结构
```
suixinkanUITests/
Support/ # 启动配置、登录、导航、断言
Auth/ # 登录冒烟与完整登录流
Shell/ # 主 Tab
Home/ # 首页调试菜单全路由
Profile/ # 我的二级页
Orders/ # 订单深层流
Modules/ # 各业务模块交互
```
## 降级 / 跳过项
以下场景在模拟器 UI Test 中仅验证「可打开 / 可关闭」,不做完整硬件或上传闭环:
- 真机摄像头扫码内容识别
- 直播推流预览与音视频专项
- 高德地图完整选点
- OSS 真实文件上传(可选只验证选图 Sheet
## 新增页面时
1.`Support/UITestHomeMenuCatalog.swift` 补充调试菜单覆盖项(如有首页入口)。
2. 在对应 `Modules/*UITests.swift` 增加关键交互用例。
3. 优先使用已有 `accessibilityIdentifier`、导航栏标题和稳定文案定位,不修改 Features 视图。

View File

@ -0,0 +1,28 @@
//
// MainTabUITests.swift
// suixinkanUITests
//
import XCTest
final class MainTabUITests: AuthenticatedUITestCase {
/// Tab
func testAllMainTabsAreReachable() {
TabBarNavigator.select(.home, app: app)
XCTAssertTrue(
app.application.staticTexts["常用应用"].waitForExistence(timeout: 8)
|| app.application.staticTexts["请选择景区"].exists
)
TabBarNavigator.select(.orders, app: app)
NavigationAssertions.waitForNavigationTitle("订单", in: app)
NavigationAssertions.waitForStaticText("订单管理", in: app)
TabBarNavigator.select(.statistics, app: app)
NavigationAssertions.waitForNavigationTitle("数据", in: app)
TabBarNavigator.select(.profile, app: app)
NavigationAssertions.waitForNavigationTitle("个人信息", in: app)
NavigationAssertions.waitForStaticText("系统设置", in: app)
}
}

View File

@ -0,0 +1,42 @@
//
// AuthenticatedUITestCase.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
/// UI Test
class AuthenticatedUITestCase: XCTestCase {
private static var sharedApp: SuixinkanApp?
var app: SuixinkanApp!
override class func setUp() {
super.setUp()
guard sharedApp == nil else { return }
guard UITestLaunchConfiguration.credentials != nil else { return }
sharedApp = try? UITestAuthenticatedSession.launchLoggedIn()
}
override class func tearDown() {
sharedApp?.application.terminate()
sharedApp = nil
super.tearDown()
}
override func setUpWithError() throws {
try super.setUpWithError()
continueAfterFailure = false
guard let sharedApp = Self.sharedApp else {
throw XCTSkip("缺少 UI Test 账号或登录失败。")
}
app = sharedApp
app.application.activate()
app.waitForGlobalLoadingToDisappear()
}
}

View File

@ -0,0 +1,34 @@
//
// BackNavigator.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
///
enum BackNavigator {
///
static func goBack(app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) {
let navigationBar = app.application.navigationBars.element(boundBy: 0)
guard navigationBar.exists else {
XCTFail("当前页面没有可返回的导航栏。", file: file, line: line)
return
}
let backButton = navigationBar.buttons.element(boundBy: 0)
XCTAssertTrue(backButton.waitForExistence(timeout: 4), "未找到返回按钮。", file: file, line: line)
backButton.tap()
app.waitForGlobalLoadingToDisappear()
}
///
static func dismissCustomTopBarIfNeeded(app: SuixinkanApp) {
let backCandidates = app.application.buttons.matching(NSPredicate(format: "label CONTAINS 'chevron.left'"))
if backCandidates.count > 0 {
backCandidates.element(boundBy: 0).tap()
app.waitForGlobalLoadingToDisappear()
}
}
}

View File

@ -0,0 +1,42 @@
//
// DebugMenuNavigator.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
/// DEBUG
enum DebugMenuNavigator {
///
static func open(app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) {
TabBarNavigator.select(.profile, app: app)
let debugEntry = app.application.staticTexts["首页调试"]
XCTAssertTrue(debugEntry.waitForExistence(timeout: 8), "未找到首页调试入口。", file: file, line: line)
debugEntry.tap()
NavigationAssertions.waitForNavigationTitle("首页菜单调试", in: app, file: file, line: line)
}
///
static func tapMenu(
title: String,
app: SuixinkanApp,
file: StaticString = #filePath,
line: UInt = #line
) {
let cell = app.application.staticTexts[title]
XCTAssertTrue(cell.waitForExistence(timeout: 8), "调试菜单未找到:\(title)", file: file, line: line)
cell.tap()
app.waitForGlobalLoadingToDisappear()
}
///
static func returnToMenu(app: SuixinkanApp) {
if app.application.navigationBars["首页菜单调试"].exists {
return
}
BackNavigator.goBack(app: app)
NavigationAssertions.waitForNavigationTitle("首页菜单调试", in: app)
}
}

View File

@ -0,0 +1,104 @@
//
// LoginFlow.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
///
enum LoginFlow {
///
static func loginIfNeeded(
app: SuixinkanApp,
credentials: UITestCredentials,
file: StaticString = #filePath,
line: UInt = #line
) {
guard app.isOnLoginScreen else { return }
fillLoginForm(app: app, credentials: credentials)
submitLogin(app: app)
handleAccountSelectionIfNeeded(app: app, credentials: credentials)
app.waitForGlobalLoadingToDisappear()
XCTAssertTrue(
app.isOnMainTabs,
"登录后应进入主 Tab。",
file: file,
line: line
)
}
///
static func fillLoginForm(app: SuixinkanApp, credentials: UITestCredentials) {
let usernameField = app.application.textFields["login.username"]
XCTAssertTrue(usernameField.waitForExistence(timeout: 8))
usernameField.tap()
usernameField.clearAndType(credentials.phone)
let passwordField = preferredPasswordField(in: app.application)
XCTAssertTrue(passwordField.waitForExistence(timeout: 4))
passwordField.tap()
passwordField.clearAndType(credentials.password)
let privacyToggle = app.application.buttons["login.privacy"]
if privacyToggle.exists {
privacyToggle.tap()
}
}
///
static func submitLogin(app: SuixinkanApp) {
let submitButton = app.application.buttons["login.submit"]
XCTAssertTrue(submitButton.waitForExistence(timeout: 4))
submitButton.tap()
app.waitForGlobalLoadingToDisappear()
}
/// v9
static func handleAccountSelectionIfNeeded(
app: SuixinkanApp,
credentials: UITestCredentials
) {
let accountSelectionTitle = app.application.navigationBars["选择账号"]
guard accountSelectionTitle.waitForExistence(timeout: 6) else { return }
if let accountName = credentials.accountName {
let accountCell = app.application.staticTexts[accountName]
if accountCell.waitForExistence(timeout: 4) {
accountCell.tap()
}
}
let confirmButton = app.application.buttons["进入系统"]
XCTAssertTrue(confirmButton.waitForExistence(timeout: 4))
confirmButton.tap()
app.waitForGlobalLoadingToDisappear()
}
///
private static func preferredPasswordField(in app: XCUIApplication) -> XCUIElement {
let secureField = app.secureTextFields["login.password"]
if secureField.exists {
return secureField
}
return app.textFields["login.password"]
}
}
private extension XCUIElement {
///
func clearAndType(_ text: String) {
guard let currentValue = value as? String else {
typeText(text)
return
}
tap()
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
typeText(deleteString)
typeText(text)
}
}

View File

@ -0,0 +1,74 @@
//
// NavigationAssertions.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
///
enum NavigationAssertions {
///
@discardableResult
static func waitForNavigationTitle(
_ title: String,
in app: SuixinkanApp,
timeout: TimeInterval = 12,
file: StaticString = #filePath,
line: UInt = #line
) -> Bool {
let navigationBar = app.application.navigationBars[title]
let exists = navigationBar.waitForExistence(timeout: timeout)
XCTAssertTrue(exists, "未找到导航标题:\(title)", file: file, line: line)
return exists
}
///
@discardableResult
static func waitForAnyNavigationTitle(
_ titles: [String],
in app: SuixinkanApp,
timeout: TimeInterval = 12,
file: StaticString = #filePath,
line: UInt = #line
) -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
for title in titles {
if app.application.navigationBars[title].exists {
return title
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTFail("未找到任一导航标题:\(titles.joined(separator: " / "))", file: file, line: line)
return nil
}
///
@discardableResult
static func waitForStaticText(
_ text: String,
in app: SuixinkanApp,
timeout: TimeInterval = 8,
file: StaticString = #filePath,
line: UInt = #line
) -> Bool {
let label = app.application.staticTexts[text]
let exists = label.waitForExistence(timeout: timeout)
XCTAssertTrue(exists, "未找到文案:\(text)", file: file, line: line)
return exists
}
/// Loading
static func assertNotStuckOnLoading(
app: SuixinkanApp,
file: StaticString = #filePath,
line: UInt = #line
) {
let loading = app.application.staticTexts["加载中"]
XCTAssertFalse(loading.exists, "页面不应长期停留在全局 Loading。", file: file, line: line)
}
}

View File

@ -0,0 +1,78 @@
//
// SuixinkanUIApplication.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
/// XCUIApplication Loading
final class SuixinkanApp {
let application: XCUIApplication
private let resetState: Bool
/// UI Test
init(resetState: Bool = false) {
self.resetState = resetState
application = XCUIApplication()
UITestLaunchConfiguration.apply(to: application, resetState: resetState)
}
/// App
@discardableResult
func launch(waitForLoading: Bool = true) -> Self {
if application.state == .runningForeground {
application.activate()
} else {
application.launch()
}
dismissSystemAlertsIfNeeded()
if waitForLoading {
waitForGlobalLoadingToDisappear()
}
return self
}
/// Loading
func waitForGlobalLoadingToDisappear(timeout: TimeInterval = 45) {
let loading = application.staticTexts["加载中"]
guard loading.waitForExistence(timeout: 2) else { return }
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: loading)
_ = XCTWaiter.wait(for: [expectation], timeout: timeout)
}
/// 便
func dismissSystemAlertsIfNeeded() {
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let allowButtons = ["允许", "", "使用App时允许", "Allow", "Allow While Using App", "OK"]
for title in allowButtons {
let button = springboard.buttons[title]
if button.waitForExistence(timeout: 1) {
button.tap()
}
}
}
///
var isOnLoginScreen: Bool {
application.staticTexts["login.title"].exists
}
/// Tab
var isOnMainTabs: Bool {
application.buttons["首页"].waitForExistence(timeout: 1)
|| application.buttons["我的"].waitForExistence(timeout: 1)
}
/// Tab
func waitForInitialScreen(timeout: TimeInterval = 45) {
if application.staticTexts["login.title"].waitForExistence(timeout: 2) {
return
}
_ = application.buttons["首页"].waitForExistence(timeout: timeout)
|| application.buttons["我的"].waitForExistence(timeout: 2)
}
}

View File

@ -0,0 +1,26 @@
//
// TabBarNavigator.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
/// Tab
enum TabBarNavigator {
enum Tab: String, CaseIterable {
case home = "首页"
case orders = "订单"
case statistics = "数据"
case profile = "我的"
}
/// Tab
static func select(_ tab: Tab, app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) {
let button = app.application.buttons[tab.rawValue]
XCTAssertTrue(button.waitForExistence(timeout: 8), "未找到 Tab\(tab.rawValue)", file: file, line: line)
button.tap()
app.waitForGlobalLoadingToDisappear()
}
}

View File

@ -0,0 +1,24 @@
//
// UITestAuthenticatedSession.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
/// UI Test
enum UITestAuthenticatedSession {
/// App
static func launchLoggedIn(
resetState: Bool = false,
file: StaticString = #filePath,
line: UInt = #line
) throws -> SuixinkanApp {
let credentials = try UITestLaunchConfiguration.requireCredentials(file: file, line: line)
let app = SuixinkanApp(resetState: resetState).launch()
app.waitForInitialScreen()
LoginFlow.loginIfNeeded(app: app, credentials: credentials, file: file, line: line)
return app
}
}

View File

@ -0,0 +1,43 @@
//
// UITestExpectationVerifier.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
///
enum UITestExpectationVerifier {
///
static func verify(
_ expectation: UITestHomeMenuCatalog.Expectation,
app: SuixinkanApp,
file: StaticString = #filePath,
line: UInt = #line
) {
switch expectation {
case .navigationTitle(let title):
NavigationAssertions.waitForNavigationTitle(title, in: app, file: file, line: line)
case .anyNavigationTitle(let titles):
_ = NavigationAssertions.waitForAnyNavigationTitle(titles, in: app, file: file, line: line)
case .tab(let tab):
switch tab {
case .orders:
NavigationAssertions.waitForNavigationTitle("订单", in: app, file: file, line: line)
case .statistics:
NavigationAssertions.waitForNavigationTitle("数据", in: app, file: file, line: line)
case .home:
XCTAssertTrue(app.application.staticTexts["常用应用"].waitForExistence(timeout: 8) || app.application.staticTexts["请选择景区"].exists, file: file, line: line)
case .profile:
NavigationAssertions.waitForNavigationTitle("个人信息", in: app, file: file, line: line)
}
case .staticText(let text):
NavigationAssertions.waitForStaticText(text, in: app, file: file, line: line)
case .unsupportedPlaceholder(let title):
_ = NavigationAssertions.waitForAnyNavigationTitle([title], in: app, file: file, line: line)
}
NavigationAssertions.assertNotStuckOnLoading(app: app, file: file, line: line)
}
}

View File

@ -0,0 +1,73 @@
//
// UITestHomeMenuCatalog.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import Foundation
///
enum UITestHomeMenuCatalog {
///
enum Expectation: Equatable {
case navigationTitle(String)
case anyNavigationTitle([String])
case tab(TabBarNavigator.Tab)
case staticText(String)
case unsupportedPlaceholder(title: String)
}
///
struct Entry: Equatable {
let menuTitle: String
let expectation: Expectation
}
/// URI
static let entries: [Entry] = [
Entry(menuTitle: "空间设置", expectation: .navigationTitle("个人信息")),
Entry(menuTitle: "相册管理", expectation: .navigationTitle("相册管理")),
Entry(menuTitle: "相册预览上传", expectation: .navigationTitle("相册预览上传")),
Entry(menuTitle: "我的钱包", expectation: .navigationTitle("我的钱包")),
Entry(menuTitle: "立即收款", expectation: .navigationTitle("立即收款")),
Entry(menuTitle: "相册云盘", expectation: .navigationTitle("相册云盘")),
Entry(menuTitle: "传输管理", expectation: .navigationTitle("传输记录")),
Entry(menuTitle: "素材管理", expectation: .navigationTitle("素材管理")),
Entry(menuTitle: "上传素材", expectation: .navigationTitle("上传素材")),
Entry(menuTitle: "任务管理", expectation: .navigationTitle("任务管理")),
Entry(menuTitle: "发布任务", expectation: .navigationTitle("发布任务")),
Entry(menuTitle: "日程管理", expectation: .navigationTitle("日程管理")),
Entry(menuTitle: "消息中心", expectation: .navigationTitle("消息中心")),
Entry(menuTitle: "打卡点管理", expectation: .navigationTitle("打卡点管理")),
Entry(menuTitle: "样片管理", expectation: .navigationTitle("样片管理")),
Entry(menuTitle: "上传样片", expectation: .navigationTitle("上传样片")),
Entry(menuTitle: "直播管理", expectation: .navigationTitle("直播管理")),
Entry(menuTitle: "直播相册", expectation: .navigationTitle("直播相册")),
Entry(menuTitle: "景区选择", expectation: .navigationTitle("景区选择")),
Entry(menuTitle: "景区申请", expectation: .navigationTitle("景区申请")),
Entry(menuTitle: "权限申请", expectation: .navigationTitle("申请权限")),
Entry(menuTitle: "权限申请状态", expectation: .anyNavigationTitle(["权限申请", "权限申请状态"])),
Entry(menuTitle: "景区结算", expectation: .navigationTitle("景区结算")),
Entry(menuTitle: "结算审核", expectation: .navigationTitle("结算审核")),
Entry(menuTitle: "核销订单", expectation: .tab(.orders)),
Entry(menuTitle: "景区订单", expectation: .tab(.orders)),
Entry(menuTitle: "数据统计", expectation: .tab(.statistics)),
Entry(menuTitle: "项目管理", expectation: .navigationTitle("项目管理")),
Entry(menuTitle: "店铺项目管理", expectation: .navigationTitle("店铺项目管理")),
Entry(menuTitle: "位置上报", expectation: .navigationTitle("位置上报")),
Entry(menuTitle: "注册邀请", expectation: .navigationTitle("摄影师邀请")),
Entry(menuTitle: "邀请记录", expectation: .navigationTitle("邀请记录")),
Entry(menuTitle: "店铺管理", expectation: .staticText("全部功能")),
Entry(menuTitle: "飞行管理", expectation: .unsupportedPlaceholder(title: "飞行管理")),
Entry(menuTitle: "飞手认证", expectation: .navigationTitle("飞手认证")),
Entry(menuTitle: "飞控", expectation: .unsupportedPlaceholder(title: "飞控")),
Entry(menuTitle: "排队管理", expectation: .navigationTitle("排队管理")),
Entry(menuTitle: "运营区域", expectation: .navigationTitle("运营区域")),
Entry(menuTitle: "押金订单详情", expectation: .navigationTitle("押金订单")),
Entry(menuTitle: "提现审核", expectation: .navigationTitle("提现审核")),
Entry(menuTitle: "定位上报历史", expectation: .navigationTitle("位置上报历史")),
Entry(menuTitle: "更多功能", expectation: .staticText("全部功能")),
Entry(menuTitle: "系统设置", expectation: .navigationTitle("设置"))
]
}

View File

@ -0,0 +1,115 @@
//
// UITestLaunchConfiguration.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import XCTest
/// UI Test App `AppUITestLaunchState`
enum UITestLaunchConfiguration {
static let uiTestsArgument = "-suixinkan-ui-tests"
static let resetArgument = "-suixinkan-ui-tests-reset-state"
static let phoneEnvironmentKey = "SUI_TEST_PHONE"
static let passwordEnvironmentKey = "SUI_TEST_PASSWORD"
static let accountNameEnvironmentKey = "SUI_TEST_ACCOUNT_NAME"
/// XCUIApplication UI Test resetState
static func apply(to app: XCUIApplication, resetState: Bool = false) {
if !app.launchArguments.contains(uiTestsArgument) {
app.launchArguments.append(uiTestsArgument)
}
if resetState {
if !app.launchArguments.contains(resetArgument) {
app.launchArguments.append(resetArgument)
}
} else {
app.launchArguments.removeAll { $0 == resetArgument }
}
}
/// Scheme / xcodebuild plist
static var credentials: UITestCredentials? {
if let environmentCredentials = credentialsFromEnvironment() {
return environmentCredentials
}
return credentialsFromLocalPlist()
}
///
static func requireCredentials(file: StaticString = #filePath, line: UInt = #line) throws -> UITestCredentials {
guard let credentials else {
throw XCTSkip(
"缺少 UI Test 账号。请配置环境变量 \(phoneEnvironmentKey) / \(passwordEnvironmentKey),或在本机创建 UITestCredentials.local.plist。"
)
}
return credentials
}
/// Scheme / xcodebuild
private static func credentialsFromEnvironment() -> UITestCredentials? {
let environment = ProcessInfo.processInfo.environment
guard
let phone = environment[phoneEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines),
!phone.isEmpty,
let password = environment[passwordEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
else {
return nil
}
let accountName = environment[accountNameEnvironmentKey]?
.trimmingCharacters(in: .whitespacesAndNewlines)
return UITestCredentials(
phone: phone,
password: password,
accountName: accountName?.isEmpty == false ? accountName : nil
)
}
/// gitignore plist 便
private static func credentialsFromLocalPlist() -> UITestCredentials? {
let plistURL = localCredentialsURL
guard
FileManager.default.fileExists(atPath: plistURL.path),
let dictionary = NSDictionary(contentsOf: plistURL) as? [String: String]
else {
return nil
}
guard
let phone = dictionary[phoneEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines),
!phone.isEmpty,
let password = dictionary[passwordEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
else {
return nil
}
let accountName = dictionary[accountNameEnvironmentKey]?
.trimmingCharacters(in: .whitespacesAndNewlines)
return UITestCredentials(
phone: phone,
password: password,
accountName: accountName?.isEmpty == false ? accountName : nil
)
}
/// plist
private static var localCredentialsURL: URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("UITestCredentials.local.plist")
}
}
/// UI Test
struct UITestCredentials: Equatable {
let phone: String
let password: String
let accountName: String?
}