From d27438b2642609e8e38fc40f29c4419f48cbb467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Fri, 26 Jun 2026 12:44:10 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=20Auth/Tasks/ScenicPermissio?= =?UTF-8?q?n/Live=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=AE=8C=E5=96=84=20UI=20Test=20=E7=99=BB=E5=BD=95=E4=B8=8E?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=A8=B3=E5=AE=9A=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 LoginViewModel 硬编码账号,扩展登录流程测试覆盖;调整 ZLoginSmokeUITests 执行顺序与路由直达逻辑,避免清空 Keychain 影响其它用例。 Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 8 +- suixinkan/App/AppUITestRouteDriver.swift | 17 +- .../Auth/ViewModels/LoginViewModel.swift | 4 +- suixinkanTests/AuthModelsTests.swift | 95 +++++ .../v9_login_single_scenic_success.json | 24 ++ .../Fixtures/v9_set_scenic_user_success.json | 24 ++ suixinkanTests/Live/LiveViewModelTests.swift | 84 ++++ suixinkanTests/LoginViewModelTests.swift | 358 +++++++++++++++++- .../ScenicPermissionViewModelTests.swift | 235 +++++++++++- suixinkanTests/TaskViewModelTests.swift | 190 ++++++++++ ...UITests.swift => ZLoginSmokeUITests.swift} | 25 +- suixinkanUITests/README.md | 2 +- suixinkanUITests/Shell/MainTabUITests.swift | 11 +- .../Support/HomeRouteTestRunner.swift | 6 +- suixinkanUITests/Support/LoginFlow.swift | 34 +- .../Support/UITestMenuTitleAliases.swift | 21 + suixinkanUITests/Support/UITestSession.swift | 2 + 17 files changed, 1104 insertions(+), 36 deletions(-) create mode 100644 suixinkanTests/AuthModelsTests.swift create mode 100644 suixinkanTests/Fixtures/v9_login_single_scenic_success.json create mode 100644 suixinkanTests/Fixtures/v9_set_scenic_user_success.json rename suixinkanUITests/Auth/{LoginSmokeUITests.swift => ZLoginSmokeUITests.swift} (87%) create mode 100644 suixinkanUITests/Support/UITestMenuTitleAliases.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index d168fb9..1af304d 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -64,11 +64,15 @@ }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = suixinkanTests; sourceTree = ""; }; B00000022FEF000000000002 /* suixinkanUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = suixinkanUITests; sourceTree = ""; }; @@ -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"; diff --git a/suixinkan/App/AppUITestRouteDriver.swift b/suixinkan/App/AppUITestRouteDriver.swift index 5ae8434..eeae42c 100644 --- a/suixinkan/App/AppUITestRouteDriver.swift +++ b/suixinkan/App/AppUITestRouteDriver.swift @@ -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 diff --git a/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift b/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift index 36532a4..426211b 100644 --- a/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift +++ b/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift @@ -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 diff --git a/suixinkanTests/AuthModelsTests.swift b/suixinkanTests/AuthModelsTests.swift new file mode 100644 index 0000000..3d97c06 --- /dev/null +++ b/suixinkanTests/AuthModelsTests.swift @@ -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, "") + } +} diff --git a/suixinkanTests/Fixtures/v9_login_single_scenic_success.json b/suixinkanTests/Fixtures/v9_login_single_scenic_success.json new file mode 100644 index 0000000..bc123a9 --- /dev/null +++ b/suixinkanTests/Fixtures/v9_login_single_scenic_success.json @@ -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": [] + } +} diff --git a/suixinkanTests/Fixtures/v9_set_scenic_user_success.json b/suixinkanTests/Fixtures/v9_set_scenic_user_success.json new file mode 100644 index 0000000..09c3413 --- /dev/null +++ b/suixinkanTests/Fixtures/v9_set_scenic_user_success.json @@ -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": [] + } +} diff --git a/suixinkanTests/Live/LiveViewModelTests.swift b/suixinkanTests/Live/LiveViewModelTests.swift index 58518a4..a871499 100644 --- a/suixinkanTests/Live/LiveViewModelTests.swift +++ b/suixinkanTests/Live/LiveViewModelTests.swift @@ -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 时 prepare、start、stop 和 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 diff --git a/suixinkanTests/LoginViewModelTests.swift b/suixinkanTests/LoginViewModelTests.swift index cf05f87..4cc6eaf 100644 --- a/suixinkanTests/LoginViewModelTests.swift +++ b/suixinkanTests/LoginViewModelTests.swift @@ -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)! + ) + } } diff --git a/suixinkanTests/ScenicPermissionViewModelTests.swift b/suixinkanTests/ScenicPermissionViewModelTests.swift index bcbff0b..9acb173 100644 --- a/suixinkanTests/ScenicPermissionViewModelTests.swift +++ b/suixinkanTests/ScenicPermissionViewModelTests.swift @@ -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 } /// 记录角色权限申请提交请求。 diff --git a/suixinkanTests/TaskViewModelTests.swift b/suixinkanTests/TaskViewModelTests.swift index 37cc48f..67336c3 100644 --- a/suixinkanTests/TaskViewModelTests.swift +++ b/suixinkanTests/TaskViewModelTests.swift @@ -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.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.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] = [] var detailResponse: TaskDetailResponse? var availableOrders: [AvailableOrderResponse] = [] + var availableOrderError: Error? var cloudFileResponses: [ListPayload] = [] + 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 { diff --git a/suixinkanUITests/Auth/LoginSmokeUITests.swift b/suixinkanUITests/Auth/ZLoginSmokeUITests.swift similarity index 87% rename from suixinkanUITests/Auth/LoginSmokeUITests.swift rename to suixinkanUITests/Auth/ZLoginSmokeUITests.swift index 1755939..a738e2a 100644 --- a/suixinkanUITests/Auth/LoginSmokeUITests.swift +++ b/suixinkanUITests/Auth/ZLoginSmokeUITests.swift @@ -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"] diff --git a/suixinkanUITests/README.md b/suixinkanUITests/README.md index 9f24cc7..485c958 100644 --- a/suixinkanUITests/README.md +++ b/suixinkanUITests/README.md @@ -22,7 +22,7 @@ 缺少账号时,需要登录的用例会自动 `XCTSkip`。 -已登录用例通过 `UITestSession` 全套件共享一次登录;每条首页路由会注册为独立 `testHomeRoute_<菜单名>` 用例,并通过 `-suixinkan-ui-tests-open-menu` 直达目标页。`LoginSmokeUITests` 会清空本地状态并使共享会话失效。 +已登录用例通过 `UITestSession` 全套件共享一次登录;每条首页路由会注册为独立 `testHomeRoute_<菜单名>` 用例,并通过 `-suixinkan-ui-tests-open-menu` 直达目标页。`ZLoginSmokeUITests` 在套件末尾执行并清空本地状态。 ## 测试数据约定(api-test) diff --git a/suixinkanUITests/Shell/MainTabUITests.swift b/suixinkanUITests/Shell/MainTabUITests.swift index 8e82c03..e8888f5 100644 --- a/suixinkanUITests/Shell/MainTabUITests.swift +++ b/suixinkanUITests/Shell/MainTabUITests.swift @@ -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) } } diff --git a/suixinkanUITests/Support/HomeRouteTestRunner.swift b/suixinkanUITests/Support/HomeRouteTestRunner.swift index b57a44e..e78f6a8 100644 --- a/suixinkanUITests/Support/HomeRouteTestRunner.swift +++ b/suixinkanUITests/Support/HomeRouteTestRunner.swift @@ -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 } diff --git a/suixinkanUITests/Support/LoginFlow.swift b/suixinkanUITests/Support/LoginFlow.swift index 31ef094..d2f950e 100644 --- a/suixinkanUITests/Support/LoginFlow.swift +++ b/suixinkanUITests/Support/LoginFlow.swift @@ -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() - usernameField.clearAndType(credentials.phone) + 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 } diff --git a/suixinkanUITests/Support/UITestMenuTitleAliases.swift b/suixinkanUITests/Support/UITestMenuTitleAliases.swift new file mode 100644 index 0000000..da897e9 --- /dev/null +++ b/suixinkanUITests/Support/UITestMenuTitleAliases.swift @@ -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 + } + } +} diff --git a/suixinkanUITests/Support/UITestSession.swift b/suixinkanUITests/Support/UITestSession.swift index 375686a..3857ffe 100644 --- a/suixinkanUITests/Support/UITestSession.swift +++ b/suixinkanUITests/Support/UITestSession.swift @@ -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,