Refactor AppServices into assembly, feature facades, and lifecycle coordinator.

Extract dependency wiring and session lifecycle from the container and RootViewController, split NetworkBundle by domain, and add module-level feature services while keeping flat accessors for compatibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 09:32:44 +08:00
parent 1a6d4a1791
commit 0d8f97417b
11 changed files with 686 additions and 197 deletions

View File

@ -0,0 +1,169 @@
//
// AppFeatureServicesTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan_ios
@MainActor
///
final class AppFeatureServicesTests: XCTestCase {
///
func testOrdersFeatureServicesReflectsCurrentContext() {
let services = makeServices()
applyContext(to: services)
let feature = services.ordersFeatureServices
XCTAssertIdentical(feature.api as AnyObject, services.ordersAPI as AnyObject)
XCTAssertEqual(feature.currentScenicId, 7)
XCTAssertEqual(feature.currentStoreId, 9)
XCTAssertEqual(feature.currentRoleId, 3)
XCTAssertIdentical(feature.navigator as AnyObject, services.appNavigator as AnyObject)
XCTAssertIdentical(feature.toastCenter as AnyObject, services.toastCenter as AnyObject)
XCTAssertIdentical(feature.globalLoading as AnyObject, services.globalLoading as AnyObject)
}
///
func testAssetsFeatureServicesUsesSharedTransferStore() {
let services = makeServices()
applyContext(to: services)
let feature = services.assetsFeatureServices
XCTAssertIdentical(feature.api as AnyObject, services.assetsAPI as AnyObject)
XCTAssertIdentical(feature.uploader as AnyObject, services.ossUploadService as AnyObject)
XCTAssertIdentical(feature.transferStore as AnyObject, CloudTransferStore.shared as AnyObject)
XCTAssertEqual(feature.currentScenicId, 7)
}
/// App
func testQueueFeatureServicesUsesSharedRuntimeAndContext() {
let services = makeServices()
applyContext(to: services)
let feature = services.queueFeatureServices
XCTAssertIdentical(feature.api as AnyObject, services.scenicQueueAPI as AnyObject)
XCTAssertIdentical(feature.runtime as AnyObject, ScenicQueueRuntime.shared as AnyObject)
XCTAssertIdentical(feature.scenicSpotContext as AnyObject, services.scenicSpotContext as AnyObject)
XCTAssertEqual(feature.currentScenicId, 7)
XCTAssertEqual(feature.userId, "42")
}
/// 退
func testProfileFeatureLogoutStopsQueueRuntime() {
let services = makeServices()
applyContext(to: services)
services.appSession.markLoggedIn(token: "token")
ScenicQueueSettingsStore.saveSelectedSpot(id: 11, name: "一号点", userId: "42", scenicId: 7)
let runtime = ScenicQueueRuntime.shared
defer {
runtime.stop()
clearQueueSelection(userId: "42", scenicId: 7)
}
runtime.update(api: AppFeatureScenicQueueMock(), userId: "42", scenicId: 7, scenePhase: .active)
XCTAssertTrue(runtime.isMonitoring)
services.profileFeatureServices.logout()
XCTAssertFalse(runtime.isMonitoring)
XCTAssertFalse(services.appSession.isLoggedIn)
XCTAssertNil(services.accountContext.profile)
}
///
private func makeServices() -> AppServices {
AppServices(apiClient: APIClient(session: AppFeatureRecordingURLSession()))
}
///
private func applyContext(to services: AppServices) {
services.accountContext.applyLogin(
profile: AccountProfile(
userId: "42",
displayName: "测试用户",
phone: "18600000000",
avatarURL: nil
)
)
services.accountContext.replaceScopes(
scenic: [BusinessScope(id: 7, name: "测试景区", kind: .scenic)],
stores: [BusinessScope(id: 9, name: "测试门店", kind: .store, parentScenicId: 7)]
)
services.permissionContext.currentRole = RoleInfo(id: 3, name: "店长")
}
///
private func clearQueueSelection(userId: String, scenicId: Int) {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: ScenicQueueLocalSettings.selectedSpotIdKey)
defaults.removeObject(forKey: ScenicQueueLocalSettings.selectedSpotNameKey)
defaults.removeObject(forKey: ScenicQueueSettingsStore.scopedKey(
base: ScenicQueueLocalSettings.selectedSpotIdKey,
userId: userId,
scenicId: scenicId
))
defaults.removeObject(forKey: ScenicQueueSettingsStore.scopedKey(
base: ScenicQueueLocalSettings.selectedSpotNameKey,
userId: userId,
scenicId: scenicId
))
}
}
///
@MainActor
private final class AppFeatureScenicQueueMock: ScenicQueueServing {
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData {
ScenicQueueStatsData()
}
func scenicQueueHome(
scenicId: Int,
scenicSpotId: Int,
type: Int,
page: Int,
pageSize: Int
) async throws -> ScenicQueueHomeData {
ScenicQueueHomeData()
}
func socketToken() async throws -> SocketTokenResponse { SocketTokenResponse(socketToken: "token") }
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData { ScenicQueueCallData(id: id) }
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData { ScenicQueuePassData(id: id) }
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData { ScenicQueueFinishData(id: id) }
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws {}
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws {}
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData { ScenicQueueSettingData() }
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws {}
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData {
ScenicQueueShootQueueQRCodeData(qrcodeUrl: "")
}
func scenicQueueSettingChangeLog(
scenicId: Int,
scenicSpotId: Int?,
page: Int,
pageSize: Int
) async throws -> ScenicQueueSettingChangeLogData {
ScenicQueueSettingChangeLogData()
}
}
/// URLSession
private final class AppFeatureRecordingURLSession: URLSessionProtocol {
///
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let response = HTTPURLResponse(
url: request.url ?? URL(string: "https://example.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (Data(), response)
}
}