同步 Android 启动页到 iOS,并在冷启动期间静默恢复登录态。

新增 SplashView、SplashCoordinator 与 Launch Screen 资源,移除冷启动 Lottie;LoginView 加载 App 配置。同时将 token 存储迁移至 UserDefaults,并更新相关测试与文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 14:59:44 +08:00
parent 08492df6ce
commit 5bdf4a7dbf
29 changed files with 501 additions and 91 deletions

View File

@ -0,0 +1,87 @@
//
// AppConfigTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
@MainActor
/// App API
final class AppConfigTests: XCTestCase {
/// App 使 enable_register
func testAppConfigAPIRequestsConfigEndpoint() async throws {
let response = """
{
"code": 100000,
"msg": "success",
"data": {
"enable_register": true
}
}
""".data(using: .utf8)!
let session = AppConfigURLSession(responses: [response])
let api = AppConfigAPI(client: APIClient(session: session))
let config = try await api.fetchAppConfig()
XCTAssertTrue(config.enableRegister)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/app/config")
}
/// App enableRegister
func testLoginViewModelLoadAppConfigUpdatesEnableRegister() async throws {
let response = """
{
"code": 100000,
"msg": "success",
"data": {
"enable_register": true
}
}
""".data(using: .utf8)!
let session = AppConfigURLSession(responses: [response])
let api = AppConfigAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
await viewModel.loadAppConfig(appConfigAPI: api)
XCTAssertTrue(viewModel.enableRegister)
}
/// App
func testLoginViewModelLoadAppConfigKeepsDefaultOnFailure() async {
let session = AppConfigURLSession(responses: [Data()])
let api = AppConfigAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
await viewModel.loadAppConfig(appConfigAPI: api)
XCTAssertFalse(viewModel.enableRegister)
}
}
/// App URLSession
private final class AppConfigURLSession: 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)!
)
}
}

View File

@ -191,7 +191,7 @@ final class ProfileSecondaryPagesTests: XCTestCase {
///
func testApplyCachedProfileIgnoredAfterUserInfoLoaded() {
let viewModel = ProfileViewModel()
viewModel.userInfo = UserInfoResponse(nickname: "接口昵称", phone: "13900000000")
viewModel.userInfo = UserInfoResponse(phone: "13900000000", nickname: "接口昵称")
viewModel.applyCachedProfile(
AccountProfile(userId: "101", displayName: "缓存昵称", phone: "13800000000")
)

View File

@ -0,0 +1,68 @@
//
// SplashCoordinatorTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
@MainActor
/// bootstrap UI Test
final class SplashCoordinatorTests: XCTestCase {
/// 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

@ -10,6 +10,29 @@ import XCTest
/// Store UserDefaults
final class StorageTests: XCTestCase {
/// SessionTokenStore 使 UserDefaults token
func testSessionTokenStorePersistsTokenInUserDefaults() throws {
let defaults = makeIsolatedDefaults()
let store = SessionTokenStore(defaults: defaults, key: "session.token.test")
try store.save(" token-123 ")
XCTAssertEqual(store.load(), "token-123")
try store.clear()
XCTAssertNil(store.load())
}
/// token token
func testSessionTokenStoreClearsWhenSavingBlankToken() throws {
let defaults = makeIsolatedDefaults()
let store = SessionTokenStore(defaults: defaults, key: "session.token.blank.test")
try store.save("token-123")
try store.save(" \n")
XCTAssertNil(store.load())
}
/// AppPreferencesStore
func testAppPreferencesStorePersistsLoginPreferences() {
let defaults = makeIsolatedDefaults()