优化冷启动流程:Launch Screen 对齐 Splash,仅阻塞 rolePermissions。

移除 SplashCoordinator,bootstrap 期间用 SplashView 覆盖避免空白闪屏,其余账号数据改为后台补充刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 09:56:49 +08:00
parent d2fe5d71e4
commit 258c438f9a
15 changed files with 545 additions and 223 deletions

View File

@ -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()

View 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: [])
}
}

View File

@ -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)
}
}

View 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))
}
}