优化冷启动流程:Launch Screen 对齐 Splash,仅阻塞 rolePermissions。
移除 SplashCoordinator,bootstrap 期间用 SplashView 覆盖避免空白闪屏,其余账号数据改为后台补充刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -65,6 +65,64 @@ final class AccountContextLoaderTests: XCTestCase {
|
||||
XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88])
|
||||
}
|
||||
|
||||
/// 测试仅恢复角色权限时会写入 PermissionContext,并在无快照景区时补齐作用域。
|
||||
func testRestorePermissionsLoadsRolePermissionsOnly() 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: "东湖景区")]
|
||||
)
|
||||
]
|
||||
|
||||
try await AccountContextLoader().restorePermissions(
|
||||
permissionContext: permissionContext,
|
||||
accountContext: accountContext,
|
||||
accountContextAPI: api
|
||||
)
|
||||
|
||||
XCTAssertEqual(permissionContext.currentRole?.id, 7)
|
||||
XCTAssertTrue(permissionContext.canAccess("/home"))
|
||||
XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88])
|
||||
XCTAssertNil(accountContext.profile)
|
||||
}
|
||||
|
||||
/// 测试补充刷新会更新用户资料、景区和门店。
|
||||
func testRefreshSupplementalContextUpdatesProfileAndScopes() 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.scenicResponse = ScenicListAllResponse(total: 1, list: [ScenicListItem(id: 88, name: "东湖景区")])
|
||||
api.storeResponse = ListPayload(total: 1, list: [StoreItem(id: 66, scenicId: 88, name: "东湖门店")])
|
||||
|
||||
try await AccountContextLoader().restorePermissions(
|
||||
permissionContext: permissionContext,
|
||||
accountContext: accountContext,
|
||||
accountContextAPI: api
|
||||
)
|
||||
|
||||
try await AccountContextLoader().refreshSupplementalContext(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: MockUserProfileAPI(),
|
||||
accountContextAPI: api,
|
||||
cachedCurrentScenicId: 88,
|
||||
cachedCurrentStoreId: 66
|
||||
)
|
||||
|
||||
XCTAssertEqual(accountContext.profile?.displayName, "测试用户")
|
||||
XCTAssertEqual(accountContext.currentScenic?.id, 88)
|
||||
XCTAssertEqual(accountContext.currentStore?.id, 66)
|
||||
}
|
||||
|
||||
/// 测试门店接口失败时不阻断上下文加载。
|
||||
func testRefreshAllowsStoreFailure() async throws {
|
||||
let accountContext = AccountContext()
|
||||
|
||||
144
suixinkanTests/SessionBootstrapperTests.swift
Normal file
144
suixinkanTests/SessionBootstrapperTests.swift
Normal file
@ -0,0 +1,144 @@
|
||||
//
|
||||
// SessionBootstrapperTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/30.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 冷启动恢复测试,覆盖阻塞权限恢复与后台补充刷新。
|
||||
final class SessionBootstrapperTests: XCTestCase {
|
||||
/// 测试无 token 时直接进入未登录状态。
|
||||
func testRestoreWithoutTokenLogsOut() async {
|
||||
let defaults = makeDefaults()
|
||||
let tokenStore = SessionTokenStore(defaults: defaults, key: "test.token")
|
||||
let snapshotStore = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
|
||||
let bootstrapper = SessionBootstrapper(tokenStore: tokenStore, snapshotStore: snapshotStore)
|
||||
let appSession = AppSession()
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
let toastCenter = ToastCenter()
|
||||
|
||||
await bootstrapper.restore(
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: MockUserProfileAPI(),
|
||||
accountContextAPI: MockAccountContextAPI(),
|
||||
toastCenter: toastCenter
|
||||
)
|
||||
|
||||
XCTAssertEqual(appSession.phase, .loggedOut)
|
||||
}
|
||||
|
||||
/// 测试有 token 时阻塞等待 rolePermissions 后立即进入 loggedIn。
|
||||
func testRestoreMarksLoggedInAfterRolePermissions() async {
|
||||
let defaults = makeDefaults()
|
||||
let tokenStore = SessionTokenStore(defaults: defaults, key: "test.token")
|
||||
try? tokenStore.save("token-1")
|
||||
let snapshotStore = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
|
||||
let bootstrapper = SessionBootstrapper(tokenStore: tokenStore, snapshotStore: snapshotStore)
|
||||
let appSession = AppSession()
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
let toastCenter = ToastCenter()
|
||||
let api = MockAccountContextAPI()
|
||||
api.roles = [
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: 7, name: "运营", permission: [PermissionItem(id: 1, name: "首页", uri: "/home")]),
|
||||
scenic: [ScenicInfo(id: 88, name: "东湖景区")]
|
||||
)
|
||||
]
|
||||
|
||||
await bootstrapper.restore(
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: MockUserProfileAPI(),
|
||||
accountContextAPI: api,
|
||||
toastCenter: toastCenter
|
||||
)
|
||||
|
||||
XCTAssertEqual(appSession.phase, .loggedIn)
|
||||
XCTAssertEqual(permissionContext.currentRole?.id, 7)
|
||||
XCTAssertTrue(permissionContext.canAccess("/home"))
|
||||
|
||||
let profileExpectation = expectation(description: "profile loaded in background")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
if accountContext.profile?.displayName == "测试用户" {
|
||||
profileExpectation.fulfill()
|
||||
}
|
||||
}
|
||||
await fulfillment(of: [profileExpectation], timeout: 2)
|
||||
}
|
||||
|
||||
/// 测试 rolePermissions 鉴权失败时会清空本地登录态。
|
||||
func testRestoreLogsOutWhenRolePermissionsExpired() async {
|
||||
let defaults = makeDefaults()
|
||||
let tokenStore = SessionTokenStore(defaults: defaults, key: "test.token")
|
||||
try? tokenStore.save("token-1")
|
||||
let snapshotStore = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
|
||||
snapshotStore.save(AccountSnapshot(profile: AccountProfile(userId: "1", displayName: "本地用户")))
|
||||
let bootstrapper = SessionBootstrapper(tokenStore: tokenStore, snapshotStore: snapshotStore)
|
||||
let appSession = AppSession()
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
let toastCenter = ToastCenter()
|
||||
let api = MockAccountContextAPI()
|
||||
api.roleError = APIError.httpStatus(401, "登录状态已失效")
|
||||
|
||||
await bootstrapper.restore(
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: MockUserProfileAPI(),
|
||||
accountContextAPI: api,
|
||||
toastCenter: toastCenter
|
||||
)
|
||||
|
||||
XCTAssertEqual(appSession.phase, .loggedOut)
|
||||
XCTAssertNil(tokenStore.load())
|
||||
XCTAssertNil(snapshotStore.load())
|
||||
}
|
||||
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
let suiteName = "SessionBootstrapperTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockUserProfileAPI: UserProfileServing {
|
||||
func userInfo() async throws -> UserInfoResponse {
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
return UserInfoResponse(nickname: "测试用户", roleName: "运营")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockAccountContextAPI: AccountContextServing {
|
||||
var roles: [RolePermissionResponse] = []
|
||||
var roleError: Error?
|
||||
|
||||
func rolePermissions() async throws -> [RolePermissionResponse] {
|
||||
if let roleError { throw roleError }
|
||||
return roles
|
||||
}
|
||||
|
||||
func scenicListAll() async throws -> ScenicListAllResponse {
|
||||
ScenicListAllResponse(total: 0, list: [])
|
||||
}
|
||||
|
||||
func storeAll() async throws -> ListPayload<StoreItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
//
|
||||
// SplashCoordinatorTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 启动页协调器测试,覆盖可选最短展示时长、bootstrap 并行和 UI Test 跳过策略。
|
||||
final class SplashCoordinatorTests: XCTestCase {
|
||||
/// 测试默认无最短展示时长时,bootstrap 完成即可结束 Splash。
|
||||
func testStartFinishesImmediatelyWithDefaultMinimumDuration() async {
|
||||
let coordinator = SplashCoordinator(skipsMinimumDuration: true)
|
||||
let startedAt = Date()
|
||||
|
||||
await coordinator.start {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
|
||||
XCTAssertTrue(coordinator.isFinished)
|
||||
XCTAssertLessThan(Date().timeIntervalSince(startedAt), 0.15)
|
||||
}
|
||||
|
||||
/// 测试 bootstrap 完成后仍需等待最短展示时长。
|
||||
func testStartWaitsForMinimumDisplayDuration() async {
|
||||
let coordinator = SplashCoordinator(minimumDisplayDuration: 0.2, skipsMinimumDuration: false)
|
||||
let startedAt = Date()
|
||||
|
||||
await coordinator.start {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
|
||||
XCTAssertTrue(coordinator.isFinished)
|
||||
XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(startedAt), 0.19)
|
||||
}
|
||||
|
||||
/// 测试 bootstrap 耗时超过最短展示时长时,以 bootstrap 完成时间为准。
|
||||
func testStartWaitsForSlowBootstrap() async {
|
||||
let coordinator = SplashCoordinator(minimumDisplayDuration: 0.05, skipsMinimumDuration: false)
|
||||
let startedAt = Date()
|
||||
|
||||
await coordinator.start {
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
}
|
||||
|
||||
XCTAssertTrue(coordinator.isFinished)
|
||||
XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(startedAt), 0.14)
|
||||
}
|
||||
|
||||
/// 测试 UI Test 模式会跳过最短展示时长。
|
||||
func testStartSkipsMinimumDurationWhenRequested() async {
|
||||
let coordinator = SplashCoordinator(minimumDisplayDuration: 1.0, skipsMinimumDuration: true)
|
||||
let startedAt = Date()
|
||||
|
||||
await coordinator.start {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
|
||||
XCTAssertTrue(coordinator.isFinished)
|
||||
XCTAssertLessThan(Date().timeIntervalSince(startedAt), 0.2)
|
||||
}
|
||||
|
||||
/// 测试重复调用 start 不会重置已完成状态。
|
||||
func testStartIsIdempotentAfterFinished() async {
|
||||
let coordinator = SplashCoordinator(minimumDisplayDuration: 0, skipsMinimumDuration: true)
|
||||
var bootstrapCount = 0
|
||||
|
||||
await coordinator.start {
|
||||
bootstrapCount += 1
|
||||
}
|
||||
await coordinator.start {
|
||||
bootstrapCount += 1
|
||||
}
|
||||
|
||||
XCTAssertTrue(coordinator.isFinished)
|
||||
XCTAssertEqual(bootstrapCount, 1)
|
||||
}
|
||||
}
|
||||
26
suixinkanTests/SplashTypographyTests.swift
Normal file
26
suixinkanTests/SplashTypographyTests.swift
Normal file
@ -0,0 +1,26 @@
|
||||
//
|
||||
// SplashTypographyTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/30.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 启动页字体规范测试,确保 Storyboard 与 SwiftUI 使用同一套字号与字重。
|
||||
final class SplashTypographyTests: XCTestCase {
|
||||
/// 标题应使用 40pt system bold。
|
||||
func testTitleFontUsesExpectedPointSizeAndWeight() {
|
||||
let font = SplashTypography.titleUIFont
|
||||
XCTAssertEqual(font.pointSize, SplashTypography.titlePointSize, accuracy: 0.01)
|
||||
XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitBold))
|
||||
}
|
||||
|
||||
/// 副标题应使用 20pt system bold。
|
||||
func testSubtitleFontUsesExpectedPointSizeAndWeight() {
|
||||
let font = SplashTypography.subtitleUIFont
|
||||
XCTAssertEqual(font.pointSize, SplashTypography.subtitlePointSize, accuracy: 0.01)
|
||||
XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitBold))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user