Initial commit
This commit is contained in:
29
suixinkanTests/APIErrorTests.swift
Normal file
29
suixinkanTests/APIErrorTests.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// APIErrorTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 网络错误测试,覆盖登录凭证失效判断。
|
||||
final class APIErrorTests: XCTestCase {
|
||||
/// 测试 HTTP 401 和 403 会被识别为登录失效。
|
||||
func testAuthenticationExpiredForUnauthorizedHTTPStatus() {
|
||||
XCTAssertTrue(APIError.isAuthenticationExpired(APIError.httpStatus(401, "Unauthorized")))
|
||||
XCTAssertTrue(APIError.isAuthenticationExpired(APIError.httpStatus(403, "Forbidden")))
|
||||
}
|
||||
|
||||
/// 测试后端业务码和中文失效文案会被识别为登录失效。
|
||||
func testAuthenticationExpiredForServerTokenMessages() {
|
||||
XCTAssertTrue(APIError.isAuthenticationExpired(APIError.serverCode(200001, "token 已过期")))
|
||||
XCTAssertTrue(APIError.isAuthenticationExpired(APIError.serverCode(0, "登录失效,请重新登录")))
|
||||
}
|
||||
|
||||
/// 测试普通网络错误不会被识别为登录失效。
|
||||
func testNetworkErrorIsNotAuthenticationExpired() {
|
||||
XCTAssertFalse(APIError.isAuthenticationExpired(APIError.networkFailed("请求超时,请稍后重试")))
|
||||
}
|
||||
}
|
||||
137
suixinkanTests/AccountContextLoaderTests.swift
Normal file
137
suixinkanTests/AccountContextLoaderTests.swift
Normal file
@ -0,0 +1,137 @@
|
||||
//
|
||||
// AccountContextLoaderTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文加载器测试,覆盖权限、景区兜底和门店容错。
|
||||
final class AccountContextLoaderTests: XCTestCase {
|
||||
/// 测试权限、用户资料、景区和门店成功时会写入上下文。
|
||||
func testRefreshLoadsRolePermissionScenicAndStoreContext() async throws {
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
let api = MockAccountContextAPI()
|
||||
api.roles = [
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: 7, name: "运营", permission: [PermissionItem(id: 1, name: "首页", uri: "/home")]),
|
||||
scenic: [ScenicInfo(id: 88, name: "东湖景区")]
|
||||
)
|
||||
]
|
||||
api.scenicResponse = ScenicListAllResponse(total: 1, list: [ScenicListItem(id: 88, name: "东湖景区")])
|
||||
api.storeResponse = ListPayload(total: 1, list: [StoreItem(id: 66, scenicId: 88, name: "东湖门店")])
|
||||
|
||||
try await AccountContextLoader().refresh(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: MockUserProfileAPI(),
|
||||
accountContextAPI: api
|
||||
)
|
||||
|
||||
XCTAssertEqual(accountContext.profile?.displayName, "测试用户")
|
||||
XCTAssertEqual(permissionContext.currentRole?.id, 7)
|
||||
XCTAssertTrue(permissionContext.canAccess("/home"))
|
||||
XCTAssertEqual(accountContext.currentScenic?.id, 88)
|
||||
XCTAssertEqual(accountContext.currentStore?.id, 66)
|
||||
}
|
||||
|
||||
/// 测试景区接口失败时会从角色权限景区兜底。
|
||||
func testRefreshFallsBackToRoleScenicsWhenScenicAPIThrows() async throws {
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
let api = MockAccountContextAPI()
|
||||
api.roles = [
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: 7, name: "运营"),
|
||||
scenic: [
|
||||
ScenicInfo(id: 88, name: "东湖景区"),
|
||||
ScenicInfo(id: 88, name: "东湖景区")
|
||||
]
|
||||
)
|
||||
]
|
||||
api.scenicError = APIError.networkFailed("景区接口失败")
|
||||
|
||||
try await AccountContextLoader().refresh(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: MockUserProfileAPI(),
|
||||
accountContextAPI: api
|
||||
)
|
||||
|
||||
XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88])
|
||||
}
|
||||
|
||||
/// 测试门店接口失败时不阻断上下文加载。
|
||||
func testRefreshAllowsStoreFailure() async throws {
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
let api = MockAccountContextAPI()
|
||||
api.roles = [
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: 7, name: "运营"),
|
||||
scenic: [ScenicInfo(id: 88, name: "东湖景区")]
|
||||
)
|
||||
]
|
||||
api.storeError = APIError.networkFailed("门店接口失败")
|
||||
|
||||
try await AccountContextLoader().refresh(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: MockUserProfileAPI(),
|
||||
accountContextAPI: api
|
||||
)
|
||||
|
||||
XCTAssertEqual(accountContext.currentScenic?.id, 88)
|
||||
XCTAssertTrue(accountContext.storeScopes.isEmpty)
|
||||
XCTAssertNil(accountContext.currentStore)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 用户资料服务测试替身,返回固定用户资料。
|
||||
private final class MockUserProfileAPI: UserProfileServing {
|
||||
/// 获取固定用户资料。
|
||||
func userInfo() async throws -> UserInfoResponse {
|
||||
UserInfoResponse(nickname: "测试用户", roleName: "运营")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文服务测试替身,可分别控制各接口成功或失败。
|
||||
private final class MockAccountContextAPI: AccountContextServing {
|
||||
var roles: [RolePermissionResponse] = []
|
||||
var scenicResponse = ScenicListAllResponse(total: 0, list: [])
|
||||
var storeResponse = ListPayload(total: 0, list: [StoreItem]())
|
||||
var scenicError: Error?
|
||||
var storeError: Error?
|
||||
|
||||
/// 获取测试角色权限。
|
||||
func rolePermissions() async throws -> [RolePermissionResponse] {
|
||||
roles
|
||||
}
|
||||
|
||||
/// 获取测试景区列表,或按配置抛出错误。
|
||||
func scenicListAll() async throws -> ScenicListAllResponse {
|
||||
if let scenicError {
|
||||
throw scenicError
|
||||
}
|
||||
return scenicResponse
|
||||
}
|
||||
|
||||
/// 获取测试门店列表,或按配置抛出错误。
|
||||
func storeAll() async throws -> ListPayload<StoreItem> {
|
||||
if let storeError {
|
||||
throw storeError
|
||||
}
|
||||
return storeResponse
|
||||
}
|
||||
|
||||
/// 获取测试景点列表。
|
||||
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
}
|
||||
133
suixinkanTests/AccountContextTests.swift
Normal file
133
suixinkanTests/AccountContextTests.swift
Normal file
@ -0,0 +1,133 @@
|
||||
//
|
||||
// AccountContextTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文测试,覆盖景区、门店和角色权限的选择恢复。
|
||||
final class AccountContextTests: XCTestCase {
|
||||
/// 测试 replaceScopes 能按缓存 ID 恢复当前景区和门店。
|
||||
func testReplaceScopesRestoresCurrentSelectionsById() {
|
||||
let context = AccountContext()
|
||||
let scenicScopes = [
|
||||
BusinessScope(id: 1, name: "默认景区", kind: .scenic),
|
||||
BusinessScope(id: 2, name: "缓存景区", kind: .scenic)
|
||||
]
|
||||
let storeScopes = [
|
||||
BusinessScope(id: 10, name: "默认门店", kind: .store),
|
||||
BusinessScope(id: 20, name: "缓存门店", kind: .store)
|
||||
]
|
||||
|
||||
context.replaceScopes(
|
||||
scenic: scenicScopes,
|
||||
stores: storeScopes,
|
||||
currentScenicId: 2,
|
||||
currentStoreId: 20
|
||||
)
|
||||
|
||||
XCTAssertEqual(context.currentScenic?.id, 2)
|
||||
XCTAssertEqual(context.currentStore?.id, 20)
|
||||
}
|
||||
|
||||
/// 测试缓存 ID 不存在时会回退到首个可用作用域。
|
||||
func testReplaceScopesFallsBackToFirstScopeWhenCachedIdIsMissing() {
|
||||
let context = AccountContext()
|
||||
|
||||
context.replaceScopes(
|
||||
scenic: [BusinessScope(id: 1, name: "默认景区", kind: .scenic)],
|
||||
stores: [BusinessScope(id: 10, name: "默认门店", kind: .store)],
|
||||
currentScenicId: 999,
|
||||
currentStoreId: 999
|
||||
)
|
||||
|
||||
XCTAssertEqual(context.currentScenic?.id, 1)
|
||||
XCTAssertEqual(context.currentStore?.id, 10)
|
||||
}
|
||||
|
||||
/// 测试切换景区时会优先选择同景区门店。
|
||||
func testSelectScenicResolvesStoreWithinSelectedScenic() {
|
||||
let context = AccountContext()
|
||||
context.replaceScopes(
|
||||
scenic: [
|
||||
BusinessScope(id: 1, name: "西湖景区", kind: .scenic),
|
||||
BusinessScope(id: 2, name: "东湖景区", kind: .scenic)
|
||||
],
|
||||
stores: [
|
||||
BusinessScope(id: 10, name: "西湖门店", kind: .store, parentScenicId: 1),
|
||||
BusinessScope(id: 20, name: "东湖门店", kind: .store, parentScenicId: 2)
|
||||
],
|
||||
currentScenicId: 1,
|
||||
currentStoreId: 10
|
||||
)
|
||||
|
||||
context.selectScenic(id: 2)
|
||||
|
||||
XCTAssertEqual(context.currentScenic?.id, 2)
|
||||
XCTAssertEqual(context.currentStore?.id, 20)
|
||||
}
|
||||
|
||||
/// 测试切换角色时会同步角色景区并重新匹配门店。
|
||||
func testSelectRoleUpdatesScenicScopesAndStoreSelection() {
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
accountContext.replaceScopes(
|
||||
scenic: [BusinessScope(id: 1, name: "旧景区", kind: .scenic)],
|
||||
stores: [
|
||||
BusinessScope(id: 10, name: "旧门店", kind: .store, parentScenicId: 1),
|
||||
BusinessScope(id: 20, name: "新门店", kind: .store, parentScenicId: 2)
|
||||
],
|
||||
currentScenicId: 1,
|
||||
currentStoreId: 10
|
||||
)
|
||||
permissionContext.replaceRolePermissions([
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: 1, name: "旧角色"),
|
||||
scenic: [ScenicInfo(id: 1, name: "旧景区")]
|
||||
),
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: 2, name: "新角色"),
|
||||
scenic: [ScenicInfo(id: 2, name: "新景区")]
|
||||
)
|
||||
])
|
||||
|
||||
permissionContext.selectRole(id: 2, accountContext: accountContext)
|
||||
|
||||
XCTAssertEqual(permissionContext.currentRole?.id, 2)
|
||||
XCTAssertEqual(accountContext.scenicScopes.map(\.id), [2])
|
||||
XCTAssertEqual(accountContext.currentScenic?.id, 2)
|
||||
XCTAssertEqual(accountContext.currentStore?.id, 20)
|
||||
}
|
||||
|
||||
/// 测试权限上下文会递归展开权限 URI。
|
||||
func testPermissionContextFlattensPermissionURIs() {
|
||||
let permissionContext = PermissionContext()
|
||||
permissionContext.replaceRolePermissions([
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(
|
||||
id: 1,
|
||||
name: "运营",
|
||||
permission: [
|
||||
PermissionItem(
|
||||
id: 1,
|
||||
name: "首页",
|
||||
uri: "/home",
|
||||
children: [
|
||||
PermissionItem(id: 2, name: "订单", uri: "/orders")
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
scenic: []
|
||||
)
|
||||
])
|
||||
|
||||
XCTAssertTrue(permissionContext.canAccess("/home"))
|
||||
XCTAssertTrue(permissionContext.canAccess("/orders"))
|
||||
XCTAssertFalse(permissionContext.canAccess("/settings"))
|
||||
}
|
||||
}
|
||||
80
suixinkanTests/HomeCommonMenuStoreTests.swift
Normal file
80
suixinkanTests/HomeCommonMenuStoreTests.swift
Normal file
@ -0,0 +1,80 @@
|
||||
//
|
||||
// HomeCommonMenuStoreTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 首页常用应用存储测试,覆盖默认值、过滤和别名去重。
|
||||
final class HomeCommonMenuStoreTests: XCTestCase {
|
||||
/// 测试首次无配置时会生成默认常用应用。
|
||||
func testLoadCreatesDefaultCommonMenusWhenBaselineIsMissing() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
let menus = [
|
||||
menu("registration_invitation"),
|
||||
menu("location_report"),
|
||||
menu("pm_manager")
|
||||
]
|
||||
|
||||
let uris = store.load(menuItems: menus)
|
||||
|
||||
XCTAssertEqual(uris, ["registration_invitation", "location_report", "pm_manager"])
|
||||
XCTAssertTrue(defaults.bool(forKey: HomeCommonMenuStore.defaultBaselineKey))
|
||||
}
|
||||
|
||||
/// 测试已保存 URI 会按当前权限过滤并解析同义 URI。
|
||||
func testLoadFiltersSavedUrisByCurrentPermissions() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
defaults.set(true, forKey: HomeCommonMenuStore.defaultBaselineKey)
|
||||
defaults.set(["task_management", "unknown", "pm"], forKey: HomeCommonMenuStore.defaultStorageKey)
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
let menus = [
|
||||
menu("task_management_editor"),
|
||||
menu("pm_manager")
|
||||
]
|
||||
|
||||
let uris = store.load(menuItems: menus)
|
||||
|
||||
XCTAssertEqual(uris, ["task_management_editor", "pm_manager"])
|
||||
}
|
||||
|
||||
/// 测试添加常用应用时会按同义 URI 去重。
|
||||
func testAddDeduplicatesAliases() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
let menus = [
|
||||
menu("task_management_editor")
|
||||
]
|
||||
|
||||
let uris = store.add("task_management", current: ["task_management_editor"], menuItems: menus)
|
||||
|
||||
XCTAssertEqual(uris, ["task_management_editor"])
|
||||
}
|
||||
|
||||
/// 测试移除常用应用时会移除同义 URI。
|
||||
func testRemoveDeletesAliases() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
|
||||
let uris = store.remove("pm", current: ["pm_manager", "location_report"])
|
||||
|
||||
XCTAssertEqual(uris, ["location_report"])
|
||||
}
|
||||
|
||||
/// 创建测试菜单实体。
|
||||
private func menu(_ uri: String) -> HomeMenuItem {
|
||||
HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil)
|
||||
}
|
||||
|
||||
/// 创建独立 UserDefaults,避免测试污染真实 App 偏好。
|
||||
private func makeIsolatedDefaults() -> UserDefaults {
|
||||
let suiteName = "suixinkan.home.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
152
suixinkanTests/HomeMenuRouterTests.swift
Normal file
152
suixinkanTests/HomeMenuRouterTests.swift
Normal file
@ -0,0 +1,152 @@
|
||||
//
|
||||
// HomeMenuRouterTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 首页菜单路由测试,覆盖 URI 映射、别名和未知路由诊断。
|
||||
final class HomeMenuRouterTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
HomeRouteDiagnostics.resetUnknownRoutes()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
HomeRouteDiagnostics.resetUnknownRoutes()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
/// 测试已迁移的首页路由会解析到真实目标。
|
||||
func testMigratedRoutesResolveToDestinations() {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "space_settings", title: ""), .destination(.profileSpace))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicselection", title: ""), .destination(.scenicSelection))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "store", title: ""), .destination(.moreFunctions))
|
||||
}
|
||||
|
||||
/// 测试已知但未迁移的首页路由会进入安全占位。
|
||||
func testKnownUnmigratedRoutesResolveToPlaceholders() {
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "task_management", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "task_management", title: "任务管理"))
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "payment_qr", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "payment_qr", title: "收款码"))
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
||||
func testTabRoutesStayCentralized() {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .tab(.orders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .tab(.orders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .tab(.orders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_stats", title: ""), .tab(.statistics))
|
||||
}
|
||||
|
||||
/// 测试飞控相关入口属于已知不支持范围。
|
||||
func testFlightControlRoutesAreKnownUnsupportedScope() {
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "fly", title: ""),
|
||||
.unsupported(uri: "fly", title: "飞行管理", reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。")
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试未知 URI 会显式进入 placeholder。
|
||||
func testUnknownRouteIsExplicitPlaceholder() {
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "android_only_feature", title: "Android 专项"),
|
||||
.placeholder(uri: "android_only_feature", title: "Android 专项")
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试 canonicalURI 会使用当前权限可用的同义 URI。
|
||||
func testCanonicalURIUsesAvailableAlias() {
|
||||
let available: Set<String> = ["task_management_editor", "photographer_invite", "pm_manager", "payment_qr"]
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "task_management", availableURIs: available), "task_management_editor")
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "registration_invitation", availableURIs: available), "photographer_invite")
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "pm", availableURIs: available), "pm_manager")
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "payment_code", availableURIs: available), "payment_qr")
|
||||
}
|
||||
|
||||
/// 测试 menuAliasKey 会把同义入口分到同一组。
|
||||
func testMenuAliasKeyGroupsDuplicateAndroidEntries() {
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "task_management_editor"), "task_management")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "photographer_invite"), "registration_invitation")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "pm_manager"), "pm")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "payment_code"), "payment_collection")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "deposit_order_shooting_info"), "deposit_order")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "/scenic-order-manage"), "photographer_orders")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "android_only_module"), "android_only_module")
|
||||
}
|
||||
|
||||
/// 测试未知路由诊断会去重并更新标题。
|
||||
func testUnknownRouteDiagnosticsDeduplicatesAndUpdatesTitle() {
|
||||
HomeRouteDiagnostics.recordUnknown(uri: "android_feature", title: "旧标题")
|
||||
HomeRouteDiagnostics.recordUnknown(uri: "android_feature", title: "新标题")
|
||||
HomeRouteDiagnostics.recordUnknown(uri: "another_feature", title: "")
|
||||
|
||||
let records = HomeRouteDiagnostics.unknownRoutes()
|
||||
|
||||
XCTAssertEqual(records.count, 2)
|
||||
XCTAssertEqual(records.first { $0.uri == "android_feature" }?.title, "新标题")
|
||||
XCTAssertEqual(records.first { $0.uri == "android_feature" }?.count, 2)
|
||||
XCTAssertEqual(records.first { $0.uri == "another_feature" }?.count, 1)
|
||||
}
|
||||
|
||||
/// 测试未知路由诊断报告是 Markdown 且按出现次数排序。
|
||||
func testUnknownRouteDiagnosticsReportIsMarkdownAndSortedByCount() {
|
||||
HomeRouteDiagnostics.recordUnknown(uri: "rare_feature", title: "低频入口")
|
||||
HomeRouteDiagnostics.recordUnknown(uri: "hot_feature", title: "高频入口")
|
||||
HomeRouteDiagnostics.recordUnknown(uri: "hot_feature", title: "高频入口")
|
||||
|
||||
let report = HomeRouteDiagnostics.unknownRouteReport(generatedAt: Date(timeIntervalSince1970: 0))
|
||||
|
||||
XCTAssertTrue(report.contains("# Home Route Diagnostics"))
|
||||
XCTAssertTrue(report.contains("| hot_feature | 高频入口 | 2 |"))
|
||||
XCTAssertLessThan(
|
||||
report.range(of: "hot_feature")!.lowerBound,
|
||||
report.range(of: "rare_feature")!.lowerBound
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试权限路由审计会区分可路由、不支持和未知入口。
|
||||
func testPermissionRouteAuditSeparatesRoutableUnsupportedAndUnknownEntries() {
|
||||
let permissions = [
|
||||
rolePermission(
|
||||
roleId: 1,
|
||||
permissions: [
|
||||
permission(id: 1, name: "任务管理", uri: "task_management", children: [
|
||||
permission(id: 2, name: "发布任务", uri: "task_create")
|
||||
]),
|
||||
permission(id: 3, name: "飞控", uri: "pilot_controller"),
|
||||
permission(id: 4, name: "Android 专项入口", uri: "android_only_module"),
|
||||
permission(id: 5, name: "重复任务", uri: "task_management")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
let audit = HomePermissionRouteAuditor.audit(permissions: permissions, currentRoleId: 1)
|
||||
|
||||
XCTAssertEqual(audit.routable.map(\.uri), ["task_management", "task_create"])
|
||||
XCTAssertEqual(audit.unsupported.map(\.uri), ["pilot_controller"])
|
||||
XCTAssertEqual(audit.unknown.map(\.uri), ["android_only_module"])
|
||||
XCTAssertTrue(audit.hasUnknownRoutes)
|
||||
}
|
||||
|
||||
/// 创建角色权限测试实体。
|
||||
private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse {
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: roleId, name: "角色\(roleId)", permission: permissions),
|
||||
scenic: []
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建权限节点测试实体。
|
||||
private func permission(id: Int, name: String, uri: String, children: [PermissionItem] = []) -> PermissionItem {
|
||||
PermissionItem(id: id, name: name, uri: uri, children: children)
|
||||
}
|
||||
}
|
||||
111
suixinkanTests/HomeViewModelTests.swift
Normal file
111
suixinkanTests/HomeViewModelTests.swift
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// HomeViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 首页 ViewModel 测试,覆盖权限菜单构建、排序和去重。
|
||||
final class HomeViewModelTests: XCTestCase {
|
||||
/// 测试当前角色缺失时清空菜单,不回退到其他角色。
|
||||
func testBuildMenusClearsStaleMenusWhenCurrentRoleIsMissing() {
|
||||
let viewModel = HomeViewModel()
|
||||
let permissions = [
|
||||
rolePermission(roleId: 1, permissions: [
|
||||
permission(id: 1, name: "消息中心", uri: "message_center")
|
||||
])
|
||||
]
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: 1)
|
||||
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["message_center"])
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: 99)
|
||||
|
||||
XCTAssertTrue(viewModel.menuItems.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试当前角色为空时才回退到第一个角色。
|
||||
func testBuildMenusFallsBackToFirstRoleOnlyWhenCurrentRoleIsNil() {
|
||||
let viewModel = HomeViewModel()
|
||||
let permissions = [
|
||||
rolePermission(roleId: 1, permissions: [
|
||||
permission(id: 1, name: "任务管理", uri: "task_management")
|
||||
]),
|
||||
rolePermission(roleId: 2, permissions: [
|
||||
permission(id: 2, name: "消息中心", uri: "message_center")
|
||||
])
|
||||
]
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: nil)
|
||||
|
||||
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["task_management"])
|
||||
}
|
||||
|
||||
/// 测试权限树会递归展开为菜单。
|
||||
func testBuildMenusFlattensPermissionTree() {
|
||||
let viewModel = HomeViewModel()
|
||||
let permissions = [
|
||||
rolePermission(roleId: 1, permissions: [
|
||||
permission(id: 1, name: "任务管理", uri: "task_management", children: [
|
||||
permission(id: 2, name: "消息中心", uri: "message_center")
|
||||
])
|
||||
])
|
||||
]
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: 1)
|
||||
|
||||
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["task_management", "message_center"])
|
||||
}
|
||||
|
||||
/// 测试同义 URI 会去重,只保留首次出现的菜单。
|
||||
func testBuildMenusDeduplicatesKnownAndroidAliases() {
|
||||
let viewModel = HomeViewModel()
|
||||
let permissions = [
|
||||
rolePermission(roleId: 1, permissions: [
|
||||
permission(id: 1, name: "项目管理", uri: "pm_manager"),
|
||||
permission(id: 2, name: "项目管理旧入口", uri: "pm"),
|
||||
permission(id: 3, name: "收款码", uri: "payment_code"),
|
||||
permission(id: 4, name: "收款码旧入口", uri: "payment_qr"),
|
||||
permission(id: 5, name: "押金订单详情", uri: "deposit_order_detail"),
|
||||
permission(id: 6, name: "押金订单", uri: "deposit_order")
|
||||
])
|
||||
]
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: 1)
|
||||
|
||||
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["deposit_order_detail", "pm_manager", "payment_code"])
|
||||
}
|
||||
|
||||
/// 测试菜单按旧工程 preferred order 排序。
|
||||
func testBuildMenusSortsByPreferredOrder() {
|
||||
let viewModel = HomeViewModel()
|
||||
let permissions = [
|
||||
rolePermission(roleId: 1, permissions: [
|
||||
permission(id: 1, name: "消息中心", uri: "message_center"),
|
||||
permission(id: 2, name: "空间设置", uri: "space_settings"),
|
||||
permission(id: 3, name: "钱包", uri: "wallet")
|
||||
])
|
||||
]
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: 1)
|
||||
|
||||
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["space_settings", "wallet", "message_center"])
|
||||
}
|
||||
|
||||
/// 创建角色权限测试实体。
|
||||
private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse {
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: roleId, name: "角色\(roleId)", permission: permissions),
|
||||
scenic: []
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建权限节点测试实体。
|
||||
private func permission(id: Int, name: String, uri: String, children: [PermissionItem] = []) -> PermissionItem {
|
||||
PermissionItem(id: id, name: name, uri: uri, children: children)
|
||||
}
|
||||
}
|
||||
52
suixinkanTests/LoginViewModelTests.swift
Normal file
52
suixinkanTests/LoginViewModelTests.swift
Normal file
@ -0,0 +1,52 @@
|
||||
//
|
||||
// LoginViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验和偏好恢复。
|
||||
final class LoginViewModelTests: XCTestCase {
|
||||
/// 测试 +86 手机号会被规范化为 11 位国内手机号。
|
||||
func testNormalizeUsernameCountryCodeRemovesChinaPrefix() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.username = "+86 186 5185 7230"
|
||||
|
||||
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||||
|
||||
XCTAssertEqual(viewModel.username, "18651857230")
|
||||
XCTAssertEqual(viewModel.normalizedUsername, "18651857230")
|
||||
XCTAssertTrue(viewModel.isValidPhone)
|
||||
}
|
||||
|
||||
/// 测试未勾选协议时登录校验返回隐私协议错误。
|
||||
func testValidateForLoginRequiresPrivacyAgreement() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.username = "18651857230"
|
||||
viewModel.password = "secret123"
|
||||
viewModel.privacyChecked = false
|
||||
|
||||
XCTAssertEqual(viewModel.validateForLogin(), .privacyUnchecked)
|
||||
}
|
||||
|
||||
/// 测试登录偏好只恢复手机号和协议状态,不恢复密码。
|
||||
func testApplyPreferencesRestoresUsernameAndPrivacyOnly() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.password = "manual-password"
|
||||
|
||||
viewModel.applyPreferences(
|
||||
LoginPreferences(
|
||||
lastUsername: "18651857230",
|
||||
privacyAgreementAccepted: true
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.username, "18651857230")
|
||||
XCTAssertTrue(viewModel.privacyChecked)
|
||||
XCTAssertEqual(viewModel.password, "manual-password")
|
||||
}
|
||||
}
|
||||
18
suixinkanTests/NavigationRouterTests.swift
Normal file
18
suixinkanTests/NavigationRouterTests.swift
Normal file
@ -0,0 +1,18 @@
|
||||
//
|
||||
// NavigationRouterTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 主导航路由测试,覆盖 AppRoute 的跨页面导航策略。
|
||||
final class NavigationRouterTests: XCTestCase {
|
||||
/// 测试通过 NavigationStack push 的子页面默认隐藏底部 TabBar。
|
||||
func testPushedRoutesHideTabBarByDefault() {
|
||||
XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
|
||||
}
|
||||
}
|
||||
76
suixinkanTests/ScenicSpotContextTests.swift
Normal file
76
suixinkanTests/ScenicSpotContextTests.swift
Normal file
@ -0,0 +1,76 @@
|
||||
//
|
||||
// ScenicSpotContextTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 景点上下文测试,覆盖按景区懒加载和失败隔离。
|
||||
final class ScenicSpotContextTests: XCTestCase {
|
||||
/// 测试景点列表会按当前景区 ID 请求并写入上下文。
|
||||
func testReloadRequestsSpotsForScenicId() async {
|
||||
let context = ScenicSpotContext()
|
||||
let api = MockScenicSpotAPI()
|
||||
api.spotResponse = ListPayload(total: 1, list: [ScenicSpotItem(id: 9, name: "观景台")])
|
||||
|
||||
await context.reload(scenicId: 88, api: api)
|
||||
|
||||
XCTAssertEqual(api.requestedScenicIds, [88])
|
||||
XCTAssertEqual(context.scenicId, 88)
|
||||
XCTAssertEqual(context.spots.map(\.id), [9])
|
||||
XCTAssertEqual(context.loadState, .loaded)
|
||||
}
|
||||
|
||||
/// 测试景点接口失败时只标记景点模块失败。
|
||||
func testReloadFailureOnlyAffectsScenicSpotContext() async {
|
||||
let context = ScenicSpotContext()
|
||||
let api = MockScenicSpotAPI()
|
||||
api.error = APIError.networkFailed("景点接口失败")
|
||||
|
||||
await context.reload(scenicId: 88, api: api)
|
||||
|
||||
XCTAssertEqual(api.requestedScenicIds, [88])
|
||||
XCTAssertTrue(context.spots.isEmpty)
|
||||
if case .failed = context.loadState {
|
||||
XCTAssertTrue(true)
|
||||
} else {
|
||||
XCTFail("景点接口失败时应进入 failed 状态")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 景点服务测试替身,记录请求过的景区 ID。
|
||||
private final class MockScenicSpotAPI: AccountContextServing {
|
||||
var requestedScenicIds: [Int] = []
|
||||
var spotResponse = ListPayload(total: 0, list: [ScenicSpotItem]())
|
||||
var error: Error?
|
||||
|
||||
/// 获取空角色权限,当前测试不使用。
|
||||
func rolePermissions() async throws -> [RolePermissionResponse] {
|
||||
[]
|
||||
}
|
||||
|
||||
/// 获取空景区列表,当前测试不使用。
|
||||
func scenicListAll() async throws -> ScenicListAllResponse {
|
||||
ScenicListAllResponse(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 获取空门店列表,当前测试不使用。
|
||||
func storeAll() async throws -> ListPayload<StoreItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 获取测试景点列表,并记录景区 ID。
|
||||
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
|
||||
requestedScenicIds.append(scenicId)
|
||||
if let error {
|
||||
throw error
|
||||
}
|
||||
return spotResponse
|
||||
}
|
||||
}
|
||||
60
suixinkanTests/StorageTests.swift
Normal file
60
suixinkanTests/StorageTests.swift
Normal file
@ -0,0 +1,60 @@
|
||||
//
|
||||
// StorageTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 本地缓存 Store 测试,覆盖 UserDefaults 中的偏好和账号快照。
|
||||
final class StorageTests: XCTestCase {
|
||||
/// 测试 AppPreferencesStore 能保存并读取上次登录手机号和协议状态。
|
||||
func testAppPreferencesStorePersistsLoginPreferences() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = AppPreferencesStore(defaults: defaults)
|
||||
|
||||
store.saveLastLoginUsername(" 18651857230 ")
|
||||
store.savePrivacyAgreementAccepted(true)
|
||||
|
||||
XCTAssertEqual(store.loadLastLoginUsername(), "18651857230")
|
||||
XCTAssertTrue(store.loadPrivacyAgreementAccepted())
|
||||
}
|
||||
|
||||
/// 测试账号快照能保存并恢复账号资料、业务作用域和当前选择。
|
||||
func testAccountSnapshotStorePersistsAccountContextSnapshot() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = AccountSnapshotStore(defaults: defaults)
|
||||
let snapshot = AccountSnapshot(
|
||||
profile: AccountProfile(
|
||||
userId: "101",
|
||||
displayName: "测试账号",
|
||||
phone: "18651857230",
|
||||
avatarURL: "https://cdn.example.com/avatar.jpg"
|
||||
),
|
||||
accountType: "store_user",
|
||||
businessUserId: 101,
|
||||
scenicScopes: [
|
||||
BusinessScope(id: 88, name: "东门景区", kind: .scenic)
|
||||
],
|
||||
storeScopes: [
|
||||
BusinessScope(id: 66, name: "东门门店", kind: .store)
|
||||
],
|
||||
currentScenicId: 88,
|
||||
currentStoreId: 66
|
||||
)
|
||||
|
||||
store.save(snapshot)
|
||||
|
||||
XCTAssertEqual(store.load(), snapshot)
|
||||
}
|
||||
|
||||
/// 创建独立 UserDefaults,避免测试污染真实 App 偏好。
|
||||
private func makeIsolatedDefaults() -> UserDefaults {
|
||||
let suiteName = "suixinkan.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user