补充 Auth/Tasks/ScenicPermission/Live 单元测试,并完善 UI Test 登录与会话稳定性。

移除 LoginViewModel 硬编码账号,扩展登录流程测试覆盖;调整 ZLoginSmokeUITests 执行顺序与路由直达逻辑,避免清空 Keychain 影响其它用例。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 12:44:10 +08:00
parent aac8458b04
commit d27438b264
17 changed files with 1104 additions and 36 deletions

View File

@ -64,11 +64,15 @@
};
A00000022FE9000000000002 /* suixinkanTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanTests;
sourceTree = "<group>";
};
B00000022FEF000000000002 /* suixinkanUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanUITests;
sourceTree = "<group>";
};
@ -323,14 +327,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";

View File

@ -20,7 +20,7 @@ enum AppUITestRouteDriver {
|| AppUITestLaunchState.pendingProfileRouteRawValue != nil else { return }
didApply = true
try? await Task.sleep(nanoseconds: 600_000_000)
try? await Task.sleep(nanoseconds: 1_200_000_000)
if let menuTitle = AppUITestLaunchState.pendingMenuTitle {
openHomeMenu(title: menuTitle, appRouter: appRouter)
@ -34,10 +34,12 @@ enum AppUITestRouteDriver {
///
private static func openHomeMenu(title: String, appRouter: AppRouter) {
guard let item = HomeMenuRouter.debugAllMenuItems().first(where: { $0.title == title }) else {
let resolvedTitle = resolveMenuTitle(title)
guard let item = HomeMenuRouter.debugAllMenuItems().first(where: { $0.title == resolvedTitle }) else {
return
}
appRouter.reset()
let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
switch resolved {
case .tab(let tab):
@ -79,8 +81,19 @@ enum AppUITestRouteDriver {
}
guard let route else { return }
appRouter.reset()
appRouter.select(.profile)
appRouter.router(for: .profile).navigate(to: .profile(route))
}
/// UI Test
private static func resolveMenuTitle(_ title: String) -> String {
switch title {
case "系统设置":
return "设置中心"
default:
return title
}
}
}
#endif

View File

@ -70,8 +70,8 @@ enum LoginFlowError: LocalizedError {
@MainActor
/// ViewModel
final class LoginViewModel: ObservableObject {
@Published var username = "18651857230"
@Published var password = "zhifly666"
@Published var username = ""
@Published var password = ""
@Published var privacyChecked = false
@Published var isLoading = false
@Published var isSelectingAccount = false

View File

@ -0,0 +1,95 @@
//
// AuthModelsTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/26.
//
import XCTest
@testable import suixinkan
/// Auth v9
final class AuthModelsTests: XCTestCase {
/// fixture
func testV9AuthResponseDecodesMultiAccountFixture() throws {
let response = try TestFixture.payload(V9AuthResponse.self, named: "v9_login_multi_success")
XCTAssertEqual(response.token, "person-temp-token")
XCTAssertEqual(response.scenicUsers.count, 1)
XCTAssertEqual(response.storeUsers.count, 1)
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
}
/// 使 ss_user_id ID
func testV9ScenicUserBusinessUserIdPrefersSsUserId() throws {
let json = """
{
"account_type": "scenic_user",
"id": 1,
"user_id": 2,
"scenic_user_id": 3,
"ss_user_id": 101,
"username": "scenic_admin",
"real_name": "",
"nickname": "",
"phone": "13800138000",
"scenic_id": 10,
"scenic_name": "",
"is_current": false
}
""".data(using: .utf8)!
let user = try JSONDecoder().decode(V9ScenicUser.self, from: json)
XCTAssertEqual(user.businessUserId, 101)
XCTAssertEqual(user.toAccountSwitchAccount().accountType, V9ScenicUser.accountTypeValue)
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(ssUserId: 101))
}
/// 使 store_user_id ID
func testV9StoreUserBusinessUserIdPrefersStoreUserId() throws {
let json = """
{
"account_type": "store_user",
"id": 1,
"user_id": 2,
"store_user_id": 201,
"username": "store_admin",
"user_name": "store_admin",
"real_name": "",
"phone": "13800138000",
"avatar": "",
"scenic_id": 10,
"scenic_name": "",
"store_id": 20,
"store_name": "",
"is_current": false
}
""".data(using: .utf8)!
let user = try JSONDecoder().decode(V9StoreUser.self, from: json)
XCTAssertEqual(user.businessUserId, 201)
XCTAssertEqual(user.toAccountSwitchAccount().accountType, V9StoreUser.accountTypeValue)
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
}
///
func testV9AuthResponseDerivesScopesFromAccounts() throws {
let response = try TestFixture.payload(V9AuthResponse.self, named: "v9_login_multi_success")
XCTAssertEqual(response.scenicScopes.map(\.id), [10])
XCTAssertEqual(response.storeScopes.map(\.id), [20])
XCTAssertEqual(response.primaryProfile?.displayName, "示例门店")
}
/// LoginRequest
func testLoginRequestEncodesExpectedDefaults() throws {
let request = LoginRequest(username: "18651857230", password: "secret123")
let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(request)) as? [String: Any]
XCTAssertEqual(json?["username"] as? String, "18651857230")
XCTAssertEqual(json?["password"] as? String, "secret123")
XCTAssertEqual(json?["type"] as? Int, 1)
XCTAssertEqual(json?["mobile"] as? String, "")
XCTAssertEqual(json?["code"] as? String, "")
}
}

View File

@ -0,0 +1,24 @@
{
"code": 100000,
"msg": "success",
"data": {
"token": "person-temp-token",
"scenic_users": [
{
"account_type": "scenic_user",
"id": 101,
"user_id": 101,
"scenic_user_id": 101,
"ss_user_id": 101,
"username": "scenic_admin",
"real_name": "张三",
"nickname": "张三",
"phone": "13800138000",
"scenic_id": 10,
"scenic_name": "示例景区",
"is_current": false
}
],
"store_users": []
}
}

View File

@ -0,0 +1,24 @@
{
"code": 100000,
"msg": "success",
"data": {
"token": "business-token-scenic",
"scenic_users": [
{
"account_type": "scenic_user",
"id": 101,
"user_id": 101,
"scenic_user_id": 101,
"ss_user_id": 101,
"username": "scenic_admin",
"real_name": "张三",
"nickname": "张三",
"phone": "13800138000",
"scenic_id": 10,
"scenic_name": "示例景区",
"is_current": true
}
],
"store_users": []
}
}

View File

@ -293,6 +293,90 @@ final class LiveViewModelTests: XCTestCase {
await XCTAssertThrowsErrorAsync(try await missingURLViewModel.prepare())
XCTAssertEqual(missingURLViewModel.errorMessage, LivePushReadinessError.missingPushURL.localizedDescription)
}
/// RTMP/FLV HTTP
func testLivePlaybackResolverCandidatePriority() {
let live = LiveEntity(
playUrl: "rtmp://pull.example.com/live/1",
pullUrl: "https://cdn.example.com/live/1.flv",
hlsUrl: "https://cdn.example.com/live/1.m3u8",
liveUrl: "https://cdn.example.com/live/1.mp4"
)
XCTAssertEqual(
LivePlaybackURLResolver.playableURL(from: live)?.absoluteString,
"https://cdn.example.com/live/1.m3u8"
)
XCTAssertEqual(
LivePlaybackURLResolver.playableURL(from: ["rtmp://a", "https://cdn.example.com/fallback.mp4"])?.absoluteString,
"https://cdn.example.com/fallback.mp4"
)
}
/// LiveEntity reload
func testLivePlaybackViewModelLoadLiveEntityAndReload() {
let live = LiveEntity(hlsUrl: "https://cdn.example.com/live/1.m3u8")
let viewModel = LivePlaybackViewModel(live: live)
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
XCTAssertNotNil(viewModel.player)
viewModel.play()
XCTAssertTrue(viewModel.isPlaying)
viewModel.reload()
XCTAssertFalse(viewModel.isPlaying)
XCTAssertNotNil(viewModel.player)
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
}
///
func testLivePushReadinessInvalidPushURL() {
let viewModel = LivePushReadinessViewModel(
permissionProvider: LivePermissionMock(camera: .granted, microphone: .granted),
networkMonitor: LiveNetworkMock(state: .wifi),
adapter: LivePushAdapterMock(available: true)
)
viewModel.configure(pushURL: "not-a-valid-url")
XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.invalidPushURL.localizedDescription)
}
/// SDK preparestartstop dispose
func testLivePushReadinessStartStopAndDispose() async throws {
let permissions = LivePermissionMock(camera: .granted, microphone: .granted)
let network = LiveNetworkMock(state: .wifi)
let adapter = LivePushAdapterMock(available: true)
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
viewModel.startMonitoring()
await viewModel.refreshPermissions()
try viewModel.runDiagnostics()
try await viewModel.prepare()
try await viewModel.startPush()
XCTAssertTrue(adapter.prepareCalled)
XCTAssertTrue(adapter.startCalled)
XCTAssertTrue(viewModel.running)
XCTAssertTrue(viewModel.prepared)
await viewModel.stopPush()
XCTAssertTrue(adapter.stopCalled)
XCTAssertFalse(viewModel.running)
await viewModel.dispose()
XCTAssertFalse(viewModel.prepared)
}
///
func testLivePushStateDisplayText() {
XCTAssertEqual(LivePushPermissionState.granted.displayText, "已授权")
XCTAssertEqual(LivePushNetworkState.wifi.displayText, "Wi-Fi")
XCTAssertEqual(LivePushNetworkState.unavailable.displayText, "网络不可用")
}
}
@MainActor

View File

@ -9,7 +9,7 @@ import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
/// ViewModel
final class LoginViewModelTests: XCTestCase {
/// +86 11
func testNormalizeUsernameCountryCodeRemovesChinaPrefix() {
@ -23,6 +23,47 @@ final class LoginViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.isValidPhone)
}
///
func testCanSubmitRequiresValidPhoneAndNonEmptyPassword() {
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = " secret123 "
XCTAssertTrue(viewModel.canSubmit)
XCTAssertEqual(viewModel.trimmedPassword, "secret123")
}
///
func testCanSubmitIsFalseForInvalidPhone() {
let viewModel = LoginViewModel()
viewModel.username = "12345"
viewModel.password = "secret123"
XCTAssertFalse(viewModel.canSubmit)
XCTAssertFalse(viewModel.isValidPhone)
}
///
func testValidateForLoginReturnsExpectedErrorsInOrder() {
let viewModel = LoginViewModel()
viewModel.username = "12345"
viewModel.password = "secret123"
viewModel.privacyChecked = true
XCTAssertEqual(viewModel.validateForLogin(), .invalidPhone)
viewModel.username = "18651857230"
viewModel.password = " "
XCTAssertEqual(viewModel.validateForLogin(), .emptyPassword)
viewModel.password = "secret123"
viewModel.privacyChecked = false
XCTAssertEqual(viewModel.validateForLogin(), .privacyUnchecked)
viewModel.privacyChecked = true
XCTAssertNil(viewModel.validateForLogin())
}
///
func testValidateForLoginRequiresPrivacyAgreement() {
let viewModel = LoginViewModel()
@ -49,4 +90,319 @@ final class LoginViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.privacyChecked)
XCTAssertEqual(viewModel.password, "manual-password")
}
///
func testApplyPreferencesDoesNotOverwriteExistingUsername() {
let viewModel = LoginViewModel()
viewModel.username = "13900139000"
viewModel.applyPreferences(
LoginPreferences(
lastUsername: "18651857230",
privacyAgreementAccepted: false
)
)
XCTAssertEqual(viewModel.username, "13900139000")
XCTAssertFalse(viewModel.privacyChecked)
}
///
func testAcceptAgreementChecksPrivacyAndDismissesSheet() {
let viewModel = LoginViewModel()
viewModel.showsAgreementSheet = true
viewModel.acceptAgreement()
XCTAssertTrue(viewModel.privacyChecked)
XCTAssertFalse(viewModel.showsAgreementSheet)
}
/// set-user completed
func testLoginWithSingleAccountAutoCompletesSetUser() async throws {
let session = LoginRecordingURLSession(responses: [
try TestFixture.data(named: "v9_login_single_scenic_success"),
try TestFixture.data(named: "v9_set_scenic_user_success")
])
let api = AuthAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = "secret123"
viewModel.privacyChecked = true
let resolution = try await viewModel.login(authAPI: api)
guard case .completed(let response) = resolution else {
return XCTFail("单账号登录应直接完成")
}
XCTAssertEqual(response.token, "business-token-scenic")
XCTAssertFalse(viewModel.isLoading)
XCTAssertNil(viewModel.pendingAccountSelection)
XCTAssertEqual(session.requests.map { $0.url?.path }, [
"/api/app/v9/login",
"/api/app/v9/set-user"
])
let loginBody = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[0].httpBody)) as? [String: Any])
XCTAssertEqual(loginBody["username"] as? String, "18651857230")
let setUserBody = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[1].httpBody)) as? [String: Any])
XCTAssertEqual(setUserBody["ss_user_id"] as? Int, 101)
XCTAssertNil(setUserBody["store_user_id"] as? Int)
}
/// needsAccountSelection
func testLoginWithMultipleAccountsNeedsSelection() async throws {
let session = LoginRecordingURLSession(responses: [
try TestFixture.data(named: "v9_login_multi_success")
])
let api = AuthAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = "secret123"
viewModel.privacyChecked = true
let resolution = try await viewModel.login(authAPI: api)
guard case .needsAccountSelection(let payload) = resolution else {
return XCTFail("多账号登录应进入账号选择")
}
XCTAssertEqual(payload.tempToken, "person-temp-token")
XCTAssertEqual(payload.accounts.map(\.businessUserId), [101, 201])
XCTAssertEqual(viewModel.pendingAccountSelection, payload)
XCTAssertEqual(session.requests.count, 1)
XCTAssertEqual(session.requests.first?.url?.path, "/api/app/v9/login")
}
/// noAvailableAccount
func testLoginThrowsWhenNoAvailableAccounts() async throws {
let session = LoginRecordingURLSession(responses: [
"""
{
"code": 100000,
"msg": "success",
"data": {
"token": "person-temp-token",
"scenic_users": [],
"store_users": []
}
}
""".data(using: .utf8)!
])
let api = AuthAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = "secret123"
viewModel.privacyChecked = true
do {
_ = try await viewModel.login(authAPI: api)
XCTFail("没有可用账号时应抛出错误")
} catch let error as LoginFlowError {
guard case .noAvailableAccount = error else {
return XCTFail("期望 noAvailableAccount实际为 \(error)")
}
}
XCTAssertFalse(viewModel.isLoading)
}
/// token missingToken
func testLoginThrowsWhenTokenMissing() async throws {
let session = LoginRecordingURLSession(responses: [
"""
{
"code": 100000,
"msg": "success",
"data": {
"token": "",
"scenic_users": [
{
"account_type": "scenic_user",
"id": 101,
"user_id": 101,
"scenic_user_id": 101,
"ss_user_id": 101,
"username": "scenic_admin",
"real_name": "",
"nickname": "",
"phone": "13800138000",
"scenic_id": 10,
"scenic_name": "",
"is_current": false
}
],
"store_users": []
}
}
""".data(using: .utf8)!
])
let api = AuthAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = "secret123"
viewModel.privacyChecked = true
do {
_ = try await viewModel.login(authAPI: api)
XCTFail("缺少 token 时应抛出错误")
} catch let error as LoginFlowError {
guard case .missingToken = error else {
return XCTFail("期望 missingToken实际为 \(error)")
}
}
}
/// CancellationError
func testLoginWhileLoadingThrowsCancellationError() async throws {
let session = LoginRecordingURLSession(responses: [
try TestFixture.data(named: "v9_login_single_scenic_success"),
try TestFixture.data(named: "v9_set_scenic_user_success")
])
let api = AuthAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = "secret123"
viewModel.privacyChecked = true
viewModel.isLoading = true
do {
_ = try await viewModel.login(authAPI: api)
XCTFail("加载中重复登录应被取消")
} catch {
XCTAssertTrue(error is CancellationError)
}
}
/// token set-user
func testSelectAccountCompletesWithFinalToken() async throws {
let session = LoginRecordingURLSession(responses: [
try TestFixture.data(named: "v9_set_store_user_success")
])
let api = AuthAPI(client: APIClient(session: session))
let account = AccountSwitchAccount(
accountType: V9StoreUser.accountTypeValue,
businessUserId: 201,
title: "示例门店",
subtitle: "示例景区",
phone: "13800138000",
avatar: "",
scenicName: "示例景区",
storeId: 20,
storeName: "示例门店",
scenicId: 10,
isCurrent: false
)
let viewModel = LoginViewModel()
viewModel.pendingAccountSelection = AccountSelectionPayload(
tempToken: "person-temp-token",
accounts: [account]
)
let response = try await viewModel.selectAccount(account, authAPI: api)
XCTAssertEqual(response.token, "business-token-store")
XCTAssertNil(viewModel.pendingAccountSelection)
XCTAssertFalse(viewModel.isSelectingAccount)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.url?.path, "/api/app/v9/set-user")
XCTAssertEqual(request.value(forHTTPHeaderField: "token"), "person-temp-token")
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any])
XCTAssertEqual(json["store_user_id"] as? Int, 201)
}
/// selectAccount missingToken
func testSelectAccountThrowsWhenPendingSelectionMissing() async {
let api = AuthAPI(client: APIClient(session: LoginRecordingURLSession(responses: [])))
let account = AccountSwitchAccount(
accountType: V9StoreUser.accountTypeValue,
businessUserId: 201,
title: "示例门店",
subtitle: "示例景区",
phone: "13800138000",
avatar: "",
scenicName: "示例景区",
storeId: 20,
storeName: "示例门店",
scenicId: 10,
isCurrent: false
)
let viewModel = LoginViewModel()
do {
_ = try await viewModel.selectAccount(account, authAPI: api)
XCTFail("缺少待选账号时应抛出错误")
} catch let error as LoginFlowError {
guard case .missingToken = error else {
return XCTFail("期望 missingToken实际为 \(error)")
}
} catch {
XCTFail("意外错误: \(error)")
}
}
/// ID selectAccount invalidAccount
func testSelectAccountThrowsWhenAccountInvalid() async {
let api = AuthAPI(client: APIClient(session: LoginRecordingURLSession(responses: [])))
let account = AccountSwitchAccount(
accountType: V9StoreUser.accountTypeValue,
businessUserId: 0,
title: "无效账号",
subtitle: "",
phone: "",
avatar: "",
scenicName: "",
storeId: nil,
storeName: "",
scenicId: nil,
isCurrent: false
)
let viewModel = LoginViewModel()
viewModel.pendingAccountSelection = AccountSelectionPayload(
tempToken: "person-temp-token",
accounts: [account]
)
do {
_ = try await viewModel.selectAccount(account, authAPI: api)
XCTFail("无效账号 ID 时应抛出错误")
} catch let error as LoginFlowError {
guard case .invalidAccount = error else {
return XCTFail("期望 invalidAccount实际为 \(error)")
}
} catch {
XCTFail("意外错误: \(error)")
}
}
///
func testClearPendingAccountSelection() {
let viewModel = LoginViewModel()
viewModel.pendingAccountSelection = AccountSelectionPayload(
tempToken: "person-temp-token",
accounts: []
)
viewModel.clearPendingAccountSelection()
XCTAssertNil(viewModel.pendingAccountSelection)
}
}
/// URLSession
private final class LoginRecordingURLSession: URLSessionProtocol {
private var responses: [Data]
private(set) var requests: [URLRequest] = []
///
init(responses: [Data]) {
self.responses = responses
}
///
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requests.append(request)
let data = responses.isEmpty ? Data() : responses.removeFirst()
return (
data,
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
)
}
}

View File

@ -200,6 +200,237 @@ final class ScenicPermissionViewModelTests: XCTestCase {
XCTAssertNil(api.submittedScenicRequest)
XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription)
}
///
func testPermissionApplyBootstrapWithRejectedPending() {
let pending = RoleApplyPendingResponse(
id: 9,
code: "AP-001",
roleId: 2,
roleName: "店长",
scenicList: [RoleApplyScenicItem(id: 3, name: "驳回景区")],
status: 3
)
let viewModel = PermissionApplyViewModel(initialPending: pending)
viewModel.bootstrap(rolePermissions: [
RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师", notes: "拍摄"), scenic: []),
RolePermissionResponse(role: RoleInfo(id: 2, name: "店长", notes: "门店管理"), scenic: [])
])
XCTAssertEqual(viewModel.selectedRoleId, 2)
XCTAssertEqual(viewModel.selectedScenicIds, [3])
XCTAssertEqual(viewModel.selectedRoleName, "店长")
XCTAssertEqual(viewModel.selectedRoleNotes, "门店管理")
}
///
func testPermissionApplySelectedScenicSummary() {
let viewModel = PermissionApplyViewModel()
viewModel.scenicOptions = [
PermissionScenicOption(id: 1, name: "景区A", selected: false, disabled: false),
PermissionScenicOption(id: 2, name: "景区B", selected: false, disabled: false)
]
viewModel.toggleScenic(id: 1)
viewModel.toggleScenic(id: 2)
XCTAssertEqual(viewModel.selectedScenicSummary, "已选择 2 个景区")
viewModel.toggleScenic(id: 2)
XCTAssertEqual(viewModel.selectedScenicSummary, "景区A")
}
///
func testPermissionApplyExistingRolesAndScenicLoadFailure() async {
let api = ScenicPermissionServingFake()
api.scenicListError = TestError.sample
let viewModel = PermissionApplyViewModel()
viewModel.bootstrap(rolePermissions: [
RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [ScenicInfo(id: 1, name: "已有景区")])
])
viewModel.selectRole(id: 1)
await viewModel.loadScenicListIfNeeded(api: api)
XCTAssertEqual(viewModel.existingRoleInfos.first?.scenics.map(\.id), [1])
XCTAssertTrue(viewModel.scenicLoadFailed)
XCTAssertEqual(viewModel.scenicLoadFailureReason, TestError.sample.localizedDescription)
XCTAssertEqual(viewModel.message, "景区列表加载失败,请重试")
XCTAssertTrue(viewModel.selectedScenicIds.isEmpty)
}
///
func testPermissionApplyStatusLoadByApplyCode() async {
let api = ScenicPermissionServingFake()
api.roleApplies = [
RoleApplyPendingResponse(id: 1, code: "AP-001", roleId: 1, roleName: "摄影师", status: 2),
RoleApplyPendingResponse(id: 2, code: "AP-002", roleId: 2, roleName: "店长", status: 1)
]
let viewModel = PermissionApplyStatusViewModel()
await viewModel.load(api: api, applyCode: " AP-002 ")
XCTAssertEqual(viewModel.pending?.code, "AP-002")
XCTAssertFalse(viewModel.loadFailed)
}
///
func testPermissionApplyStatusLoadPendingOrRejectedWithoutCode() async {
let api = ScenicPermissionServingFake()
api.roleApplies = [
RoleApplyPendingResponse(id: 1, code: "AP-001", roleId: 1, roleName: "摄影师", status: 2),
RoleApplyPendingResponse(id: 2, code: "AP-002", roleId: 2, roleName: "店长", status: 3)
]
let viewModel = PermissionApplyStatusViewModel()
await viewModel.load(api: api, applyCode: nil)
XCTAssertEqual(viewModel.pending?.status, 3)
}
///
func testPermissionApplyStatusLoadFailure() async {
let api = ScenicPermissionServingFake()
api.roleApplyAllError = TestError.sample
let viewModel = PermissionApplyStatusViewModel()
await viewModel.load(api: api, applyCode: "AP-001")
XCTAssertNil(viewModel.pending)
XCTAssertTrue(viewModel.loadFailed)
XCTAssertEqual(viewModel.loadFailureReason, TestError.sample.localizedDescription)
}
///
func testScenicApplicationReadOnlyWhenPendingReview() async {
let api = ScenicPermissionServingFake()
api.areas = [ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")])]
api.scenicPendings = ScenicApplicationPendingsResponse(items: [
ScenicApplicationPendingResponse(
id: 1,
code: "A001",
scenicId: 9,
scenicName: "审核中景区",
scenicImages: "https://a.com/1.jpg",
scenicProvince: "浙江",
scenicCity: "杭州",
coopType: 1,
remark: nil,
status: 1
)
])
let viewModel = ScenicApplicationViewModel()
await viewModel.loadInitial(api: api)
XCTAssertTrue(viewModel.isReadOnly)
XCTAssertTrue(viewModel.isSubmitLocked)
XCTAssertFalse(viewModel.canSubmit)
}
///
func testScenicApplicationOnProvinceChangeClearsInvalidCity() {
let viewModel = ScenicApplicationViewModel()
viewModel.provinces = [
ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")]),
ScenicAreaNode(id: "js", name: "江苏", children: [ScenicAreaNode(id: "nj", name: "南京")])
]
viewModel.selectedProvince = "浙江"
viewModel.onProvinceChange()
viewModel.selectedCity = "杭州"
viewModel.selectedProvince = "江苏"
viewModel.onProvinceChange()
XCTAssertEqual(viewModel.cities.map(\.name), ["南京"])
XCTAssertEqual(viewModel.selectedCity, "")
}
///
func testScenicApplicationRemoteImageParsingAndRemoval() {
let viewModel = ScenicApplicationViewModel()
viewModel.imageURLs = "https://a.com/1.jpg,https://a.com/2.jpg\nhttps://a.com/3.jpg"
XCTAssertEqual(viewModel.remoteImageURLs, [
"https://a.com/1.jpg",
"https://a.com/2.jpg",
"https://a.com/3.jpg"
])
XCTAssertEqual(viewModel.uploadPlaceholderCount, 3)
viewModel.removeRemoteImage("https://a.com/2.jpg")
XCTAssertEqual(viewModel.remoteImageURLs, ["https://a.com/1.jpg", "https://a.com/3.jpg"])
}
/// 20
func testScenicApplicationMaxImageLimit() {
let viewModel = ScenicApplicationViewModel()
viewModel.imageURLs = Array(repeating: "https://a.com/x.jpg", count: 19).joined(separator: "\n")
viewModel.addLocalImage(data: Data([1]), fileName: "local.jpg")
XCTAssertEqual(viewModel.uploadPlaceholderCount, 20)
viewModel.addLocalImage(data: Data([2]), fileName: "overflow.jpg")
XCTAssertEqual(viewModel.uploadPlaceholderCount, 20)
XCTAssertEqual(viewModel.message, "最多上传20张景区图片")
}
/// URL
func testScenicUploadPlaceholderPolicy() {
XCTAssertEqual(
ScenicUploadPlaceholderPolicy.fileName(from: "https://cdn.example.com/scenic/a.jpg?x=1#preview", fallbackPrefix: "scenic"),
"a.jpg"
)
XCTAssertEqual(ScenicUploadPlaceholderPolicy.fileType(from: "clip.mp4"), "video")
XCTAssertEqual(ScenicUploadPlaceholderPolicy.fileType(from: "doc.pdf"), "file")
XCTAssertEqual(ScenicUploadPlaceholderPolicy.fileType(from: "photo.heic"), "image")
}
/// OSS
func testScenicApplicationSubmitWithRemoteImagesOnly() async {
let api = ScenicPermissionServingFake()
let uploader = OSSUploadServingFake()
let viewModel = ScenicApplicationViewModel()
viewModel.scenicName = "新景区"
viewModel.imageURLs = "https://a.com/1.jpg"
viewModel.selectedProvince = "浙江"
viewModel.selectedCity = "杭州"
viewModel.agreed = true
await viewModel.submit(api: api, uploader: uploader)
XCTAssertTrue(uploader.uploadedFileNames.isEmpty)
XCTAssertEqual(api.submittedScenicRequest?.scenicImages, ["https://a.com/1.jpg"])
XCTAssertEqual(api.uploadPlaceholders.count, 1)
XCTAssertEqual(viewModel.message, "提交成功")
}
///
func testScenicApplicationClearPendingWhenNoMatchingStatus() async {
let api = ScenicPermissionServingFake()
api.areas = [ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")])]
api.scenicPendings = ScenicApplicationPendingsResponse(items: [
ScenicApplicationPendingResponse(
id: 1,
code: "A001",
scenicId: 9,
scenicName: "已通过",
scenicImages: "",
scenicProvince: "浙江",
scenicCity: "杭州",
coopType: 1,
remark: nil,
status: 2
)
])
let viewModel = ScenicApplicationViewModel()
viewModel.scenicName = "草稿"
await viewModel.loadInitial(api: api)
XCTAssertNil(viewModel.pending)
XCTAssertEqual(viewModel.scenicName, "")
XCTAssertFalse(viewModel.agreed)
}
}
@MainActor
@ -214,6 +445,7 @@ private final class ScenicPermissionServingFake: ScenicPermissionServing {
var submittedScenicRequest: ScenicApplicationSubmitRequest?
var uploadPlaceholders: [ScenicApplicationUploadPlaceholder] = []
var roleApplies: [RoleApplyPendingResponse] = []
var roleApplyAllError: Error?
var submittedRoleId: Int?
var submittedScenicIds: [Int] = []
var roleApplySubmitError: Error?
@ -248,7 +480,8 @@ private final class ScenicPermissionServingFake: ScenicPermissionServing {
///
func roleApplyAll() async throws -> [RoleApplyPendingResponse] {
roleApplies
if let roleApplyAllError { throw roleApplyAllError }
return roleApplies
}
///

View File

@ -169,6 +169,186 @@ final class TaskViewModelTests: XCTestCase {
XCTAssertEqual(api.cloudFileCalls.map(\.page), [1, 2, 1])
}
///
func testTaskCreateLoadAvailableOrders() async throws {
let api = TaskMockService()
api.availableOrders = try TestFixture.payload([AvailableOrderResponse].self, named: "available_order_success")
let viewModel = TaskCreateViewModel()
await viewModel.loadAvailableOrders(api: api, scenicId: nil)
XCTAssertTrue(viewModel.availableOrders.isEmpty)
XCTAssertTrue(api.availableOrderScenicIds.isEmpty)
await viewModel.loadAvailableOrders(api: api, scenicId: 88)
XCTAssertEqual(api.availableOrderScenicIds, [88])
XCTAssertEqual(viewModel.availableOrders.count, 1)
XCTAssertFalse(viewModel.isLoadingOrders)
}
///
func testTaskCreateLoadAvailableOrdersFailure() async {
let api = TaskMockService()
api.availableOrderError = APIError.networkFailed("订单加载失败")
let viewModel = TaskCreateViewModel()
await viewModel.loadAvailableOrders(api: api, scenicId: 88)
XCTAssertTrue(viewModel.availableOrders.isEmpty)
XCTAssertEqual(viewModel.errorMessage, "网络请求失败:订单加载失败")
}
///
func testTaskCreateCanSubmitBlocksWhileUploading() {
let viewModel = TaskCreateViewModel()
viewModel.taskName = "任务"
viewModel.remark = "备注"
viewModel.selectedLocalFiles = [
TaskLocalUploadItem(
id: UUID(),
data: Data([1]),
fileName: "local.jpg",
fileType: 2,
remark: "",
uploadedURL: nil,
progress: 50,
errorMessage: nil
)
]
XCTAssertFalse(viewModel.canSubmit)
XCTAssertTrue(viewModel.selectedLocalFiles.first?.isUploading == true)
XCTAssertEqual(viewModel.selectedLocalFiles.first?.statusText, "上传中 50%")
}
///
func testTaskCreateMergeAndRemoveAttachments() {
let viewModel = TaskCreateViewModel()
let file = TaskCloudSelectionItem(id: 501, fileName: "cloud.jpg", fileType: 2, remark: "备注")
viewModel.mergeCloudFiles([file, file])
viewModel.addLocalFile(data: Data([1]), fileName: "photo.jpg")
guard let localID = viewModel.selectedLocalFiles.first?.id else {
return XCTFail("应存在本地附件")
}
XCTAssertEqual(viewModel.selectedCloudFiles.count, 1)
viewModel.removeCloudFile(id: 501)
viewModel.removeLocalFile(id: localID)
XCTAssertTrue(viewModel.selectedCloudFiles.isEmpty)
XCTAssertTrue(viewModel.selectedLocalFiles.isEmpty)
}
///
func testTaskCreateSubmitIncludesSelectedOrderAndUrgentHour() async throws {
let api = TaskMockService()
let upload = TaskMockUploadService()
let viewModel = TaskCreateViewModel()
viewModel.taskName = " 任务 "
viewModel.remark = "备注"
viewModel.urgentHourText = " 12 "
viewModel.selectOrder(taskAvailableOrder(orderNumber: "ORD-1001"))
let success = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
XCTAssertTrue(success)
let request = try XCTUnwrap(api.addTaskRequests.first)
XCTAssertEqual(request.name, "任务")
XCTAssertEqual(request.orderNumber, "ORD-1001")
XCTAssertEqual(request.urgentHour, 12)
}
///
func testTaskCreateValidationCoversTaskNameAndUrgentHour() async throws {
let api = TaskMockService()
let upload = TaskMockUploadService()
let viewModel = TaskCreateViewModel()
viewModel.remark = "备注"
let missingName = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
XCTAssertFalse(missingName)
XCTAssertEqual(viewModel.errorMessage, "请输入任务名称")
viewModel.taskName = "任务"
viewModel.urgentHourText = "abc"
let invalidHour = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
XCTAssertFalse(invalidHour)
XCTAssertEqual(viewModel.errorMessage, "请输入正确的紧急小时")
}
///
func testTaskCreateDetectsVideoFileType() {
let viewModel = TaskCreateViewModel()
viewModel.addLocalFile(data: Data([1]), fileName: "clip.mp4")
XCTAssertEqual(viewModel.selectedLocalFiles.first?.fileType, 1)
}
///
func testCloudSelectionPopToFolderSearchAndDeselect() async throws {
let api = TaskMockService()
let firstPage = try TestFixture.payload(ListPayload<CloudDriveFile>.self, named: "cloud_file_list_success")
api.cloudFileResponses = [firstPage, firstPage]
let viewModel = TaskCloudFileSelectionViewModel()
let folder = firstPage.list[0]
let file = firstPage.list[1]
try await viewModel.enterFolder(folder, api: api)
viewModel.searchText = " photo "
viewModel.selectedFilter = .image
try await viewModel.reload(api: api)
let call = try XCTUnwrap(api.cloudFileCalls.last)
XCTAssertEqual(call.parentFolderId, folder.id)
XCTAssertEqual(call.name, "photo")
XCTAssertEqual(call.type, TaskCloudFileFilter.image.rawValue)
viewModel.toggleSelection(file)
XCTAssertTrue(viewModel.isSelected(file))
viewModel.toggleSelection(file)
XCTAssertFalse(viewModel.isSelected(file))
try await viewModel.popToFolder(at: 0, api: api)
XCTAssertEqual(viewModel.currentFolderId, 0)
}
///
func testCloudSelectionReloadFailureClearsState() async {
let api = TaskMockService()
api.cloudFileError = APIError.networkFailed("云盘失败")
let viewModel = TaskCloudFileSelectionViewModel()
viewModel.files = [CloudDriveFile(id: 1, name: "占位")]
viewModel.total = 5
viewModel.page = 2
do {
try await viewModel.reload(api: api)
XCTFail("加载失败时应抛出错误")
} catch APIError.networkFailed(let message) {
XCTAssertEqual(message, "云盘失败")
} catch {
XCTFail("意外错误: \(error)")
}
XCTAssertTrue(viewModel.files.isEmpty)
XCTAssertEqual(viewModel.total, 0)
XCTAssertEqual(viewModel.page, 1)
XCTAssertEqual(viewModel.errorMessage, "网络请求失败:云盘失败")
}
///
func testCloudSelectionLoadMoreStopsWhenNoMore() async throws {
let api = TaskMockService()
let page = try TestFixture.payload(ListPayload<CloudDriveFile>.self, named: "cloud_file_list_success")
api.cloudFileResponses = [page]
let viewModel = TaskCloudFileSelectionViewModel()
try await viewModel.reload(api: api)
viewModel.total = viewModel.files.count
try await viewModel.loadMore(api: api)
XCTAssertEqual(api.cloudFileCalls.count, 1)
XCTAssertFalse(viewModel.isLoadingMore)
}
///
private static func date(_ text: String) -> Date {
let formatter = DateFormatter()
@ -204,7 +384,9 @@ private final class TaskMockService: TaskServing {
var taskListResponses: [ListPayload<PhotographerTaskItem>] = []
var detailResponse: TaskDetailResponse?
var availableOrders: [AvailableOrderResponse] = []
var availableOrderError: Error?
var cloudFileResponses: [ListPayload<CloudDriveFile>] = []
var cloudFileError: Error?
private(set) var taskListCalls: [TaskListCall] = []
private(set) var detailIds: [Int] = []
private(set) var availableOrderScenicIds: [Int] = []
@ -244,6 +426,7 @@ private final class TaskMockService: TaskServing {
///
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
availableOrderScenicIds.append(scenicId)
if let availableOrderError { throw availableOrderError }
return availableOrders
}
@ -271,10 +454,17 @@ private final class TaskMockService: TaskServing {
pageSize: pageSize
)
)
if let cloudFileError { throw cloudFileError }
return cloudFileResponses.isEmpty ? ListPayload(total: 0, list: []) : cloudFileResponses.removeFirst()
}
}
///
private func taskAvailableOrder(orderNumber: String) -> AvailableOrderResponse {
let data = Data(#"{"project_name":"","order_number":"\#(orderNumber)","order_status":"1","order_status_label":"","pay_time":"2026-06-24","user_phone":"13800000000"}"#.utf8)
return try! JSONDecoder().decode(AvailableOrderResponse.self, from: data)
}
@MainActor
/// OSS
private final class TaskMockUploadService: OSSUploadServing {

View File

@ -1,24 +1,22 @@
//
// LoginSmokeUITests.swift
// ZLoginSmokeUITests.swift
// suixinkanUITests
//
// Z Keychain
//
import XCTest
final class LoginSmokeUITests: XCTestCase {
override class func setUp() {
super.setUp()
UITestSession.invalidate()
}
override class func tearDown() {
UITestSession.invalidate()
super.tearDown()
}
final class ZLoginSmokeUITests: XCTestCase {
override func setUp() {
super.setUp()
continueAfterFailure = false
UITestSession.invalidate()
}
override func tearDown() {
UITestSession.invalidate()
super.tearDown()
}
///
@ -34,11 +32,13 @@ final class LoginSmokeUITests: XCTestCase {
XCTAssertTrue(app.application.buttons["login.submit"].exists)
XCTAssertTrue(app.application.buttons["login.userAgreement"].exists)
XCTAssertTrue(app.application.buttons["login.privacyPolicy"].exists)
app.application.terminate()
}
///
func testLoginRequiresPrivacyAgreement() throws {
let app = SuixinkanApp(resetState: true).launch(waitForLoading: false)
defer { app.application.terminate() }
guard UITestLaunchConfiguration.credentials != nil else {
throw XCTSkip("缺少 UI Test 账号。")
}
@ -51,6 +51,7 @@ final class LoginSmokeUITests: XCTestCase {
///
func testEmptyPasswordDoesNotLeaveLoginScreen() throws {
let app = SuixinkanApp(resetState: true).launch(waitForLoading: false)
defer { app.application.terminate() }
let credentials = try UITestLaunchConfiguration.requireCredentials()
let usernameField = app.application.textFields["login.username"]

View File

@ -22,7 +22,7 @@
缺少账号时,需要登录的用例会自动 `XCTSkip`
已登录用例通过 `UITestSession` 全套件共享一次登录;每条首页路由会注册为独立 `testHomeRoute_<菜单名>` 用例,并通过 `-suixinkan-ui-tests-open-menu` 直达目标页。`LoginSmokeUITests` 会清空本地状态并使共享会话失效
已登录用例通过 `UITestSession` 全套件共享一次登录;每条首页路由会注册为独立 `testHomeRoute_<菜单名>` 用例,并通过 `-suixinkan-ui-tests-open-menu` 直达目标页。`ZLoginSmokeUITests` 在套件末尾执行并清空本地状态
## 测试数据约定api-test

View File

@ -15,14 +15,15 @@ final class MainTabUITests: AuthenticatedUITestCase {
)
TabBarNavigator.select(.orders, app: app)
NavigationAssertions.waitForNavigationTitle("订单", in: app)
NavigationAssertions.waitForStaticText("订单管理", in: app)
XCTAssertTrue(
NavigationAssertions.waitForAnyNavigationTitle(["订单"], in: app, timeout: 15) != nil
|| app.application.staticTexts["订单管理"].waitForExistence(timeout: 15)
)
TabBarNavigator.select(.statistics, app: app)
NavigationAssertions.waitForNavigationTitle("数据", in: app)
_ = NavigationAssertions.waitForAnyNavigationTitle(["数据"], in: app, timeout: 15)
TabBarNavigator.select(.profile, app: app)
NavigationAssertions.waitForNavigationTitle("个人信息", in: app)
NavigationAssertions.waitForStaticText("系统设置", in: app)
NavigationAssertions.waitForStaticText("系统设置", in: app, timeout: 12)
}
}

View File

@ -16,7 +16,11 @@ enum HomeRouteTestRunner {
file: StaticString = #filePath,
line: UInt = #line
) throws -> SuixinkanApp {
let app = try UITestSession.relaunch(openMenu: entry.menuTitle, file: file, line: line)
let app = try UITestSession.relaunch(
openMenu: UITestMenuTitleAliases.resolve(entry.menuTitle),
file: file,
line: line
)
UITestExpectationVerifier.verify(entry.expectation, app: app, file: file, line: line)
return app
}

View File

@ -18,6 +18,8 @@ enum LoginFlow {
) {
guard app.isOnLoginScreen else { return }
app.application.activate()
app.waitForGlobalLoadingToDisappear()
fillLoginForm(app: app, credentials: credentials)
submitLogin(app: app)
handleAccountSelectionIfNeeded(app: app, credentials: credentials)
@ -34,17 +36,21 @@ enum LoginFlow {
///
static func fillLoginForm(app: SuixinkanApp, credentials: UITestCredentials) {
let usernameField = app.application.textFields["login.username"]
XCTAssertTrue(usernameField.waitForExistence(timeout: 8))
usernameField.tap()
XCTAssertTrue(usernameField.waitForExistence(timeout: 12))
let currentPhone = usernameField.value as? String
if currentPhone != credentials.phone {
tapWhenReady(usernameField)
usernameField.clearAndType(credentials.phone)
}
let passwordField = preferredPasswordField(in: app.application)
XCTAssertTrue(passwordField.waitForExistence(timeout: 4))
passwordField.tap()
tapWhenReady(passwordField)
passwordField.clearAndType(credentials.password)
let privacyToggle = app.application.buttons["login.privacy"]
if privacyToggle.exists {
if privacyToggle.exists, privacyToggle.isHittable {
privacyToggle.tap()
}
}
@ -53,7 +59,7 @@ enum LoginFlow {
static func submitLogin(app: SuixinkanApp) {
let submitButton = app.application.buttons["login.submit"]
XCTAssertTrue(submitButton.waitForExistence(timeout: 4))
submitButton.tap()
tapWhenReady(submitButton)
app.waitForGlobalLoadingToDisappear()
}
@ -86,12 +92,26 @@ enum LoginFlow {
}
return app.textFields["login.password"]
}
/// 退
private static func tapWhenReady(_ element: XCUIElement) {
let deadline = Date().addingTimeInterval(8)
while Date() < deadline {
if element.isHittable {
element.tap()
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
}
private extension XCUIElement {
///
func clearAndType(_ text: String) {
guard let currentValue = value as? String else {
guard let currentValue = value as? String, !currentValue.isEmpty else {
typeText(text)
return
}

View File

@ -0,0 +1,21 @@
//
// UITestMenuTitleAliases.swift
// suixinkanUITests
//
// Created by Codex on 2026/6/26.
//
import Foundation
/// `HomeMenuRouter.titleMap`
enum UITestMenuTitleAliases {
///
static func resolve(_ menuTitle: String) -> String {
switch menuTitle {
case "系统设置":
return "设置中心"
default:
return menuTitle
}
}
}

View File

@ -33,6 +33,7 @@ enum UITestSession {
line: UInt = #line
) throws -> SuixinkanApp {
sharedApp?.application.terminate()
Thread.sleep(forTimeInterval: 1.0)
let relaunched = try UITestAuthenticatedSession.launchLoggedIn(
openMenuTitle: menuTitle,
@ -51,6 +52,7 @@ enum UITestSession {
line: UInt = #line
) throws -> SuixinkanApp {
sharedApp?.application.terminate()
Thread.sleep(forTimeInterval: 1.0)
let relaunched = try UITestAuthenticatedSession.launchLoggedIn(
openProfileRoute: route,