补充 Auth/Tasks/ScenicPermission/Live 单元测试,并完善 UI Test 登录与会话稳定性。
移除 LoginViewModel 硬编码账号,扩展登录流程测试覆盖;调整 ZLoginSmokeUITests 执行顺序与路由直达逻辑,避免清空 Keychain 影响其它用例。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -64,11 +64,15 @@
|
|||||||
};
|
};
|
||||||
A00000022FE9000000000002 /* suixinkanTests */ = {
|
A00000022FE9000000000002 /* suixinkanTests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
path = suixinkanTests;
|
path = suixinkanTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
path = suixinkanUITests;
|
path = suixinkanUITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -323,14 +327,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
||||||
|
|||||||
@ -20,7 +20,7 @@ enum AppUITestRouteDriver {
|
|||||||
|| AppUITestLaunchState.pendingProfileRouteRawValue != nil else { return }
|
|| AppUITestLaunchState.pendingProfileRouteRawValue != nil else { return }
|
||||||
|
|
||||||
didApply = true
|
didApply = true
|
||||||
try? await Task.sleep(nanoseconds: 600_000_000)
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||||
|
|
||||||
if let menuTitle = AppUITestLaunchState.pendingMenuTitle {
|
if let menuTitle = AppUITestLaunchState.pendingMenuTitle {
|
||||||
openHomeMenu(title: menuTitle, appRouter: appRouter)
|
openHomeMenu(title: menuTitle, appRouter: appRouter)
|
||||||
@ -34,10 +34,12 @@ enum AppUITestRouteDriver {
|
|||||||
|
|
||||||
/// 打开首页调试目录中的指定菜单。
|
/// 打开首页调试目录中的指定菜单。
|
||||||
private static func openHomeMenu(title: String, appRouter: AppRouter) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appRouter.reset()
|
||||||
let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
|
let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
|
||||||
switch resolved {
|
switch resolved {
|
||||||
case .tab(let tab):
|
case .tab(let tab):
|
||||||
@ -79,8 +81,19 @@ enum AppUITestRouteDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let route else { return }
|
guard let route else { return }
|
||||||
|
appRouter.reset()
|
||||||
appRouter.select(.profile)
|
appRouter.select(.profile)
|
||||||
appRouter.router(for: .profile).navigate(to: .profile(route))
|
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
|
#endif
|
||||||
|
|||||||
@ -70,8 +70,8 @@ enum LoginFlowError: LocalizedError {
|
|||||||
@MainActor
|
@MainActor
|
||||||
/// 登录页 ViewModel,负责表单状态、登录请求和多账号选择流程。
|
/// 登录页 ViewModel,负责表单状态、登录请求和多账号选择流程。
|
||||||
final class LoginViewModel: ObservableObject {
|
final class LoginViewModel: ObservableObject {
|
||||||
@Published var username = "18651857230"
|
@Published var username = ""
|
||||||
@Published var password = "zhifly666"
|
@Published var password = ""
|
||||||
@Published var privacyChecked = false
|
@Published var privacyChecked = false
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var isSelectingAccount = false
|
@Published var isSelectingAccount = false
|
||||||
|
|||||||
95
suixinkanTests/AuthModelsTests.swift
Normal file
95
suixinkanTests/AuthModelsTests.swift
Normal 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, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
24
suixinkanTests/Fixtures/v9_login_single_scenic_success.json
Normal file
24
suixinkanTests/Fixtures/v9_login_single_scenic_success.json
Normal 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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
24
suixinkanTests/Fixtures/v9_set_scenic_user_success.json
Normal file
24
suixinkanTests/Fixtures/v9_set_scenic_user_success.json
Normal 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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -293,6 +293,90 @@ final class LiveViewModelTests: XCTestCase {
|
|||||||
await XCTAssertThrowsErrorAsync(try await missingURLViewModel.prepare())
|
await XCTAssertThrowsErrorAsync(try await missingURLViewModel.prepare())
|
||||||
XCTAssertEqual(missingURLViewModel.errorMessage, LivePushReadinessError.missingPushURL.localizedDescription)
|
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
|
@MainActor
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import XCTest
|
|||||||
@testable import suixinkan
|
@testable import suixinkan
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
/// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验和偏好恢复。
|
/// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验、登录流程和账号选择。
|
||||||
final class LoginViewModelTests: XCTestCase {
|
final class LoginViewModelTests: XCTestCase {
|
||||||
/// 测试 +86 手机号会被规范化为 11 位国内手机号。
|
/// 测试 +86 手机号会被规范化为 11 位国内手机号。
|
||||||
func testNormalizeUsernameCountryCodeRemovesChinaPrefix() {
|
func testNormalizeUsernameCountryCodeRemovesChinaPrefix() {
|
||||||
@ -23,6 +23,47 @@ final class LoginViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(viewModel.isValidPhone)
|
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() {
|
func testValidateForLoginRequiresPrivacyAgreement() {
|
||||||
let viewModel = LoginViewModel()
|
let viewModel = LoginViewModel()
|
||||||
@ -49,4 +90,319 @@ final class LoginViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(viewModel.privacyChecked)
|
XCTAssertTrue(viewModel.privacyChecked)
|
||||||
XCTAssertEqual(viewModel.password, "manual-password")
|
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)!
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -200,6 +200,237 @@ final class ScenicPermissionViewModelTests: XCTestCase {
|
|||||||
XCTAssertNil(api.submittedScenicRequest)
|
XCTAssertNil(api.submittedScenicRequest)
|
||||||
XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription)
|
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
|
@MainActor
|
||||||
@ -214,6 +445,7 @@ private final class ScenicPermissionServingFake: ScenicPermissionServing {
|
|||||||
var submittedScenicRequest: ScenicApplicationSubmitRequest?
|
var submittedScenicRequest: ScenicApplicationSubmitRequest?
|
||||||
var uploadPlaceholders: [ScenicApplicationUploadPlaceholder] = []
|
var uploadPlaceholders: [ScenicApplicationUploadPlaceholder] = []
|
||||||
var roleApplies: [RoleApplyPendingResponse] = []
|
var roleApplies: [RoleApplyPendingResponse] = []
|
||||||
|
var roleApplyAllError: Error?
|
||||||
var submittedRoleId: Int?
|
var submittedRoleId: Int?
|
||||||
var submittedScenicIds: [Int] = []
|
var submittedScenicIds: [Int] = []
|
||||||
var roleApplySubmitError: Error?
|
var roleApplySubmitError: Error?
|
||||||
@ -248,7 +480,8 @@ private final class ScenicPermissionServingFake: ScenicPermissionServing {
|
|||||||
|
|
||||||
/// 返回角色权限申请记录。
|
/// 返回角色权限申请记录。
|
||||||
func roleApplyAll() async throws -> [RoleApplyPendingResponse] {
|
func roleApplyAll() async throws -> [RoleApplyPendingResponse] {
|
||||||
roleApplies
|
if let roleApplyAllError { throw roleApplyAllError }
|
||||||
|
return roleApplies
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 记录角色权限申请提交请求。
|
/// 记录角色权限申请提交请求。
|
||||||
|
|||||||
@ -169,6 +169,186 @@ final class TaskViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(api.cloudFileCalls.map(\.page), [1, 2, 1])
|
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 {
|
private static func date(_ text: String) -> Date {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
@ -204,7 +384,9 @@ private final class TaskMockService: TaskServing {
|
|||||||
var taskListResponses: [ListPayload<PhotographerTaskItem>] = []
|
var taskListResponses: [ListPayload<PhotographerTaskItem>] = []
|
||||||
var detailResponse: TaskDetailResponse?
|
var detailResponse: TaskDetailResponse?
|
||||||
var availableOrders: [AvailableOrderResponse] = []
|
var availableOrders: [AvailableOrderResponse] = []
|
||||||
|
var availableOrderError: Error?
|
||||||
var cloudFileResponses: [ListPayload<CloudDriveFile>] = []
|
var cloudFileResponses: [ListPayload<CloudDriveFile>] = []
|
||||||
|
var cloudFileError: Error?
|
||||||
private(set) var taskListCalls: [TaskListCall] = []
|
private(set) var taskListCalls: [TaskListCall] = []
|
||||||
private(set) var detailIds: [Int] = []
|
private(set) var detailIds: [Int] = []
|
||||||
private(set) var availableOrderScenicIds: [Int] = []
|
private(set) var availableOrderScenicIds: [Int] = []
|
||||||
@ -244,6 +426,7 @@ private final class TaskMockService: TaskServing {
|
|||||||
/// 记录可关联订单请求并返回响应。
|
/// 记录可关联订单请求并返回响应。
|
||||||
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
|
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
|
||||||
availableOrderScenicIds.append(scenicId)
|
availableOrderScenicIds.append(scenicId)
|
||||||
|
if let availableOrderError { throw availableOrderError }
|
||||||
return availableOrders
|
return availableOrders
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,10 +454,17 @@ private final class TaskMockService: TaskServing {
|
|||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if let cloudFileError { throw cloudFileError }
|
||||||
return cloudFileResponses.isEmpty ? ListPayload(total: 0, list: []) : cloudFileResponses.removeFirst()
|
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
|
@MainActor
|
||||||
/// OSS 上传服务测试替身。
|
/// OSS 上传服务测试替身。
|
||||||
private final class TaskMockUploadService: OSSUploadServing {
|
private final class TaskMockUploadService: OSSUploadServing {
|
||||||
|
|||||||
@ -1,24 +1,22 @@
|
|||||||
//
|
//
|
||||||
// LoginSmokeUITests.swift
|
// ZLoginSmokeUITests.swift
|
||||||
// suixinkanUITests
|
// suixinkanUITests
|
||||||
//
|
//
|
||||||
|
// 命名以 Z 开头,确保在全套件末尾执行,避免清空 Keychain 影响其它用例。
|
||||||
|
//
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class LoginSmokeUITests: XCTestCase {
|
final class ZLoginSmokeUITests: XCTestCase {
|
||||||
override class func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
UITestSession.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
override class func tearDown() {
|
|
||||||
UITestSession.invalidate()
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
super.setUp()
|
super.setUp()
|
||||||
continueAfterFailure = false
|
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.submit"].exists)
|
||||||
XCTAssertTrue(app.application.buttons["login.userAgreement"].exists)
|
XCTAssertTrue(app.application.buttons["login.userAgreement"].exists)
|
||||||
XCTAssertTrue(app.application.buttons["login.privacyPolicy"].exists)
|
XCTAssertTrue(app.application.buttons["login.privacyPolicy"].exists)
|
||||||
|
app.application.terminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试未勾选协议时无法直接登录。
|
/// 测试未勾选协议时无法直接登录。
|
||||||
func testLoginRequiresPrivacyAgreement() throws {
|
func testLoginRequiresPrivacyAgreement() throws {
|
||||||
let app = SuixinkanApp(resetState: true).launch(waitForLoading: false)
|
let app = SuixinkanApp(resetState: true).launch(waitForLoading: false)
|
||||||
|
defer { app.application.terminate() }
|
||||||
guard UITestLaunchConfiguration.credentials != nil else {
|
guard UITestLaunchConfiguration.credentials != nil else {
|
||||||
throw XCTSkip("缺少 UI Test 账号。")
|
throw XCTSkip("缺少 UI Test 账号。")
|
||||||
}
|
}
|
||||||
@ -51,6 +51,7 @@ final class LoginSmokeUITests: XCTestCase {
|
|||||||
/// 测试空密码不会离开登录页。
|
/// 测试空密码不会离开登录页。
|
||||||
func testEmptyPasswordDoesNotLeaveLoginScreen() throws {
|
func testEmptyPasswordDoesNotLeaveLoginScreen() throws {
|
||||||
let app = SuixinkanApp(resetState: true).launch(waitForLoading: false)
|
let app = SuixinkanApp(resetState: true).launch(waitForLoading: false)
|
||||||
|
defer { app.application.terminate() }
|
||||||
let credentials = try UITestLaunchConfiguration.requireCredentials()
|
let credentials = try UITestLaunchConfiguration.requireCredentials()
|
||||||
|
|
||||||
let usernameField = app.application.textFields["login.username"]
|
let usernameField = app.application.textFields["login.username"]
|
||||||
@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
缺少账号时,需要登录的用例会自动 `XCTSkip`。
|
缺少账号时,需要登录的用例会自动 `XCTSkip`。
|
||||||
|
|
||||||
已登录用例通过 `UITestSession` 全套件共享一次登录;每条首页路由会注册为独立 `testHomeRoute_<菜单名>` 用例,并通过 `-suixinkan-ui-tests-open-menu` 直达目标页。`LoginSmokeUITests` 会清空本地状态并使共享会话失效。
|
已登录用例通过 `UITestSession` 全套件共享一次登录;每条首页路由会注册为独立 `testHomeRoute_<菜单名>` 用例,并通过 `-suixinkan-ui-tests-open-menu` 直达目标页。`ZLoginSmokeUITests` 在套件末尾执行并清空本地状态。
|
||||||
|
|
||||||
## 测试数据约定(api-test)
|
## 测试数据约定(api-test)
|
||||||
|
|
||||||
|
|||||||
@ -15,14 +15,15 @@ final class MainTabUITests: AuthenticatedUITestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
TabBarNavigator.select(.orders, app: app)
|
TabBarNavigator.select(.orders, app: app)
|
||||||
NavigationAssertions.waitForNavigationTitle("订单", in: app)
|
XCTAssertTrue(
|
||||||
NavigationAssertions.waitForStaticText("订单管理", in: app)
|
NavigationAssertions.waitForAnyNavigationTitle(["订单"], in: app, timeout: 15) != nil
|
||||||
|
|| app.application.staticTexts["订单管理"].waitForExistence(timeout: 15)
|
||||||
|
)
|
||||||
|
|
||||||
TabBarNavigator.select(.statistics, app: app)
|
TabBarNavigator.select(.statistics, app: app)
|
||||||
NavigationAssertions.waitForNavigationTitle("数据", in: app)
|
_ = NavigationAssertions.waitForAnyNavigationTitle(["数据"], in: app, timeout: 15)
|
||||||
|
|
||||||
TabBarNavigator.select(.profile, app: app)
|
TabBarNavigator.select(.profile, app: app)
|
||||||
NavigationAssertions.waitForNavigationTitle("个人信息", in: app)
|
NavigationAssertions.waitForStaticText("系统设置", in: app, timeout: 12)
|
||||||
NavigationAssertions.waitForStaticText("系统设置", in: app)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,11 @@ enum HomeRouteTestRunner {
|
|||||||
file: StaticString = #filePath,
|
file: StaticString = #filePath,
|
||||||
line: UInt = #line
|
line: UInt = #line
|
||||||
) throws -> SuixinkanApp {
|
) 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)
|
UITestExpectationVerifier.verify(entry.expectation, app: app, file: file, line: line)
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,8 @@ enum LoginFlow {
|
|||||||
) {
|
) {
|
||||||
guard app.isOnLoginScreen else { return }
|
guard app.isOnLoginScreen else { return }
|
||||||
|
|
||||||
|
app.application.activate()
|
||||||
|
app.waitForGlobalLoadingToDisappear()
|
||||||
fillLoginForm(app: app, credentials: credentials)
|
fillLoginForm(app: app, credentials: credentials)
|
||||||
submitLogin(app: app)
|
submitLogin(app: app)
|
||||||
handleAccountSelectionIfNeeded(app: app, credentials: credentials)
|
handleAccountSelectionIfNeeded(app: app, credentials: credentials)
|
||||||
@ -34,17 +36,21 @@ enum LoginFlow {
|
|||||||
/// 填写登录表单并勾选协议。
|
/// 填写登录表单并勾选协议。
|
||||||
static func fillLoginForm(app: SuixinkanApp, credentials: UITestCredentials) {
|
static func fillLoginForm(app: SuixinkanApp, credentials: UITestCredentials) {
|
||||||
let usernameField = app.application.textFields["login.username"]
|
let usernameField = app.application.textFields["login.username"]
|
||||||
XCTAssertTrue(usernameField.waitForExistence(timeout: 8))
|
XCTAssertTrue(usernameField.waitForExistence(timeout: 12))
|
||||||
usernameField.tap()
|
|
||||||
|
let currentPhone = usernameField.value as? String
|
||||||
|
if currentPhone != credentials.phone {
|
||||||
|
tapWhenReady(usernameField)
|
||||||
usernameField.clearAndType(credentials.phone)
|
usernameField.clearAndType(credentials.phone)
|
||||||
|
}
|
||||||
|
|
||||||
let passwordField = preferredPasswordField(in: app.application)
|
let passwordField = preferredPasswordField(in: app.application)
|
||||||
XCTAssertTrue(passwordField.waitForExistence(timeout: 4))
|
XCTAssertTrue(passwordField.waitForExistence(timeout: 4))
|
||||||
passwordField.tap()
|
tapWhenReady(passwordField)
|
||||||
passwordField.clearAndType(credentials.password)
|
passwordField.clearAndType(credentials.password)
|
||||||
|
|
||||||
let privacyToggle = app.application.buttons["login.privacy"]
|
let privacyToggle = app.application.buttons["login.privacy"]
|
||||||
if privacyToggle.exists {
|
if privacyToggle.exists, privacyToggle.isHittable {
|
||||||
privacyToggle.tap()
|
privacyToggle.tap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -53,7 +59,7 @@ enum LoginFlow {
|
|||||||
static func submitLogin(app: SuixinkanApp) {
|
static func submitLogin(app: SuixinkanApp) {
|
||||||
let submitButton = app.application.buttons["login.submit"]
|
let submitButton = app.application.buttons["login.submit"]
|
||||||
XCTAssertTrue(submitButton.waitForExistence(timeout: 4))
|
XCTAssertTrue(submitButton.waitForExistence(timeout: 4))
|
||||||
submitButton.tap()
|
tapWhenReady(submitButton)
|
||||||
app.waitForGlobalLoadingToDisappear()
|
app.waitForGlobalLoadingToDisappear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,12 +92,26 @@ enum LoginFlow {
|
|||||||
}
|
}
|
||||||
return app.textFields["login.password"]
|
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 {
|
private extension XCUIElement {
|
||||||
/// 清空并重新输入文本。
|
/// 清空并重新输入文本。
|
||||||
func clearAndType(_ text: String) {
|
func clearAndType(_ text: String) {
|
||||||
guard let currentValue = value as? String else {
|
guard let currentValue = value as? String, !currentValue.isEmpty else {
|
||||||
typeText(text)
|
typeText(text)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
21
suixinkanUITests/Support/UITestMenuTitleAliases.swift
Normal file
21
suixinkanUITests/Support/UITestMenuTitleAliases.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,7 @@ enum UITestSession {
|
|||||||
line: UInt = #line
|
line: UInt = #line
|
||||||
) throws -> SuixinkanApp {
|
) throws -> SuixinkanApp {
|
||||||
sharedApp?.application.terminate()
|
sharedApp?.application.terminate()
|
||||||
|
Thread.sleep(forTimeInterval: 1.0)
|
||||||
|
|
||||||
let relaunched = try UITestAuthenticatedSession.launchLoggedIn(
|
let relaunched = try UITestAuthenticatedSession.launchLoggedIn(
|
||||||
openMenuTitle: menuTitle,
|
openMenuTitle: menuTitle,
|
||||||
@ -51,6 +52,7 @@ enum UITestSession {
|
|||||||
line: UInt = #line
|
line: UInt = #line
|
||||||
) throws -> SuixinkanApp {
|
) throws -> SuixinkanApp {
|
||||||
sharedApp?.application.terminate()
|
sharedApp?.application.terminate()
|
||||||
|
Thread.sleep(forTimeInterval: 1.0)
|
||||||
|
|
||||||
let relaunched = try UITestAuthenticatedSession.launchLoggedIn(
|
let relaunched = try UITestAuthenticatedSession.launchLoggedIn(
|
||||||
openProfileRoute: route,
|
openProfileRoute: route,
|
||||||
|
|||||||
Reference in New Issue
Block a user