From 79d9fb44596229e618eb0106729364b487ed172e Mon Sep 17 00:00:00 2001 From: Charles Wang Date: Fri, 20 Mar 2026 22:44:47 +1100 Subject: [PATCH 1/9] Refactor ServiceLocator to session-scoped ownership Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- .../Sources/Coordinator/AppCoordinator.swift | 7 +- .../Sources/Coordinator/BaseCoordinator.swift | 2 +- Core/Sources/Core/ServiceLocator.swift | 7 +- Core/Sources/Core/Session.swift | 10 +-- FunApp/FunApp/AppSessionFactory.swift | 6 +- FunApp/FunApp/SceneDelegate.swift | 7 +- Model/Sources/Model/SessionFactory.swift | 2 +- .../CoreServices/AuthenticatedSession.swift | 8 +- .../Services/CoreServices/LoginSession.swift | 6 +- .../Tests/ServicesTests/SessionTests.swift | 82 +++++++++---------- 11 files changed, 65 insertions(+), 74 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 06b17e3c..613e1c32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ Never import upward. ViewModel must NOT import UI or Coordinator. Model must NOT - **Views**: SwiftUI views embedded in UIHostingController via UIViewControllers - **Reactive**: Combine (`@Published`, `CurrentValueSubject`, `.sink`) - **ViewModel → Coordinator**: Optional closures (`onShowDetail`, `onShowProfile`, etc.) -- **DI**: Instance-based ServiceLocator — no `.shared` singleton. `@Service` property wrapper resolves via `static subscript(_enclosingInstance:)` from the enclosing type's `serviceLocator` (requires `ServiceLocatorProvider` conformance). One `ServiceLocator()` created in SceneDelegate, threaded through coordinators → sessions → ViewModels. +- **DI**: Session-scoped ServiceLocator — no `.shared` singleton. Each `Session` creates and owns its own `ServiceLocator`. On session transition, the old ServiceLocator is released with the session (no stale services). `@Service` property wrapper resolves via `static subscript(_enclosingInstance:)` from the enclosing type's `serviceLocator` (requires `ServiceLocatorProvider` conformance). Coordinators and ViewModels receive the current session's ServiceLocator via constructor injection. ## Rule Index Consult these files for detailed guidance (not auto-loaded — read on demand): diff --git a/Coordinator/Sources/Coordinator/AppCoordinator.swift b/Coordinator/Sources/Coordinator/AppCoordinator.swift index 8f547b7b..b90a3c18 100644 --- a/Coordinator/Sources/Coordinator/AppCoordinator.swift +++ b/Coordinator/Sources/Coordinator/AppCoordinator.swift @@ -46,9 +46,9 @@ public final class AppCoordinator: BaseCoordinator { // MARK: - Init - public init(navigationController: UINavigationController, sessionFactory: SessionFactory, serviceLocator: ServiceLocator) { + public init(navigationController: UINavigationController, sessionFactory: SessionFactory) { self.sessionFactory = sessionFactory - super.init(navigationController: navigationController, serviceLocator: serviceLocator) + super.init(navigationController: navigationController, serviceLocator: ServiceLocator()) } // MARK: - Start @@ -67,9 +67,10 @@ public final class AppCoordinator: BaseCoordinator { private func activateSession(for flow: AppFlow) { currentSession?.teardown() - let session = sessionFactory.makeSession(for: flow, serviceLocator: serviceLocator) + let session = sessionFactory.makeSession(for: flow) session.activate() currentSession = session + serviceLocator = session.serviceLocator onSessionActivated?() } diff --git a/Coordinator/Sources/Coordinator/BaseCoordinator.swift b/Coordinator/Sources/Coordinator/BaseCoordinator.swift index e81394a0..5be62cd1 100644 --- a/Coordinator/Sources/Coordinator/BaseCoordinator.swift +++ b/Coordinator/Sources/Coordinator/BaseCoordinator.swift @@ -23,7 +23,7 @@ public protocol Coordinator: AnyObject { open class BaseCoordinator: Coordinator, ServiceLocatorProvider { public let navigationController: UINavigationController - public let serviceLocator: ServiceLocator + public internal(set) var serviceLocator: ServiceLocator private var isTransitioning: Bool { navigationController.transitionCoordinator != nil diff --git a/Core/Sources/Core/ServiceLocator.swift b/Core/Sources/Core/ServiceLocator.swift index 0f4b414d..e84222ac 100644 --- a/Core/Sources/Core/ServiceLocator.swift +++ b/Core/Sources/Core/ServiceLocator.swift @@ -4,8 +4,10 @@ // // Central registry for dependency injection. // -// Instance-based DI: the app creates one ServiceLocator() at the top (SceneDelegate) -// and threads it through coordinators, sessions, and ViewModels. No global singleton. +// Instance-based DI: each Session creates its own ServiceLocator and registers +// services into it. On session transition, the old ServiceLocator is released +// with the session — no stale services. Coordinators and ViewModels receive +// the current session's ServiceLocator via constructor injection. // import Foundation @@ -60,6 +62,7 @@ public class ServiceLocator { // MARK: - ServiceLocatorProvider /// Any type that holds a ServiceLocator instance for instance-based DI resolution. +@MainActor public protocol ServiceLocatorProvider { var serviceLocator: ServiceLocator { get } } diff --git a/Core/Sources/Core/Session.swift b/Core/Sources/Core/Session.swift index d55436cc..4fae20ba 100644 --- a/Core/Sources/Core/Session.swift +++ b/Core/Sources/Core/Session.swift @@ -8,13 +8,13 @@ import Foundation /// A session represents a scoped set of services for a given app flow. -/// When transitioning between flows, the old session is torn down and a new one activated. -/// Each session operates on the ServiceLocator instance it was created with. +/// Each session owns its own ServiceLocator — when the session is released, +/// its services are released with it. No stale services across transitions. @MainActor -public protocol Session: AnyObject { - /// Register services for this session into the ServiceLocator +public protocol Session: AnyObject, ServiceLocatorProvider { + /// Register services for this session into its ServiceLocator func activate() - /// Tear down and unregister services for this session + /// Tear down session-specific state (e.g. clear user data) func teardown() } diff --git a/FunApp/FunApp/AppSessionFactory.swift b/FunApp/FunApp/AppSessionFactory.swift index f9106a01..6df32c00 100644 --- a/FunApp/FunApp/AppSessionFactory.swift +++ b/FunApp/FunApp/AppSessionFactory.swift @@ -11,12 +11,12 @@ import FunServices @MainActor struct AppSessionFactory: SessionFactory { - func makeSession(for flow: AppFlow, serviceLocator: ServiceLocator) -> Session { + func makeSession(for flow: AppFlow) -> Session { switch flow { case .login: - return LoginSession(serviceLocator: serviceLocator) + return LoginSession() case .main: - return AuthenticatedSession(serviceLocator: serviceLocator) + return AuthenticatedSession() } } } diff --git a/FunApp/FunApp/SceneDelegate.swift b/FunApp/FunApp/SceneDelegate.swift index 8c24f1ca..5edef9cc 100644 --- a/FunApp/FunApp/SceneDelegate.swift +++ b/FunApp/FunApp/SceneDelegate.swift @@ -17,7 +17,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, ServiceLocatorProvider var window: UIWindow? var appCoordinator: AppCoordinator? - let serviceLocator = ServiceLocator() + var serviceLocator: ServiceLocator { appCoordinator!.serviceLocator } private var darkModeCancellable: AnyCancellable? @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol @@ -43,9 +43,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, ServiceLocatorProvider // Create and start app coordinator with session factory let coordinator = AppCoordinator( navigationController: navigationController, - sessionFactory: AppSessionFactory(), - serviceLocator: serviceLocator + sessionFactory: AppSessionFactory() ) + self.appCoordinator = coordinator // Re-subscribe to dark mode after each session activation (login → main → login) // so the subscription always points to the current session's FeatureToggleService @@ -54,7 +54,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, ServiceLocatorProvider } coordinator.start() - self.appCoordinator = coordinator window.makeKeyAndVisible() diff --git a/Model/Sources/Model/SessionFactory.swift b/Model/Sources/Model/SessionFactory.swift index 928ec966..0ba1233c 100644 --- a/Model/Sources/Model/SessionFactory.swift +++ b/Model/Sources/Model/SessionFactory.swift @@ -10,5 +10,5 @@ import FunCore /// Creates the appropriate session for a given app flow @MainActor public protocol SessionFactory { - func makeSession(for flow: AppFlow, serviceLocator: ServiceLocator) -> Session + func makeSession(for flow: AppFlow) -> Session } diff --git a/Services/Sources/Services/CoreServices/AuthenticatedSession.swift b/Services/Sources/Services/CoreServices/AuthenticatedSession.swift index edc65cde..aa19ba40 100644 --- a/Services/Sources/Services/CoreServices/AuthenticatedSession.swift +++ b/Services/Sources/Services/CoreServices/AuthenticatedSession.swift @@ -9,14 +9,12 @@ import FunCore import FunModel @MainActor -public final class AuthenticatedSession: Session, ServiceLocatorProvider { +public final class AuthenticatedSession: Session { - public let serviceLocator: ServiceLocator + public let serviceLocator = ServiceLocator() @Service(.favorites) private var favoritesService: FavoritesServiceProtocol - public init(serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator - } + public init() {} public func activate() { let featureToggleService = DefaultFeatureToggleService() diff --git a/Services/Sources/Services/CoreServices/LoginSession.swift b/Services/Sources/Services/CoreServices/LoginSession.swift index a58e0c02..f03a9b9e 100644 --- a/Services/Sources/Services/CoreServices/LoginSession.swift +++ b/Services/Sources/Services/CoreServices/LoginSession.swift @@ -10,11 +10,9 @@ import FunCore @MainActor public final class LoginSession: Session { - private let serviceLocator: ServiceLocator + public let serviceLocator = ServiceLocator() - public init(serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator - } + public init() {} public func activate() { serviceLocator.register(DefaultLoggerService(), for: .logger) diff --git a/Services/Tests/ServicesTests/SessionTests.swift b/Services/Tests/ServicesTests/SessionTests.swift index cac9fc91..496f0148 100644 --- a/Services/Tests/ServicesTests/SessionTests.swift +++ b/Services/Tests/ServicesTests/SessionTests.swift @@ -23,14 +23,13 @@ struct SessionTests { @Test("Registers core services and feature toggles") func registersCoreServices() { - let locator = ServiceLocator() - let session = LoginSession(serviceLocator: locator) + let session = LoginSession() session.activate() - #expect(locator.isRegistered(for: .logger)) - #expect(locator.isRegistered(for: .network)) - #expect(locator.isRegistered(for: .featureToggles)) - #expect(!locator.isRegistered(for: .favorites)) + #expect(session.serviceLocator.isRegistered(for: .logger)) + #expect(session.serviceLocator.isRegistered(for: .network)) + #expect(session.serviceLocator.isRegistered(for: .featureToggles)) + #expect(!session.serviceLocator.isRegistered(for: .favorites)) } } @@ -42,16 +41,15 @@ struct SessionTests { @Test("Registers all six services") func registersAllServices() { - let locator = ServiceLocator() - let session = AuthenticatedSession(serviceLocator: locator) + let session = AuthenticatedSession() session.activate() - #expect(locator.isRegistered(for: .logger)) - #expect(locator.isRegistered(for: .network)) - #expect(locator.isRegistered(for: .favorites)) - #expect(locator.isRegistered(for: .toast)) - #expect(locator.isRegistered(for: .featureToggles)) - #expect(locator.isRegistered(for: .ai)) + #expect(session.serviceLocator.isRegistered(for: .logger)) + #expect(session.serviceLocator.isRegistered(for: .network)) + #expect(session.serviceLocator.isRegistered(for: .favorites)) + #expect(session.serviceLocator.isRegistered(for: .toast)) + #expect(session.serviceLocator.isRegistered(for: .featureToggles)) + #expect(session.serviceLocator.isRegistered(for: .ai)) } } @@ -61,68 +59,62 @@ struct SessionTests { @MainActor struct SessionTransitionTests { - @Test("Login to main: authenticated services become available") + @Test("Login to main: each session has isolated services") func loginToMainTransition() { - let locator = ServiceLocator() - - // Start with login session - let login = LoginSession(serviceLocator: locator) + let login = LoginSession() login.activate() - #expect(!locator.isRegistered(for: .favorites)) + #expect(!login.serviceLocator.isRegistered(for: .favorites)) - // Transition to main (teardown doesn't reset — activate overwrites) + // Transition: new session gets its own ServiceLocator login.teardown() - let main = AuthenticatedSession(serviceLocator: locator) + let main = AuthenticatedSession() main.activate() - #expect(locator.isRegistered(for: .favorites)) - #expect(locator.isRegistered(for: .toast)) - #expect(locator.isRegistered(for: .featureToggles)) + #expect(main.serviceLocator.isRegistered(for: .favorites)) + #expect(main.serviceLocator.isRegistered(for: .toast)) + #expect(main.serviceLocator.isRegistered(for: .featureToggles)) + + // Old session's locator is unaffected + #expect(!login.serviceLocator.isRegistered(for: .favorites)) } - @Test("Main to login: core services are overwritten with fresh instances") + @Test("Main to login: no stale services from previous session") func mainToLoginTransition() { - let locator = ServiceLocator() - - // Start with main session - let main = AuthenticatedSession(serviceLocator: locator) + let main = AuthenticatedSession() main.activate() - #expect(locator.isRegistered(for: .favorites)) + #expect(main.serviceLocator.isRegistered(for: .favorites)) - // Transition to login — favorites stays registered (stale but harmless) - // until next AuthenticatedSession.activate() overwrites it main.teardown() - let login = LoginSession(serviceLocator: locator) + let login = LoginSession() login.activate() - #expect(locator.isRegistered(for: .logger)) - #expect(locator.isRegistered(for: .network)) - #expect(locator.isRegistered(for: .featureToggles)) - // favorites remains registered (not cleared) — safe for live @Service references - #expect(locator.isRegistered(for: .favorites)) + // Login session has only its own services — no stale favorites + #expect(login.serviceLocator.isRegistered(for: .logger)) + #expect(login.serviceLocator.isRegistered(for: .network)) + #expect(login.serviceLocator.isRegistered(for: .featureToggles)) + #expect(!login.serviceLocator.isRegistered(for: .favorites)) } @Test("Favorites are fresh after session transition") func favoritesDoNotPersistAcrossSessions() { - let locator = ServiceLocator() UserDefaults.standard.removeObject(forKey: UserDefaultsKey.favorites) // First authenticated session: add a favorite - let main1 = AuthenticatedSession(serviceLocator: locator) + let main1 = AuthenticatedSession() main1.activate() - let favorites1: FavoritesServiceProtocol = locator.resolve(for: .favorites) + let favorites1: FavoritesServiceProtocol = main1.serviceLocator.resolve(for: .favorites) favorites1.addFavorite("test-item") #expect(favorites1.isFavorited("test-item")) - // Tear down — this clears UserDefaults favorites but keeps services registered + // Tear down — this clears UserDefaults favorites main1.teardown() // New session — favorites should be fresh (UserDefaults cleared + new instance) - let main2 = AuthenticatedSession(serviceLocator: locator) + let main2 = AuthenticatedSession() main2.activate() - let favorites2: FavoritesServiceProtocol = locator.resolve(for: .favorites) + let favorites2: FavoritesServiceProtocol = main2.serviceLocator.resolve(for: .favorites) #expect(!favorites2.isFavorited("test-item")) } } From ce51c79def44821b55de7dc319ad83fcc69728a3 Mon Sep 17 00:00:00 2001 From: Charles Wang Date: Fri, 20 Mar 2026 22:50:03 +1100 Subject: [PATCH 2/9] Update docs for session-scoped ServiceLocator Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +++--- .../Services/CoreServices/AuthenticatedSession.swift | 2 +- Services/Sources/Services/CoreServices/LoginSession.swift | 2 +- ai-rules/general.md | 4 ++-- ai-rules/swift-style.md | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a97580cd..b41c8174 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,8 @@ AuthenticatedSession: logger, network, featureToggles, favourites, toast, ai ``` ```swift -// Sessions activate/teardown automatically on flow transitions -protocol Session: AnyObject { +// Each session owns its own ServiceLocator — released on session transition +protocol Session: AnyObject, ServiceLocatorProvider { func activate() // register services func teardown() // clean up session state } @@ -159,7 +159,7 @@ class HomeViewModel: ObservableObject, ServiceLocatorProvider { ### DI Evolution -The current `@Service` property wrapper uses `static subscript(_enclosingInstance:)` to resolve from the enclosing instance's `serviceLocator`. This eliminates the global singleton (`ServiceLocator.shared`) — the app creates one instance at the top and threads it through coordinators, sessions, and ViewModels. +The current `@Service` property wrapper uses `static subscript(_enclosingInstance:)` to resolve from the enclosing instance's `serviceLocator`. This eliminates the global singleton (`ServiceLocator.shared`) — each `Session` creates its own `ServiceLocator`, and coordinators/ViewModels receive the current session's locator via constructor injection. On session transition, the old locator is released with the session. **Future**: A Swift Macro could auto-generate `ServiceLocatorProvider` conformance + the `serviceLocator` stored property, eliminating the remaining boilerplate. On `@Observable` classes it could also auto-add `@ObservationIgnored` to each `@Service` property. diff --git a/Services/Sources/Services/CoreServices/AuthenticatedSession.swift b/Services/Sources/Services/CoreServices/AuthenticatedSession.swift index aa19ba40..6b1c6009 100644 --- a/Services/Sources/Services/CoreServices/AuthenticatedSession.swift +++ b/Services/Sources/Services/CoreServices/AuthenticatedSession.swift @@ -32,6 +32,6 @@ public final class AuthenticatedSession: Session { favoritesService.resetFavorites() // Don't call serviceLocator.reset() — with @Service property wrapper, // live views may still resolve services during SwiftUI teardown. - // The next session's activate() overwrites with fresh instances. + // The next session creates its own ServiceLocator with fresh instances. } } diff --git a/Services/Sources/Services/CoreServices/LoginSession.swift b/Services/Sources/Services/CoreServices/LoginSession.swift index f03a9b9e..9033c6d7 100644 --- a/Services/Sources/Services/CoreServices/LoginSession.swift +++ b/Services/Sources/Services/CoreServices/LoginSession.swift @@ -23,6 +23,6 @@ public final class LoginSession: Session { public func teardown() { // Don't call serviceLocator.reset() — with @Service property wrapper, // live views may still resolve services during SwiftUI teardown. - // The next session's activate() overwrites with fresh instances. + // The next session creates its own ServiceLocator with fresh instances. } } diff --git a/ai-rules/general.md b/ai-rules/general.md index 5c2e2f40..95a857c6 100644 --- a/ai-rules/general.md +++ b/ai-rules/general.md @@ -96,8 +96,8 @@ Two session types control which services are available: | `AuthenticatedSession` | logger, network, favorites, toast, featureToggles, ai | Main app | - `activate()` registers services on the instance's `serviceLocator` -- `teardown()` calls `favoritesService.resetFavorites()` (AuthenticatedSession). Does NOT call `serviceLocator.reset()` — live views may still resolve services via `@Service` during SwiftUI teardown. The next session's `activate()` overwrites with fresh instances. -- `AppSessionFactory` creates the right session for each `AppFlow` case, passing the `serviceLocator` instance +- `teardown()` calls `favoritesService.resetFavorites()` (AuthenticatedSession). Does NOT call `serviceLocator.reset()` — live views may still resolve services via `@Service` during SwiftUI teardown. The old session's ServiceLocator is released when the session is deallocated. +- `AppSessionFactory` creates the right session for each `AppFlow` case. Each session creates its own ServiceLocator internally. ## Protocol Placement diff --git a/ai-rules/swift-style.md b/ai-rules/swift-style.md index 10e01b5d..383bb8a8 100644 --- a/ai-rules/swift-style.md +++ b/ai-rules/swift-style.md @@ -74,7 +74,7 @@ class MyViewModel: ObservableObject, ServiceLocatorProvider { - Registration happens in `LoginSession.activate()` and `AuthenticatedSession.activate()` on the instance - Resolution crashes if service isn't registered — this is intentional - Never call `serviceLocator.resolve()` directly in Views -- One `ServiceLocator()` is created in SceneDelegate and threaded through the entire object graph +- Each `Session` creates its own `ServiceLocator()`. Coordinators and ViewModels receive the current session's ServiceLocator via constructor injection. ## Naming Conventions From 2e12f337cfea9521fdffaf0ba464f29a5f47e683 Mon Sep 17 00:00:00 2001 From: Charles Wang Date: Fri, 20 Mar 2026 23:07:19 +1100 Subject: [PATCH 3/9] Remove throwaway ServiceLocator from AppCoordinator init Co-Authored-By: Claude Opus 4.6 (1M context) --- Coordinator/Sources/Coordinator/AppCoordinator.swift | 2 +- Coordinator/Sources/Coordinator/BaseCoordinator.swift | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Coordinator/Sources/Coordinator/AppCoordinator.swift b/Coordinator/Sources/Coordinator/AppCoordinator.swift index b90a3c18..09c252b6 100644 --- a/Coordinator/Sources/Coordinator/AppCoordinator.swift +++ b/Coordinator/Sources/Coordinator/AppCoordinator.swift @@ -48,7 +48,7 @@ public final class AppCoordinator: BaseCoordinator { public init(navigationController: UINavigationController, sessionFactory: SessionFactory) { self.sessionFactory = sessionFactory - super.init(navigationController: navigationController, serviceLocator: ServiceLocator()) + super.init(navigationController: navigationController) } // MARK: - Start diff --git a/Coordinator/Sources/Coordinator/BaseCoordinator.swift b/Coordinator/Sources/Coordinator/BaseCoordinator.swift index 5be62cd1..55116f61 100644 --- a/Coordinator/Sources/Coordinator/BaseCoordinator.swift +++ b/Coordinator/Sources/Coordinator/BaseCoordinator.swift @@ -23,7 +23,7 @@ public protocol Coordinator: AnyObject { open class BaseCoordinator: Coordinator, ServiceLocatorProvider { public let navigationController: UINavigationController - public internal(set) var serviceLocator: ServiceLocator + public internal(set) var serviceLocator = ServiceLocator() private var isTransitioning: Bool { navigationController.transitionCoordinator != nil @@ -33,6 +33,11 @@ open class BaseCoordinator: Coordinator, ServiceLocatorProvider { /// Handles deep links arriving mid-transition without full queue complexity. private var pendingAction: (@MainActor () -> Void)? + /// For root coordinators that receive serviceLocator later (e.g. from a session) + public init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + public init(navigationController: UINavigationController, serviceLocator: ServiceLocator) { self.navigationController = navigationController self.serviceLocator = serviceLocator From a70476a4050bfd2ea87cf8778abc5700803d1639 Mon Sep 17 00:00:00 2001 From: Charles Wang Date: Fri, 20 Mar 2026 23:12:02 +1100 Subject: [PATCH 4/9] Remove serviceLocator from BaseCoordinator BaseCoordinator is now pure navigation. Each coordinator that creates ViewModels stores its own serviceLocator reference. Co-Authored-By: Claude Opus 4.6 (1M context) --- Coordinator/Sources/Coordinator/AppCoordinator.swift | 3 ++- Coordinator/Sources/Coordinator/BaseCoordinator.swift | 11 +---------- Coordinator/Sources/Coordinator/HomeCoordinator.swift | 8 ++++++++ .../Sources/Coordinator/ItemsCoordinator.swift | 7 +++++++ .../Sources/Coordinator/LoginCoordinator.swift | 8 ++++++++ .../Sources/Coordinator/SettingsCoordinator.swift | 8 ++++++++ 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/Coordinator/Sources/Coordinator/AppCoordinator.swift b/Coordinator/Sources/Coordinator/AppCoordinator.swift index 09c252b6..3cf7efd3 100644 --- a/Coordinator/Sources/Coordinator/AppCoordinator.swift +++ b/Coordinator/Sources/Coordinator/AppCoordinator.swift @@ -13,10 +13,11 @@ import FunUI import FunViewModel /// Main app coordinator that manages the root navigation and app flow -public final class AppCoordinator: BaseCoordinator { +public final class AppCoordinator: BaseCoordinator, ServiceLocatorProvider { // MARK: - Services + public private(set) var serviceLocator = ServiceLocator() @Service(.logger) private var logger: LoggerService // MARK: - Session Management diff --git a/Coordinator/Sources/Coordinator/BaseCoordinator.swift b/Coordinator/Sources/Coordinator/BaseCoordinator.swift index 55116f61..5c49ef1c 100644 --- a/Coordinator/Sources/Coordinator/BaseCoordinator.swift +++ b/Coordinator/Sources/Coordinator/BaseCoordinator.swift @@ -7,8 +7,6 @@ import UIKit -import FunCore - // MARK: - Coordinator Protocol @MainActor @@ -20,10 +18,9 @@ public protocol Coordinator: AnyObject { // MARK: - Base Coordinator @MainActor -open class BaseCoordinator: Coordinator, ServiceLocatorProvider { +open class BaseCoordinator: Coordinator { public let navigationController: UINavigationController - public internal(set) var serviceLocator = ServiceLocator() private var isTransitioning: Bool { navigationController.transitionCoordinator != nil @@ -33,16 +30,10 @@ open class BaseCoordinator: Coordinator, ServiceLocatorProvider { /// Handles deep links arriving mid-transition without full queue complexity. private var pendingAction: (@MainActor () -> Void)? - /// For root coordinators that receive serviceLocator later (e.g. from a session) public init(navigationController: UINavigationController) { self.navigationController = navigationController } - public init(navigationController: UINavigationController, serviceLocator: ServiceLocator) { - self.navigationController = navigationController - self.serviceLocator = serviceLocator - } - open func start() { // Override in subclasses } diff --git a/Coordinator/Sources/Coordinator/HomeCoordinator.swift b/Coordinator/Sources/Coordinator/HomeCoordinator.swift index a44801d5..5ff2d35b 100644 --- a/Coordinator/Sources/Coordinator/HomeCoordinator.swift +++ b/Coordinator/Sources/Coordinator/HomeCoordinator.swift @@ -7,6 +7,7 @@ import UIKit +import FunCore import FunModel import FunUI import FunViewModel @@ -15,11 +16,18 @@ public final class HomeCoordinator: BaseCoordinator { // MARK: - Properties + private let serviceLocator: ServiceLocator + /// Callback to notify parent coordinator of logout public var onLogout: (() -> Void)? private var isShowingDetail = false + public init(navigationController: UINavigationController, serviceLocator: ServiceLocator) { + self.serviceLocator = serviceLocator + super.init(navigationController: navigationController) + } + override public func start() { let viewModel = HomeViewModel(serviceLocator: serviceLocator) viewModel.onShowDetail = { [weak self] item in self?.showDetail(for: item) } diff --git a/Coordinator/Sources/Coordinator/ItemsCoordinator.swift b/Coordinator/Sources/Coordinator/ItemsCoordinator.swift index 765d9038..81a52cfd 100644 --- a/Coordinator/Sources/Coordinator/ItemsCoordinator.swift +++ b/Coordinator/Sources/Coordinator/ItemsCoordinator.swift @@ -7,14 +7,21 @@ import UIKit +import FunCore import FunModel import FunUI import FunViewModel public final class ItemsCoordinator: BaseCoordinator { + private let serviceLocator: ServiceLocator private var isShowingDetail = false + public init(navigationController: UINavigationController, serviceLocator: ServiceLocator) { + self.serviceLocator = serviceLocator + super.init(navigationController: navigationController) + } + override public func start() { let viewModel = ItemsViewModel(serviceLocator: serviceLocator) viewModel.onShowDetail = { [weak self] item in self?.showDetail(for: item) } diff --git a/Coordinator/Sources/Coordinator/LoginCoordinator.swift b/Coordinator/Sources/Coordinator/LoginCoordinator.swift index b7cda9d2..43e4d1a5 100644 --- a/Coordinator/Sources/Coordinator/LoginCoordinator.swift +++ b/Coordinator/Sources/Coordinator/LoginCoordinator.swift @@ -7,6 +7,7 @@ import UIKit +import FunCore import FunUI import FunViewModel @@ -14,9 +15,16 @@ public final class LoginCoordinator: BaseCoordinator { // MARK: - Properties + private let serviceLocator: ServiceLocator + /// Callback to notify parent coordinator of successful login public var onLoginSuccess: (() -> Void)? + public init(navigationController: UINavigationController, serviceLocator: ServiceLocator) { + self.serviceLocator = serviceLocator + super.init(navigationController: navigationController) + } + override public func start() { let viewModel = LoginViewModel(serviceLocator: serviceLocator) viewModel.onLogin = { [weak self] in self?.onLoginSuccess?() } diff --git a/Coordinator/Sources/Coordinator/SettingsCoordinator.swift b/Coordinator/Sources/Coordinator/SettingsCoordinator.swift index 0e44af90..9081fe50 100644 --- a/Coordinator/Sources/Coordinator/SettingsCoordinator.swift +++ b/Coordinator/Sources/Coordinator/SettingsCoordinator.swift @@ -7,11 +7,19 @@ import UIKit +import FunCore import FunUI import FunViewModel public final class SettingsCoordinator: BaseCoordinator { + private let serviceLocator: ServiceLocator + + public init(navigationController: UINavigationController, serviceLocator: ServiceLocator) { + self.serviceLocator = serviceLocator + super.init(navigationController: navigationController) + } + override public func start() { let viewModel = SettingsViewModel(serviceLocator: serviceLocator) let viewController = SettingsViewController(viewModel: viewModel) From f66f96e81e44dfbcbc55231c32047139de54a983 Mon Sep 17 00:00:00 2001 From: Charles Wang Date: Fri, 20 Mar 2026 23:29:06 +1100 Subject: [PATCH 5/9] Pass session instead of serviceLocator through coordinators and VMs - Add SessionProvider protocol: auto-provides serviceLocator from session - ViewModels conform to SessionProvider, store session instead of serviceLocator - Coordinators pass session to VMs, no force unwraps - SceneDelegate resolves services directly, no ServiceLocatorProvider conformance - BaseCoordinator is pure navigation, no DI concerns - Tests use MockSession wrapper Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/Coordinator/AppCoordinator.swift | 34 +++++----- .../Sources/Coordinator/HomeCoordinator.swift | 12 ++-- .../Coordinator/ItemsCoordinator.swift | 10 +-- .../Coordinator/LoginCoordinator.swift | 8 +-- .../Coordinator/SettingsCoordinator.swift | 8 +-- Core/Sources/Core/ServiceLocator.swift | 19 +++++- FunApp/FunApp/SceneDelegate.swift | 7 +-- .../ModelTestSupport/MockSession.swift | 21 +++++++ UI/Sources/UI/HomeTabBarController.swift | 8 +-- .../UI/Preview Content/PreviewHelper.swift | 41 +++++++----- .../DetailViewSnapshotTests.swift | 10 +-- .../SnapshotTests/HomeViewSnapshotTests.swift | 14 ++--- .../ItemsViewSnapshotTests.swift | 10 +-- .../LoginViewSnapshotTests.swift | 10 +-- .../ProfileViewSnapshotTests.swift | 8 +-- .../SettingsViewSnapshotTests.swift | 12 ++-- .../ViewModel/Detail/DetailViewModel.swift | 8 +-- .../ViewModel/Home/HomeViewModel.swift | 8 +-- .../HomeTabBar/HomeTabBarViewModel.swift | 8 +-- .../ViewModel/Items/ItemsViewModel.swift | 8 +-- .../ViewModel/Login/LoginViewModel.swift | 8 +-- .../ViewModel/Profile/ProfileViewModel.swift | 8 +-- .../Settings/SettingsViewModel.swift | 8 +-- .../ViewModelTests/DetailViewModelTests.swift | 40 ++++++------ .../HomeTabBarViewModelTests.swift | 22 +++---- .../ViewModelTests/HomeViewModelTests.swift | 41 ++++++------ .../ViewModelTests/ItemsViewModelTests.swift | 62 +++++++++---------- .../ViewModelTests/LoginViewModelTests.swift | 20 +++--- .../ProfileViewModelTests.swift | 14 ++--- .../SettingsViewModelTests.swift | 42 ++++++------- 30 files changed, 287 insertions(+), 242 deletions(-) create mode 100644 Model/Sources/ModelTestSupport/MockSession.swift diff --git a/Coordinator/Sources/Coordinator/AppCoordinator.swift b/Coordinator/Sources/Coordinator/AppCoordinator.swift index 3cf7efd3..970ad9bb 100644 --- a/Coordinator/Sources/Coordinator/AppCoordinator.swift +++ b/Coordinator/Sources/Coordinator/AppCoordinator.swift @@ -55,35 +55,37 @@ public final class AppCoordinator: BaseCoordinator, ServiceLocatorProvider { // MARK: - Start override public func start() { - activateSession(for: currentFlow) + let session = activateSession(for: currentFlow) switch currentFlow { case .login: - showLoginFlow() + showLoginFlow(session: session) case .main: - showMainFlow() + showMainFlow(session: session) } } // MARK: - Session Lifecycle - private func activateSession(for flow: AppFlow) { + @discardableResult + private func activateSession(for flow: AppFlow) -> Session { currentSession?.teardown() let session = sessionFactory.makeSession(for: flow) session.activate() currentSession = session serviceLocator = session.serviceLocator onSessionActivated?() + return session } // MARK: - Flow Management - private func showLoginFlow() { + private func showLoginFlow(session: Session) { // Clear any existing main flow coordinators clearMainFlowCoordinators() let loginCoordinator = LoginCoordinator( navigationController: navigationController, - serviceLocator: serviceLocator + session: session ) loginCoordinator.onLoginSuccess = { [weak self] in self?.transitionToMainFlow() @@ -92,7 +94,7 @@ public final class AppCoordinator: BaseCoordinator, ServiceLocatorProvider { loginCoordinator.start() } - private func showMainFlow() { + private func showMainFlow(session: Session) { // Clear login coordinator loginCoordinator = nil @@ -129,21 +131,21 @@ public final class AppCoordinator: BaseCoordinator, ServiceLocatorProvider { settingsNavController.tabBarItem.accessibilityIdentifier = AccessibilityID.Tabs.settings // Create view model for tab bar - let tabBarViewModel = HomeTabBarViewModel(serviceLocator: serviceLocator) + let tabBarViewModel = HomeTabBarViewModel(session: session) self.tabBarViewModel = tabBarViewModel // Create and store coordinators for each tab let homeCoordinator = HomeCoordinator( navigationController: homeNavController, - serviceLocator: serviceLocator + session: session ) let itemsCoordinator = ItemsCoordinator( navigationController: itemsNavController, - serviceLocator: serviceLocator + session: session ) let settingsCoordinator = SettingsCoordinator( navigationController: settingsNavController, - serviceLocator: serviceLocator + session: session ) // Set up logout callback through home coordinator (Profile modal) @@ -169,7 +171,7 @@ public final class AppCoordinator: BaseCoordinator, ServiceLocatorProvider { itemsNavController, settingsNavController ], - serviceLocator: serviceLocator + session: session ) // Set as root (tab bar doesn't push, it's the container) @@ -180,8 +182,8 @@ public final class AppCoordinator: BaseCoordinator, ServiceLocatorProvider { private func transitionToMainFlow() { currentFlow = .main - activateSession(for: .main) - showMainFlow() + let session = activateSession(for: .main) + showMainFlow(session: session) if let deepLink = pendingDeepLink { pendingDeepLink = nil @@ -192,8 +194,8 @@ public final class AppCoordinator: BaseCoordinator, ServiceLocatorProvider { private func transitionToLoginFlow() { currentFlow = .login pendingDeepLink = nil - activateSession(for: .login) - showLoginFlow() + let session = activateSession(for: .login) + showLoginFlow(session: session) } // MARK: - Cleanup diff --git a/Coordinator/Sources/Coordinator/HomeCoordinator.swift b/Coordinator/Sources/Coordinator/HomeCoordinator.swift index 5ff2d35b..ea91906a 100644 --- a/Coordinator/Sources/Coordinator/HomeCoordinator.swift +++ b/Coordinator/Sources/Coordinator/HomeCoordinator.swift @@ -16,20 +16,20 @@ public final class HomeCoordinator: BaseCoordinator { // MARK: - Properties - private let serviceLocator: ServiceLocator + private let session: Session /// Callback to notify parent coordinator of logout public var onLogout: (() -> Void)? private var isShowingDetail = false - public init(navigationController: UINavigationController, serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + public init(navigationController: UINavigationController, session: Session) { + self.session = session super.init(navigationController: navigationController) } override public func start() { - let viewModel = HomeViewModel(serviceLocator: serviceLocator) + let viewModel = HomeViewModel(session: session) viewModel.onShowDetail = { [weak self] item in self?.showDetail(for: item) } viewModel.onShowProfile = { [weak self] in self?.showProfile() } @@ -43,7 +43,7 @@ public final class HomeCoordinator: BaseCoordinator { guard !isShowingDetail else { return } isShowingDetail = true - let viewModel = DetailViewModel(item: item, serviceLocator: serviceLocator) + let viewModel = DetailViewModel(item: item, session: session) viewModel.onPop = { [weak self] in self?.isShowingDetail = false } viewModel.onShare = { [weak self] text in self?.share(text: text) } @@ -54,7 +54,7 @@ public final class HomeCoordinator: BaseCoordinator { public func showProfile() { let profileNavController = UINavigationController() - let viewModel = ProfileViewModel(serviceLocator: serviceLocator) + let viewModel = ProfileViewModel(session: session) viewModel.onDismiss = { [weak self] in self?.safeDismiss() } viewModel.onLogout = { [weak self] in self?.safeDismiss { self?.onLogout?() } } viewModel.onGoToItems = { [weak self] in diff --git a/Coordinator/Sources/Coordinator/ItemsCoordinator.swift b/Coordinator/Sources/Coordinator/ItemsCoordinator.swift index 81a52cfd..c2a20e81 100644 --- a/Coordinator/Sources/Coordinator/ItemsCoordinator.swift +++ b/Coordinator/Sources/Coordinator/ItemsCoordinator.swift @@ -14,16 +14,16 @@ import FunViewModel public final class ItemsCoordinator: BaseCoordinator { - private let serviceLocator: ServiceLocator + private let session: Session private var isShowingDetail = false - public init(navigationController: UINavigationController, serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + public init(navigationController: UINavigationController, session: Session) { + self.session = session super.init(navigationController: navigationController) } override public func start() { - let viewModel = ItemsViewModel(serviceLocator: serviceLocator) + let viewModel = ItemsViewModel(session: session) viewModel.onShowDetail = { [weak self] item in self?.showDetail(for: item) } let viewController = ItemsViewController(viewModel: viewModel) @@ -36,7 +36,7 @@ public final class ItemsCoordinator: BaseCoordinator { guard !isShowingDetail else { return } isShowingDetail = true - let viewModel = DetailViewModel(item: item, serviceLocator: serviceLocator) + let viewModel = DetailViewModel(item: item, session: session) viewModel.onPop = { [weak self] in self?.isShowingDetail = false } viewModel.onShare = { [weak self] text in self?.share(text: text) } diff --git a/Coordinator/Sources/Coordinator/LoginCoordinator.swift b/Coordinator/Sources/Coordinator/LoginCoordinator.swift index 43e4d1a5..dfb83e5b 100644 --- a/Coordinator/Sources/Coordinator/LoginCoordinator.swift +++ b/Coordinator/Sources/Coordinator/LoginCoordinator.swift @@ -15,18 +15,18 @@ public final class LoginCoordinator: BaseCoordinator { // MARK: - Properties - private let serviceLocator: ServiceLocator + private let session: Session /// Callback to notify parent coordinator of successful login public var onLoginSuccess: (() -> Void)? - public init(navigationController: UINavigationController, serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + public init(navigationController: UINavigationController, session: Session) { + self.session = session super.init(navigationController: navigationController) } override public func start() { - let viewModel = LoginViewModel(serviceLocator: serviceLocator) + let viewModel = LoginViewModel(session: session) viewModel.onLogin = { [weak self] in self?.onLoginSuccess?() } let viewController = LoginViewController(viewModel: viewModel) diff --git a/Coordinator/Sources/Coordinator/SettingsCoordinator.swift b/Coordinator/Sources/Coordinator/SettingsCoordinator.swift index 9081fe50..040153cf 100644 --- a/Coordinator/Sources/Coordinator/SettingsCoordinator.swift +++ b/Coordinator/Sources/Coordinator/SettingsCoordinator.swift @@ -13,15 +13,15 @@ import FunViewModel public final class SettingsCoordinator: BaseCoordinator { - private let serviceLocator: ServiceLocator + private let session: Session - public init(navigationController: UINavigationController, serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + public init(navigationController: UINavigationController, session: Session) { + self.session = session super.init(navigationController: navigationController) } override public func start() { - let viewModel = SettingsViewModel(serviceLocator: serviceLocator) + let viewModel = SettingsViewModel(session: session) let viewController = SettingsViewController(viewModel: viewModel) navigationController.setViewControllers([viewController], animated: false) } diff --git a/Core/Sources/Core/ServiceLocator.swift b/Core/Sources/Core/ServiceLocator.swift index e84222ac..8143fbfe 100644 --- a/Core/Sources/Core/ServiceLocator.swift +++ b/Core/Sources/Core/ServiceLocator.swift @@ -4,10 +4,10 @@ // // Central registry for dependency injection. // -// Instance-based DI: each Session creates its own ServiceLocator and registers +// Session-scoped DI: each Session creates its own ServiceLocator and registers // services into it. On session transition, the old ServiceLocator is released -// with the session — no stale services. Coordinators and ViewModels receive -// the current session's ServiceLocator via constructor injection. +// with the session — no stale services. ViewModels receive the session and +// conform to SessionProvider, which auto-provides serviceLocator for @Service. // import Foundation @@ -67,6 +67,19 @@ public protocol ServiceLocatorProvider { var serviceLocator: ServiceLocator { get } } +// MARK: - SessionProvider + +/// Types that hold a Session reference. Provides `serviceLocator` automatically +/// from `session.serviceLocator`, so conformers only need to store `let session: Session`. +@MainActor +public protocol SessionProvider: ServiceLocatorProvider { + var session: Session { get } +} + +extension SessionProvider { + public var serviceLocator: ServiceLocator { session.serviceLocator } +} + // MARK: - @Service Property Wrapper /// Property wrapper that resolves services from the enclosing instance's ServiceLocator. diff --git a/FunApp/FunApp/SceneDelegate.swift b/FunApp/FunApp/SceneDelegate.swift index 5edef9cc..d231e93a 100644 --- a/FunApp/FunApp/SceneDelegate.swift +++ b/FunApp/FunApp/SceneDelegate.swift @@ -13,15 +13,12 @@ import FunCore import FunModel @MainActor -class SceneDelegate: UIResponder, UIWindowSceneDelegate, ServiceLocatorProvider { +class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? var appCoordinator: AppCoordinator? - var serviceLocator: ServiceLocator { appCoordinator!.serviceLocator } private var darkModeCancellable: AnyCancellable? - @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol - func scene( _ scene: UIScene, willConnectTo session: UISceneSession, @@ -67,6 +64,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, ServiceLocatorProvider // MARK: - Dark Mode Observation private func subscribeToDarkMode() { + guard let coordinator = appCoordinator else { return } + let featureToggleService: FeatureToggleServiceProtocol = coordinator.serviceLocator.resolve(for: .featureToggles) darkModeCancellable?.cancel() darkModeCancellable = featureToggleService.appearanceModePublisher .sink { [weak self] mode in diff --git a/Model/Sources/ModelTestSupport/MockSession.swift b/Model/Sources/ModelTestSupport/MockSession.swift new file mode 100644 index 00000000..38f56897 --- /dev/null +++ b/Model/Sources/ModelTestSupport/MockSession.swift @@ -0,0 +1,21 @@ +// +// MockSession.swift +// Model +// +// Mock session for testing — wraps a pre-configured ServiceLocator +// + +import FunCore + +@MainActor +public final class MockSession: Session { + + public let serviceLocator: ServiceLocator + + public init(serviceLocator: ServiceLocator) { + self.serviceLocator = serviceLocator + } + + public func activate() {} + public func teardown() {} +} diff --git a/UI/Sources/UI/HomeTabBarController.swift b/UI/Sources/UI/HomeTabBarController.swift index e8c902c4..901e8240 100644 --- a/UI/Sources/UI/HomeTabBarController.swift +++ b/UI/Sources/UI/HomeTabBarController.swift @@ -14,7 +14,7 @@ import FunCore import FunModel import FunViewModel -public class HomeTabBarController: UITabBarController, ServiceLocatorProvider { +public class HomeTabBarController: UITabBarController, SessionProvider { // MARK: - ViewModel @@ -22,7 +22,7 @@ public class HomeTabBarController: UITabBarController, ServiceLocatorProvider { // MARK: - DI - public let serviceLocator: ServiceLocator + public let session: Session @Service(.toast) private var toastService: ToastServiceProtocol // MARK: - Combine @@ -40,9 +40,9 @@ public class HomeTabBarController: UITabBarController, ServiceLocatorProvider { // MARK: - Initialization - public init(viewModel: HomeTabBarViewModel, tabNavigationControllers: [UINavigationController], serviceLocator: ServiceLocator) { + public init(viewModel: HomeTabBarViewModel, tabNavigationControllers: [UINavigationController], session: Session) { self.viewModel = viewModel - self.serviceLocator = serviceLocator + self.session = session super.init(nibName: nil, bundle: nil) delegate = self viewControllers = tabNavigationControllers diff --git a/UI/Sources/UI/Preview Content/PreviewHelper.swift b/UI/Sources/UI/Preview Content/PreviewHelper.swift index 2995cc06..39b0eeec 100644 --- a/UI/Sources/UI/Preview Content/PreviewHelper.swift +++ b/UI/Sources/UI/Preview Content/PreviewHelper.swift @@ -16,54 +16,63 @@ import FunViewModel @MainActor public enum PreviewHelper { - /// Creates a ServiceLocator with all preview mock services registered - public static func makeServiceLocator() -> ServiceLocator { - let locator = ServiceLocator() + /// Creates a Session with all preview mock services registered + public static func makeSession() -> Session { + let session = PreviewSession() - locator.register(PreviewLoggerService() as LoggerService, for: .logger) + session.serviceLocator.register(PreviewLoggerService() as LoggerService, for: .logger) let favorites = PreviewFavoritesService(initialFavorites: ["asyncawait", "swiftui"]) - locator.register(favorites as FavoritesServiceProtocol, for: .favorites) + session.serviceLocator.register(favorites as FavoritesServiceProtocol, for: .favorites) let toggles = PreviewFeatureToggleService() - locator.register(toggles as FeatureToggleServiceProtocol, for: .featureToggles) - locator.register(PreviewNetworkService() as NetworkServiceProtocol, for: .network) - locator.register(PreviewToastService() as ToastServiceProtocol, for: .toast) - locator.register(PreviewAIService() as AIServiceProtocol, for: .ai) + session.serviceLocator.register(toggles as FeatureToggleServiceProtocol, for: .featureToggles) + session.serviceLocator.register(PreviewNetworkService() as NetworkServiceProtocol, for: .network) + session.serviceLocator.register(PreviewToastService() as ToastServiceProtocol, for: .toast) + session.serviceLocator.register(PreviewAIService() as AIServiceProtocol, for: .ai) - return locator + return session } /// Creates a HomeViewModel configured for previews public static func makeHomeViewModel() -> HomeViewModel { - HomeViewModel(serviceLocator: makeServiceLocator()) + HomeViewModel(session: makeSession()) } /// Creates an ItemsViewModel configured for previews public static func makeItemsViewModel() -> ItemsViewModel { - ItemsViewModel(serviceLocator: makeServiceLocator()) + ItemsViewModel(session: makeSession()) } /// Creates a SettingsViewModel configured for previews public static func makeSettingsViewModel() -> SettingsViewModel { - SettingsViewModel(serviceLocator: makeServiceLocator()) + SettingsViewModel(session: makeSession()) } /// Creates a ProfileViewModel configured for previews public static func makeProfileViewModel() -> ProfileViewModel { - ProfileViewModel(serviceLocator: makeServiceLocator()) + ProfileViewModel(session: makeSession()) } /// Creates a DetailViewModel configured for previews public static func makeDetailViewModel() -> DetailViewModel { - DetailViewModel(item: .asyncAwait, serviceLocator: makeServiceLocator()) + DetailViewModel(item: .asyncAwait, session: makeSession()) } /// Creates a LoginViewModel configured for previews public static func makeLoginViewModel() -> LoginViewModel { - LoginViewModel(serviceLocator: makeServiceLocator()) + LoginViewModel(session: makeSession()) } } +// MARK: - Preview Session + +@MainActor +private final class PreviewSession: Session { + let serviceLocator = ServiceLocator() + func activate() {} + func teardown() {} +} + // MARK: - Preview Stub Services @MainActor diff --git a/UI/Tests/UITests/SnapshotTests/DetailViewSnapshotTests.swift b/UI/Tests/UITests/SnapshotTests/DetailViewSnapshotTests.swift index 35beae77..bfb249f9 100644 --- a/UI/Tests/UITests/SnapshotTests/DetailViewSnapshotTests.swift +++ b/UI/Tests/UITests/SnapshotTests/DetailViewSnapshotTests.swift @@ -18,7 +18,7 @@ import FunModelTestSupport @MainActor final class DetailViewSnapshotTests: XCTestCase { - private func makeServiceLocator() -> ServiceLocator { + private func makeSession() -> MockSession { let locator = ServiceLocator() locator.register(MockLoggerService(), for: .logger) locator.register(MockNetworkService(), for: .network) @@ -26,14 +26,14 @@ final class DetailViewSnapshotTests: XCTestCase { locator.register(MockFeatureToggleService(), for: .featureToggles) locator.register(MockAIService(isAvailable: false), for: .ai) locator.register(MockToastService(), for: .toast) - return locator + return MockSession(serviceLocator: locator) } // Set to true to regenerate snapshots, then set back to false private var recording: Bool { false } func testDetailView_defaultState() { - let viewModel = DetailViewModel(item: .asyncAwait, serviceLocator: makeServiceLocator()) + let viewModel = DetailViewModel(item: .asyncAwait, session: makeSession()) let view = DetailView(viewModel: viewModel) let hostingController = UIHostingController(rootView: view) @@ -43,7 +43,7 @@ final class DetailViewSnapshotTests: XCTestCase { } func testDetailView_favorited() { - let viewModel = DetailViewModel(item: .asyncAwait, serviceLocator: makeServiceLocator()) + let viewModel = DetailViewModel(item: .asyncAwait, session: makeSession()) viewModel.isFavorited = true let view = DetailView(viewModel: viewModel) @@ -54,7 +54,7 @@ final class DetailViewSnapshotTests: XCTestCase { } func testDetailView_darkMode() { - let viewModel = DetailViewModel(item: .swiftUI, serviceLocator: makeServiceLocator()) + let viewModel = DetailViewModel(item: .swiftUI, session: makeSession()) let view = DetailView(viewModel: viewModel) let hostingController = UIHostingController(rootView: view) diff --git a/UI/Tests/UITests/SnapshotTests/HomeViewSnapshotTests.swift b/UI/Tests/UITests/SnapshotTests/HomeViewSnapshotTests.swift index 8a50efdb..7dc5dd24 100644 --- a/UI/Tests/UITests/SnapshotTests/HomeViewSnapshotTests.swift +++ b/UI/Tests/UITests/SnapshotTests/HomeViewSnapshotTests.swift @@ -18,21 +18,21 @@ import FunModelTestSupport @MainActor final class HomeViewSnapshotTests: XCTestCase { - private func makeServiceLocator() -> ServiceLocator { + private func makeSession() -> MockSession { let locator = ServiceLocator() locator.register(MockLoggerService(), for: .logger) locator.register(MockNetworkService(), for: .network) locator.register(MockFeatureToggleService(), for: .featureToggles) locator.register(MockFavoritesService(), for: .favorites) locator.register(MockToastService(), for: .toast) - return locator + return MockSession(serviceLocator: locator) } // Set to true to regenerate snapshots, then set back to false private var recording: Bool { false } func testHomeView_withCarouselEnabled() { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeViewModel(session: makeSession()) viewModel.isCarouselEnabled = true let view = HomeView(viewModel: viewModel) @@ -43,7 +43,7 @@ final class HomeViewSnapshotTests: XCTestCase { } func testHomeView_withCarouselDisabled() { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeViewModel(session: makeSession()) viewModel.isCarouselEnabled = false let view = HomeView(viewModel: viewModel) @@ -54,7 +54,7 @@ final class HomeViewSnapshotTests: XCTestCase { } func testHomeView_darkMode() { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeViewModel(session: makeSession()) viewModel.isCarouselEnabled = true let view = HomeView(viewModel: viewModel) @@ -68,7 +68,7 @@ final class HomeViewSnapshotTests: XCTestCase { // MARK: - iPad Tests func testHomeView_iPad_portrait() { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeViewModel(session: makeSession()) viewModel.isCarouselEnabled = true let view = HomeView(viewModel: viewModel) @@ -79,7 +79,7 @@ final class HomeViewSnapshotTests: XCTestCase { } func testHomeView_iPad_landscape() { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeViewModel(session: makeSession()) viewModel.isCarouselEnabled = true let view = HomeView(viewModel: viewModel) diff --git a/UI/Tests/UITests/SnapshotTests/ItemsViewSnapshotTests.swift b/UI/Tests/UITests/SnapshotTests/ItemsViewSnapshotTests.swift index b3638959..b9c875ef 100644 --- a/UI/Tests/UITests/SnapshotTests/ItemsViewSnapshotTests.swift +++ b/UI/Tests/UITests/SnapshotTests/ItemsViewSnapshotTests.swift @@ -18,21 +18,21 @@ import FunModelTestSupport @MainActor final class ItemsViewSnapshotTests: XCTestCase { - private func makeServiceLocator() -> ServiceLocator { + private func makeSession() -> MockSession { let locator = ServiceLocator() locator.register(MockLoggerService(), for: .logger) locator.register(MockNetworkService(), for: .network) locator.register(MockFavoritesService(), for: .favorites) locator.register(MockFeatureToggleService(), for: .featureToggles) locator.register(MockToastService(), for: .toast) - return locator + return MockSession(serviceLocator: locator) } // Set to true to regenerate snapshots, then set back to false private var recording: Bool { false } func testItemsView_defaultState() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) await viewModel.loadItems() let view = ItemsView(viewModel: viewModel) @@ -43,7 +43,7 @@ final class ItemsViewSnapshotTests: XCTestCase { } func testItemsView_withSearchText() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) await viewModel.loadItems() viewModel.searchText = "swift" @@ -55,7 +55,7 @@ final class ItemsViewSnapshotTests: XCTestCase { } func testItemsView_darkMode() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) await viewModel.loadItems() let view = ItemsView(viewModel: viewModel) diff --git a/UI/Tests/UITests/SnapshotTests/LoginViewSnapshotTests.swift b/UI/Tests/UITests/SnapshotTests/LoginViewSnapshotTests.swift index 1127e942..02c80d74 100644 --- a/UI/Tests/UITests/SnapshotTests/LoginViewSnapshotTests.swift +++ b/UI/Tests/UITests/SnapshotTests/LoginViewSnapshotTests.swift @@ -17,19 +17,19 @@ import FunModelTestSupport @MainActor final class LoginViewSnapshotTests: XCTestCase { - private func makeServiceLocator() -> ServiceLocator { + private func makeSession() -> MockSession { let locator = ServiceLocator() locator.register(MockLoggerService(), for: .logger) locator.register(MockNetworkService(), for: .network) locator.register(MockToastService(), for: .toast) - return locator + return MockSession(serviceLocator: locator) } // Set to true to regenerate snapshots, then set back to false private var recording: Bool { false } func testLoginView_defaultState() { - let viewModel = LoginViewModel(serviceLocator: makeServiceLocator()) + let viewModel = LoginViewModel(session: makeSession()) let view = LoginView(viewModel: viewModel) let hostingController = UIHostingController(rootView: view) @@ -39,7 +39,7 @@ final class LoginViewSnapshotTests: XCTestCase { } func testLoginView_loggingInState() { - let viewModel = LoginViewModel(serviceLocator: makeServiceLocator()) + let viewModel = LoginViewModel(session: makeSession()) viewModel.isLoggingIn = true let view = LoginView(viewModel: viewModel) @@ -50,7 +50,7 @@ final class LoginViewSnapshotTests: XCTestCase { } func testLoginView_darkMode() { - let viewModel = LoginViewModel(serviceLocator: makeServiceLocator()) + let viewModel = LoginViewModel(session: makeSession()) let view = LoginView(viewModel: viewModel) let hostingController = UIHostingController(rootView: view) diff --git a/UI/Tests/UITests/SnapshotTests/ProfileViewSnapshotTests.swift b/UI/Tests/UITests/SnapshotTests/ProfileViewSnapshotTests.swift index 3b656982..7f18ec5d 100644 --- a/UI/Tests/UITests/SnapshotTests/ProfileViewSnapshotTests.swift +++ b/UI/Tests/UITests/SnapshotTests/ProfileViewSnapshotTests.swift @@ -18,19 +18,19 @@ import FunModelTestSupport @MainActor final class ProfileViewSnapshotTests: XCTestCase { - private func makeServiceLocator() -> ServiceLocator { + private func makeSession() -> MockSession { let locator = ServiceLocator() locator.register(MockLoggerService(), for: .logger) locator.register(MockNetworkService(), for: .network) locator.register(MockFavoritesService(initialFavorites: ["asyncawait", "swiftui"]), for: .favorites) - return locator + return MockSession(serviceLocator: locator) } // Set to true to regenerate snapshots, then set back to false private var recording: Bool { false } func testProfileView_defaultState() { - let viewModel = ProfileViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ProfileViewModel(session: makeSession()) let view = ProfileView(viewModel: viewModel) let hostingController = UIHostingController(rootView: view) @@ -40,7 +40,7 @@ final class ProfileViewSnapshotTests: XCTestCase { } func testProfileView_darkMode() { - let viewModel = ProfileViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ProfileViewModel(session: makeSession()) let view = ProfileView(viewModel: viewModel) let hostingController = UIHostingController(rootView: view) diff --git a/UI/Tests/UITests/SnapshotTests/SettingsViewSnapshotTests.swift b/UI/Tests/UITests/SnapshotTests/SettingsViewSnapshotTests.swift index f71d7fef..ed87cba4 100644 --- a/UI/Tests/UITests/SnapshotTests/SettingsViewSnapshotTests.swift +++ b/UI/Tests/UITests/SnapshotTests/SettingsViewSnapshotTests.swift @@ -18,19 +18,19 @@ import FunModelTestSupport @MainActor final class SettingsViewSnapshotTests: XCTestCase { - private func makeServiceLocator() -> ServiceLocator { + private func makeSession() -> MockSession { let locator = ServiceLocator() locator.register(MockLoggerService(), for: .logger) locator.register(MockNetworkService(), for: .network) locator.register(MockFeatureToggleService(), for: .featureToggles) - return locator + return MockSession(serviceLocator: locator) } // Set to true to regenerate snapshots, then set back to false private var recording: Bool { false } func testSettingsView_defaultState() { - let viewModel = SettingsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = SettingsViewModel(session: makeSession()) let view = SettingsView(viewModel: viewModel) let hostingController = UIHostingController(rootView: view) @@ -40,7 +40,7 @@ final class SettingsViewSnapshotTests: XCTestCase { } func testSettingsView_darkAppearance() { - let viewModel = SettingsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = SettingsViewModel(session: makeSession()) viewModel.appearanceMode = .dark let view = SettingsView(viewModel: viewModel) @@ -52,7 +52,7 @@ final class SettingsViewSnapshotTests: XCTestCase { } func testSettingsView_carouselEnabled() { - let viewModel = SettingsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = SettingsViewModel(session: makeSession()) viewModel.featuredCarouselEnabled = true let view = SettingsView(viewModel: viewModel) @@ -63,7 +63,7 @@ final class SettingsViewSnapshotTests: XCTestCase { } func testSettingsView_carouselDisabled() { - let viewModel = SettingsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = SettingsViewModel(session: makeSession()) viewModel.featuredCarouselEnabled = false let view = SettingsView(viewModel: viewModel) diff --git a/ViewModel/Sources/ViewModel/Detail/DetailViewModel.swift b/ViewModel/Sources/ViewModel/Detail/DetailViewModel.swift index b412fe70..3d589b09 100644 --- a/ViewModel/Sources/ViewModel/Detail/DetailViewModel.swift +++ b/ViewModel/Sources/ViewModel/Detail/DetailViewModel.swift @@ -12,7 +12,7 @@ import FunCore import FunModel @MainActor -public class DetailViewModel: ObservableObject, ServiceLocatorProvider { +public class DetailViewModel: ObservableObject, SessionProvider { // MARK: - Navigation Closures @@ -21,7 +21,7 @@ public class DetailViewModel: ObservableObject, ServiceLocatorProvider { // MARK: - DI - public let serviceLocator: ServiceLocator + public let session: Session @Service(.logger) private var logger: LoggerService @Service(.favorites) private var favoritesService: FavoritesServiceProtocol @Service(.ai) private var aiService: AIServiceProtocol @@ -48,8 +48,8 @@ public class DetailViewModel: ObservableObject, ServiceLocatorProvider { // MARK: - Initialization - public init(item: FeaturedItem, serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + public init(item: FeaturedItem, session: Session) { + self.session = session self.itemTitle = item.title self.category = item.category diff --git a/ViewModel/Sources/ViewModel/Home/HomeViewModel.swift b/ViewModel/Sources/ViewModel/Home/HomeViewModel.swift index e5a23499..c7afb9ec 100644 --- a/ViewModel/Sources/ViewModel/Home/HomeViewModel.swift +++ b/ViewModel/Sources/ViewModel/Home/HomeViewModel.swift @@ -42,7 +42,7 @@ import FunModel // See feature/observation for the full implementation. @MainActor -public class HomeViewModel: ObservableObject, ServiceLocatorProvider { +public class HomeViewModel: ObservableObject, SessionProvider { // MARK: - Navigation Closures @@ -51,7 +51,7 @@ public class HomeViewModel: ObservableObject, ServiceLocatorProvider { // MARK: - DI - public let serviceLocator: ServiceLocator + public let session: Session @Service(.logger) private var logger: LoggerService @Service(.network) private var networkService: NetworkServiceProtocol @Service(.favorites) private var favoritesService: FavoritesServiceProtocol @@ -75,8 +75,8 @@ public class HomeViewModel: ObservableObject, ServiceLocatorProvider { // MARK: - Initialization - public init(serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + public init(session: Session) { + self.session = session observeFeatureToggleChanges() observeFavoritesChanges() diff --git a/ViewModel/Sources/ViewModel/HomeTabBar/HomeTabBarViewModel.swift b/ViewModel/Sources/ViewModel/HomeTabBar/HomeTabBarViewModel.swift index 9dd9eb6e..c8595650 100644 --- a/ViewModel/Sources/ViewModel/HomeTabBar/HomeTabBarViewModel.swift +++ b/ViewModel/Sources/ViewModel/HomeTabBar/HomeTabBarViewModel.swift @@ -12,11 +12,11 @@ import FunCore import FunModel @MainActor -public class HomeTabBarViewModel: ObservableObject, ServiceLocatorProvider { +public class HomeTabBarViewModel: ObservableObject, SessionProvider { // MARK: - DI - public let serviceLocator: ServiceLocator + public let session: Session @Service(.logger) private var logger: LoggerService // MARK: - Published State @@ -26,8 +26,8 @@ public class HomeTabBarViewModel: ObservableObject, ServiceLocatorProvider { // MARK: - Initialization - public init(serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + public init(session: Session) { + self.session = session logger.log("HomeTabBarViewModel initialized") } diff --git a/ViewModel/Sources/ViewModel/Items/ItemsViewModel.swift b/ViewModel/Sources/ViewModel/Items/ItemsViewModel.swift index 802b581f..a3b86177 100644 --- a/ViewModel/Sources/ViewModel/Items/ItemsViewModel.swift +++ b/ViewModel/Sources/ViewModel/Items/ItemsViewModel.swift @@ -12,7 +12,7 @@ import FunCore import FunModel @MainActor -public class ItemsViewModel: ObservableObject, ServiceLocatorProvider { +public class ItemsViewModel: ObservableObject, SessionProvider { // MARK: - Navigation Closures @@ -20,7 +20,7 @@ public class ItemsViewModel: ObservableObject, ServiceLocatorProvider { // MARK: - DI - public let serviceLocator: ServiceLocator + public let session: Session @Service(.logger) private var logger: LoggerService @Service(.network) private var networkService: NetworkServiceProtocol @Service(.favorites) private var favoritesService: FavoritesServiceProtocol @@ -54,8 +54,8 @@ public class ItemsViewModel: ObservableObject, ServiceLocatorProvider { // MARK: - Initialization - public init(serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + public init(session: Session) { + self.session = session observeFavoritesChanges() setupSearchBinding() diff --git a/ViewModel/Sources/ViewModel/Login/LoginViewModel.swift b/ViewModel/Sources/ViewModel/Login/LoginViewModel.swift index 3532bf16..767e6f2f 100644 --- a/ViewModel/Sources/ViewModel/Login/LoginViewModel.swift +++ b/ViewModel/Sources/ViewModel/Login/LoginViewModel.swift @@ -12,7 +12,7 @@ import FunCore import FunModel @MainActor -public class LoginViewModel: ObservableObject, ServiceLocatorProvider { +public class LoginViewModel: ObservableObject, SessionProvider { // MARK: - Navigation Closures @@ -20,7 +20,7 @@ public class LoginViewModel: ObservableObject, ServiceLocatorProvider { // MARK: - DI - public let serviceLocator: ServiceLocator + public let session: Session @Service(.logger) private var logger: LoggerService @Service(.network) private var networkService: NetworkServiceProtocol @Service(.toast) private var toastService: ToastServiceProtocol @@ -35,8 +35,8 @@ public class LoginViewModel: ObservableObject, ServiceLocatorProvider { // MARK: - Initialization - public init(serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + public init(session: Session) { + self.session = session } deinit { diff --git a/ViewModel/Sources/ViewModel/Profile/ProfileViewModel.swift b/ViewModel/Sources/ViewModel/Profile/ProfileViewModel.swift index 2b346c5a..22152269 100644 --- a/ViewModel/Sources/ViewModel/Profile/ProfileViewModel.swift +++ b/ViewModel/Sources/ViewModel/Profile/ProfileViewModel.swift @@ -11,7 +11,7 @@ import FunCore import FunModel @MainActor -public class ProfileViewModel: ObservableObject, ServiceLocatorProvider { +public class ProfileViewModel: ObservableObject, SessionProvider { // MARK: - Navigation Closures @@ -21,7 +21,7 @@ public class ProfileViewModel: ObservableObject, ServiceLocatorProvider { // MARK: - DI - public let serviceLocator: ServiceLocator + public let session: Session @Service(.logger) private var logger: LoggerService // MARK: - Published State @@ -35,8 +35,8 @@ public class ProfileViewModel: ObservableObject, ServiceLocatorProvider { // MARK: - Initialization - public init(profile: UserProfile = .demo, serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + public init(profile: UserProfile = .demo, session: Session) { + self.session = session self.userName = profile.name self.userEmail = profile.email self.userBio = profile.bio diff --git a/ViewModel/Sources/ViewModel/Settings/SettingsViewModel.swift b/ViewModel/Sources/ViewModel/Settings/SettingsViewModel.swift index b3951d92..4c7fedcd 100644 --- a/ViewModel/Sources/ViewModel/Settings/SettingsViewModel.swift +++ b/ViewModel/Sources/ViewModel/Settings/SettingsViewModel.swift @@ -12,11 +12,11 @@ import FunCore import FunModel @MainActor -public class SettingsViewModel: ObservableObject, ServiceLocatorProvider { +public class SettingsViewModel: ObservableObject, SessionProvider { // MARK: - DI - public let serviceLocator: ServiceLocator + public let session: Session @Service(.logger) private var logger: LoggerService @Service(.featureToggles) private var featureToggleService: FeatureToggleServiceProtocol @@ -40,8 +40,8 @@ public class SettingsViewModel: ObservableObject, ServiceLocatorProvider { // MARK: - Initialization - public init(serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + public init(session: Session) { + self.session = session _appearanceMode = Published(initialValue: featureToggleService.appearanceMode) _featuredCarouselEnabled = Published(initialValue: featureToggleService.featuredCarousel) diff --git a/ViewModel/Tests/ViewModelTests/DetailViewModelTests.swift b/ViewModel/Tests/ViewModelTests/DetailViewModelTests.swift index 144f6540..b2c1050b 100644 --- a/ViewModel/Tests/ViewModelTests/DetailViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/DetailViewModelTests.swift @@ -20,11 +20,11 @@ struct DetailViewModelTests { // MARK: - Setup - private func makeServiceLocator( + private func makeSession( initialFavorites: Set = [], aiService: MockAIService = MockAIService(), featureToggleService: MockFeatureToggleService = MockFeatureToggleService() - ) -> ServiceLocator { + ) -> MockSession { let locator = ServiceLocator() locator.register(MockLoggerService(), for: .logger) locator.register(MockNetworkService(), for: .network) @@ -32,7 +32,7 @@ struct DetailViewModelTests { locator.register(featureToggleService, for: .featureToggles) locator.register(MockToastService(), for: .toast) locator.register(aiService, for: .ai) - return locator + return MockSession(serviceLocator: locator) } private var testItem: FeaturedItem { @@ -44,7 +44,7 @@ struct DetailViewModelTests { @Test("Initial state matches item data") func testInitialStateMatchesItem() async { let item = testItem - let viewModel = DetailViewModel(item: item, serviceLocator: makeServiceLocator()) + let viewModel = DetailViewModel(item: item, session: makeSession()) #expect(viewModel.itemTitle == item.title) #expect(viewModel.category == item.category) @@ -54,14 +54,14 @@ struct DetailViewModelTests { @Test("isFavorited is true when item is in favorites") func testIsFavoritedTrue() async { let item = testItem - let viewModel = DetailViewModel(item: item, serviceLocator: makeServiceLocator(initialFavorites: [item.id])) + let viewModel = DetailViewModel(item: item, session: makeSession(initialFavorites: [item.id])) #expect(viewModel.isFavorited == true) } @Test("isFavorited is false when item is not in favorites") func testIsFavoritedFalse() async { - let viewModel = DetailViewModel(item: testItem, serviceLocator: makeServiceLocator(initialFavorites: [])) + let viewModel = DetailViewModel(item: testItem, session: makeSession(initialFavorites: [])) #expect(viewModel.isFavorited == false) } @@ -70,7 +70,7 @@ struct DetailViewModelTests { @Test("didTapToggleFavorite adds item to favorites") func testToggleFavoriteAdds() async { - let viewModel = DetailViewModel(item: testItem, serviceLocator: makeServiceLocator(initialFavorites: [])) + let viewModel = DetailViewModel(item: testItem, session: makeSession(initialFavorites: [])) #expect(viewModel.isFavorited == false) @@ -85,7 +85,7 @@ struct DetailViewModelTests { @Test("didTapToggleFavorite removes item from favorites") func testToggleFavoriteRemoves() async { let item = testItem - let viewModel = DetailViewModel(item: item, serviceLocator: makeServiceLocator(initialFavorites: [item.id])) + let viewModel = DetailViewModel(item: item, session: makeSession(initialFavorites: [item.id])) #expect(viewModel.isFavorited == true) @@ -111,7 +111,7 @@ struct DetailViewModelTests { locator.register(MockAIService(), for: .ai) let item = testItem - let viewModel = DetailViewModel(item: item, serviceLocator: locator) + let viewModel = DetailViewModel(item: item, session: MockSession(serviceLocator: locator)) #expect(viewModel.isFavorited == false) @@ -128,7 +128,7 @@ struct DetailViewModelTests { @Test("didTapShare calls onShare with item title") func testDidTapShareCallsOnShare() async { - let viewModel = DetailViewModel(item: testItem, serviceLocator: makeServiceLocator()) + let viewModel = DetailViewModel(item: testItem, session: makeSession()) var shareCalled = false var shareText: String? @@ -144,7 +144,7 @@ struct DetailViewModelTests { @Test("handleBackNavigation calls onPop") func testHandleBackNavigationCallsOnPop() async { - let viewModel = DetailViewModel(item: testItem, serviceLocator: makeServiceLocator()) + let viewModel = DetailViewModel(item: testItem, session: makeSession()) var popCalled = false viewModel.onPop = { popCalled = true } @@ -156,7 +156,7 @@ struct DetailViewModelTests { @Test("handleBackNavigation with nil closure does not crash") func testHandleBackNavigationWithNilClosure() async { - let viewModel = DetailViewModel(item: testItem, serviceLocator: makeServiceLocator()) + let viewModel = DetailViewModel(item: testItem, session: makeSession()) viewModel.handleBackNavigation() // Should not crash } @@ -165,11 +165,11 @@ struct DetailViewModelTests { @Test("Works with different featured items") func testDifferentItems() async { - let locator = makeServiceLocator() + let session = makeSession() let items: [FeaturedItem] = [.swiftUI, .combine, .mvvm, .coordinator] for item in items { - let viewModel = DetailViewModel(item: item, serviceLocator: locator) + let viewModel = DetailViewModel(item: item, session: session) #expect(viewModel.itemTitle == item.title) #expect(viewModel.category == item.category) } @@ -181,7 +181,7 @@ struct DetailViewModelTests { func testShowAISummaryTrue() async { let aiService = MockAIService(isAvailable: true) let featureToggle = MockFeatureToggleService(aiSummary: true) - let viewModel = DetailViewModel(item: testItem, serviceLocator: makeServiceLocator(aiService: aiService, featureToggleService: featureToggle)) + let viewModel = DetailViewModel(item: testItem, session: makeSession(aiService: aiService, featureToggleService: featureToggle)) #expect(viewModel.showAISummary == true) } @@ -190,7 +190,7 @@ struct DetailViewModelTests { func testShowAISummaryFalseWhenToggleOff() async { let aiService = MockAIService(isAvailable: true) let featureToggle = MockFeatureToggleService(aiSummary: false) - let viewModel = DetailViewModel(item: testItem, serviceLocator: makeServiceLocator(aiService: aiService, featureToggleService: featureToggle)) + let viewModel = DetailViewModel(item: testItem, session: makeSession(aiService: aiService, featureToggleService: featureToggle)) #expect(viewModel.showAISummary == false) } @@ -199,7 +199,7 @@ struct DetailViewModelTests { func testShowAISummaryFalseWhenUnavailable() async { let aiService = MockAIService(isAvailable: false) let featureToggle = MockFeatureToggleService(aiSummary: true) - let viewModel = DetailViewModel(item: testItem, serviceLocator: makeServiceLocator(aiService: aiService, featureToggleService: featureToggle)) + let viewModel = DetailViewModel(item: testItem, session: makeSession(aiService: aiService, featureToggleService: featureToggle)) #expect(viewModel.showAISummary == false) } @@ -207,7 +207,7 @@ struct DetailViewModelTests { @Test("generateSummary sets summary text") func testGenerateSummarySetsText() async { let aiService = MockAIService(stubbedSummary: "Test summary result") - let viewModel = DetailViewModel(item: testItem, serviceLocator: makeServiceLocator(aiService: aiService)) + let viewModel = DetailViewModel(item: testItem, session: makeSession(aiService: aiService)) await viewModel.generateSummary() @@ -220,7 +220,7 @@ struct DetailViewModelTests { @Test("generateSummary handles errors") func testGenerateSummaryHandlesErrors() async { let aiService = MockAIService(shouldThrowError: true) - let viewModel = DetailViewModel(item: testItem, serviceLocator: makeServiceLocator(aiService: aiService)) + let viewModel = DetailViewModel(item: testItem, session: makeSession(aiService: aiService)) await viewModel.generateSummary() @@ -232,7 +232,7 @@ struct DetailViewModelTests { @Test("isSummarizing is false after generation completes") func testIsSummarizingStateAfterCompletion() async { let aiService = MockAIService(stubbedSummary: "Summary") - let viewModel = DetailViewModel(item: testItem, serviceLocator: makeServiceLocator(aiService: aiService)) + let viewModel = DetailViewModel(item: testItem, session: makeSession(aiService: aiService)) #expect(viewModel.isSummarizing == false) diff --git a/ViewModel/Tests/ViewModelTests/HomeTabBarViewModelTests.swift b/ViewModel/Tests/ViewModelTests/HomeTabBarViewModelTests.swift index 11c26dbf..3e6a18fe 100644 --- a/ViewModel/Tests/ViewModelTests/HomeTabBarViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/HomeTabBarViewModelTests.swift @@ -20,17 +20,17 @@ struct HomeTabBarViewModelTests { // MARK: - Setup - private func makeServiceLocator() -> ServiceLocator { + private func makeSession() -> MockSession { let locator = ServiceLocator() locator.register(MockLoggerService(), for: .logger) - return locator + return MockSession(serviceLocator: locator) } // MARK: - Initialization Tests @Test("Initial selectedTabIndex is 0") func testInitialTabIndex() async { - let viewModel = HomeTabBarViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeTabBarViewModel(session: makeSession()) #expect(viewModel.selectedTabIndex == 0) } @@ -39,7 +39,7 @@ struct HomeTabBarViewModelTests { @Test("tabDidChange updates selectedTabIndex") func testTabDidChangeUpdatesIndex() async { - let viewModel = HomeTabBarViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeTabBarViewModel(session: makeSession()) viewModel.tabDidChange(to: 1) #expect(viewModel.selectedTabIndex == 1) @@ -53,7 +53,7 @@ struct HomeTabBarViewModelTests { @Test("switchToTab updates selectedTabIndex") func testSwitchToTabUpdatesIndex() async { - let viewModel = HomeTabBarViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeTabBarViewModel(session: makeSession()) viewModel.switchToTab(1) #expect(viewModel.selectedTabIndex == 1) @@ -64,9 +64,9 @@ struct HomeTabBarViewModelTests { @Test("switchToTab and tabDidChange produce same result") func testSwitchAndDidChangeEquivalent() async { - let locator = makeServiceLocator() - let vm1 = HomeTabBarViewModel(serviceLocator: locator) - let vm2 = HomeTabBarViewModel(serviceLocator: locator) + let session = makeSession() + let vm1 = HomeTabBarViewModel(session: session) + let vm2 = HomeTabBarViewModel(session: session) vm1.switchToTab(2) vm2.tabDidChange(to: 2) @@ -78,7 +78,7 @@ struct HomeTabBarViewModelTests { @Test("switchToTab ignores negative index") func testSwitchToTabIgnoresNegativeIndex() async { - let viewModel = HomeTabBarViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeTabBarViewModel(session: makeSession()) viewModel.switchToTab(1) #expect(viewModel.selectedTabIndex == 1) @@ -89,7 +89,7 @@ struct HomeTabBarViewModelTests { @Test("switchToTab ignores out-of-bounds index") func testSwitchToTabIgnoresOutOfBoundsIndex() async { - let viewModel = HomeTabBarViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeTabBarViewModel(session: makeSession()) viewModel.switchToTab(1) #expect(viewModel.selectedTabIndex == 1) @@ -100,7 +100,7 @@ struct HomeTabBarViewModelTests { @Test("switchToTab accepts all valid tab indices") func testSwitchToTabAcceptsAllValidIndices() async { - let viewModel = HomeTabBarViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeTabBarViewModel(session: makeSession()) for tabIndex in TabIndex.allCases { viewModel.switchToTab(tabIndex.rawValue) diff --git a/ViewModel/Tests/ViewModelTests/HomeViewModelTests.swift b/ViewModel/Tests/ViewModelTests/HomeViewModelTests.swift index 22c84ad5..eb6de6a7 100644 --- a/ViewModel/Tests/ViewModelTests/HomeViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/HomeViewModelTests.swift @@ -43,22 +43,22 @@ struct HomeViewModelTests { // MARK: - Setup - private func makeServiceLocator( + private func makeSession( initialFavorites: Set = [], featuredCarousel: Bool = true, simulateErrors: Bool = false - ) -> ServiceLocator { + ) -> MockSession { let locator = ServiceLocator() locator.register(MockLoggerService(), for: .logger) locator.register(MockNetworkService(shouldThrowError: simulateErrors), for: .network) locator.register(MockFavoritesService(initialFavorites: initialFavorites), for: .favorites) locator.register(MockFeatureToggleService(featuredCarousel: featuredCarousel, simulateErrors: simulateErrors), for: .featureToggles) locator.register(MockToastService(), for: .toast) - return locator + return MockSession(serviceLocator: locator) } - private func makeServiceLocator(scenario: FeatureScenario, initialFavorites: Set = []) -> ServiceLocator { - makeServiceLocator( + private func makeSession(scenario: FeatureScenario, initialFavorites: Set = []) -> MockSession { + makeSession( initialFavorites: initialFavorites, featuredCarousel: scenario.carousel, simulateErrors: scenario.simulateErrors @@ -69,7 +69,7 @@ struct HomeViewModelTests { @Test("Initial hasError is false on creation") func testInitialHasErrorOnCreation() async { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeViewModel(session: makeSession()) // hasError should always start false #expect(viewModel.hasError == false) @@ -77,7 +77,7 @@ struct HomeViewModelTests { @Test("Initial currentCarouselIndex is 0 on creation") func testInitialCarouselIndexOnCreation() async { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeViewModel(session: makeSession()) #expect(viewModel.currentCarouselIndex == 0) } @@ -86,7 +86,7 @@ struct HomeViewModelTests { @Test("loadFeaturedItems populates data") func testLoadFeaturedItemsPopulatesData() async { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeViewModel(session: makeSession()) await viewModel.loadFeaturedItems() @@ -106,13 +106,14 @@ struct HomeViewModelTests { locator.register(MockFeatureToggleService(featuredCarousel: true), for: .featureToggles) locator.register(mockToast, for: .toast) - let viewModel = HomeViewModel(serviceLocator: locator) + let session = MockSession(serviceLocator: locator) + let viewModel = HomeViewModel(session: session) // Explicitly call loadFeaturedItems and wait for it await viewModel.loadFeaturedItems() // Verify the toast was called - let resolvedToast: MockToastService = locator.resolve(for: .toast) + let resolvedToast: MockToastService = session.serviceLocator.resolve(for: .toast) #expect(resolvedToast.showToastCalled == true) #expect(resolvedToast.lastType == .error) } @@ -121,7 +122,7 @@ struct HomeViewModelTests { @Test("didTapFeaturedItem calls onShowDetail") func testDidTapFeaturedItemCallsOnShowDetail() async throws { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeViewModel(session: makeSession()) var showDetailCalled = false var showDetailItem: FeaturedItem? @@ -140,7 +141,7 @@ struct HomeViewModelTests { @Test("didTapProfile calls onShowProfile") func testDidTapProfileCallsOnShowProfile() async { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeViewModel(session: makeSession()) var showProfileCalled = false viewModel.onShowProfile = { showProfileCalled = true } @@ -154,21 +155,21 @@ struct HomeViewModelTests { @Test("isFavorited returns false for unfavorited item") func testIsFavoritedReturnsFalse() async { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator(initialFavorites: [])) + let viewModel = HomeViewModel(session: makeSession(initialFavorites: [])) #expect(viewModel.isFavorited("unfavorited_item") == false) } @Test("isFavorited returns true for favorited item") func testIsFavoritedReturnsTrue() async { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator(initialFavorites: ["test_item"])) + let viewModel = HomeViewModel(session: makeSession(initialFavorites: ["test_item"])) #expect(viewModel.isFavorited("test_item") == true) } @Test("toggleFavorite updates favorites") func testToggleFavoriteUpdates() async { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator(initialFavorites: [])) + let viewModel = HomeViewModel(session: makeSession(initialFavorites: [])) #expect(viewModel.isFavorited("test_item") == false) @@ -182,7 +183,7 @@ struct HomeViewModelTests { @Test("toggleFavorite removes favorited item") func testToggleFavoriteRemoves() async { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator(initialFavorites: ["test_item"])) + let viewModel = HomeViewModel(session: makeSession(initialFavorites: ["test_item"])) #expect(viewModel.isFavorited("test_item") == true) @@ -198,7 +199,7 @@ struct HomeViewModelTests { @Test("Carousel visibility matches feature toggle", arguments: FeatureScenario.carouselScenarios) func testCarouselVisibility(scenario: FeatureScenario) async { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator(scenario: scenario)) + let viewModel = HomeViewModel(session: makeSession(scenario: scenario)) #expect(viewModel.isCarouselEnabled == scenario.carousel) } @@ -218,7 +219,7 @@ struct HomeViewModelTests { @Test("Loading behavior based on error simulation", arguments: FeatureScenario.errorScenarios) func testLoadingBehavior(scenario: FeatureScenario) async { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator(scenario: scenario)) + let viewModel = HomeViewModel(session: makeSession(scenario: scenario)) await viewModel.loadFeaturedItems() @@ -231,7 +232,7 @@ struct HomeViewModelTests { @Test("refresh reloads featured items") func testRefreshReloadsItems() async { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator()) + let viewModel = HomeViewModel(session: makeSession()) await viewModel.refresh() @@ -244,7 +245,7 @@ struct HomeViewModelTests { @Test("retry calls loadFeaturedItems") func testRetryCallsLoad() async { - let viewModel = HomeViewModel(serviceLocator: makeServiceLocator(simulateErrors: false)) + let viewModel = HomeViewModel(session: makeSession(simulateErrors: false)) // Clear items viewModel.hasError = true diff --git a/ViewModel/Tests/ViewModelTests/ItemsViewModelTests.swift b/ViewModel/Tests/ViewModelTests/ItemsViewModelTests.swift index eff8a80f..592153a2 100644 --- a/ViewModel/Tests/ViewModelTests/ItemsViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/ItemsViewModelTests.swift @@ -20,25 +20,25 @@ struct ItemsViewModelTests { // MARK: - Setup - private func makeServiceLocator( + private func makeSession( initialFavorites: Set = [], simulateErrors: Bool = false, networkService: MockNetworkService? = nil - ) -> ServiceLocator { + ) -> MockSession { let locator = ServiceLocator() locator.register(MockLoggerService(), for: .logger) locator.register(networkService ?? MockNetworkService(shouldThrowError: simulateErrors), for: .network) locator.register(MockFavoritesService(initialFavorites: initialFavorites), for: .favorites) locator.register(MockFeatureToggleService(simulateErrors: simulateErrors), for: .featureToggles) locator.register(MockToastService(), for: .toast) - return locator + return MockSession(serviceLocator: locator) } // MARK: - Initialization Tests @Test("Items are loaded on initialization") func testItemsLoadedOnInit() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) await viewModel.loadItems() #expect(viewModel.items.isEmpty == false) @@ -46,28 +46,28 @@ struct ItemsViewModelTests { @Test("Initial search text is empty") func testInitialSearchTextEmpty() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) #expect(viewModel.searchText.isEmpty) } @Test("Initial selected category is 'All'") func testInitialCategoryIsAll() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) #expect(viewModel.selectedCategory == "All") } @Test("Initial isSearching is false") func testInitialIsSearchingFalse() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) #expect(viewModel.isSearching == false) } @Test("Minimum search characters is 2") func testMinimumSearchCharacters() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) #expect(viewModel.minimumSearchCharacters == 2) } @@ -76,7 +76,7 @@ struct ItemsViewModelTests { @Test("Categories include 'All' as first option") func testCategoriesIncludeAll() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) await viewModel.loadItems() #expect(viewModel.categories.first == "All") @@ -84,7 +84,7 @@ struct ItemsViewModelTests { @Test("Selecting a category updates selectedCategory") func testSelectCategoryUpdatesState() async throws { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) await viewModel.loadItems() let categories = viewModel.categories @@ -98,7 +98,7 @@ struct ItemsViewModelTests { @Test("Selecting 'All' shows all items") func testSelectAllShowsAllItems() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) await viewModel.loadItems() // Get initial item count (with All selected) @@ -119,7 +119,7 @@ struct ItemsViewModelTests { @Test("Clear search resets search text and isSearching") func testClearSearchResetsState() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) viewModel.searchText = "test" viewModel.clearSearch() @@ -130,7 +130,7 @@ struct ItemsViewModelTests { @Test("Initial needsMoreCharacters is false") func testInitialNeedsMoreCharactersFalse() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) #expect(viewModel.needsMoreCharacters == false) } @@ -139,21 +139,21 @@ struct ItemsViewModelTests { @Test("isFavorited returns false for unfavorited item") func testIsFavoritedReturnsFalse() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator(initialFavorites: [])) + let viewModel = ItemsViewModel(session: makeSession(initialFavorites: [])) #expect(viewModel.isFavorited("unfavorited_item") == false) } @Test("isFavorited returns true for favorited item") func testIsFavoritedReturnsTrue() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator(initialFavorites: ["test_item"])) + let viewModel = ItemsViewModel(session: makeSession(initialFavorites: ["test_item"])) #expect(viewModel.isFavorited("test_item") == true) } @Test("toggleFavorite adds unfavorited item to favorites") func testToggleFavoriteAdds() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator(initialFavorites: [])) + let viewModel = ItemsViewModel(session: makeSession(initialFavorites: [])) #expect(viewModel.isFavorited("test_item") == false) @@ -167,7 +167,7 @@ struct ItemsViewModelTests { @Test("toggleFavorite removes favorited item from favorites") func testToggleFavoriteRemoves() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator(initialFavorites: ["test_item"])) + let viewModel = ItemsViewModel(session: makeSession(initialFavorites: ["test_item"])) #expect(viewModel.isFavorited("test_item") == true) @@ -191,7 +191,7 @@ struct ItemsViewModelTests { locator.register(MockFeatureToggleService(), for: .featureToggles) locator.register(MockToastService(), for: .toast) - let viewModel = ItemsViewModel(serviceLocator: locator) + let viewModel = ItemsViewModel(session: MockSession(serviceLocator: locator)) #expect(viewModel.favoriteIds.isEmpty) @@ -208,7 +208,7 @@ struct ItemsViewModelTests { @Test("didSelectItem calls onShowDetail") func testDidSelectItemCallsOnShowDetail() async throws { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) var showDetailCalled = false var showDetailItem: FeaturedItem? @@ -228,7 +228,7 @@ struct ItemsViewModelTests { @Test("Filtering by category reduces item count") func testCategoryFilterReducesItems() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) await viewModel.loadItems() let allItemsCount = viewModel.items.count @@ -249,7 +249,7 @@ struct ItemsViewModelTests { @Test("Items in filtered list match selected category") func testFilteredItemsMatchCategory() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) await viewModel.loadItems() let categories = viewModel.categories.filter { $0 != "All" } @@ -269,7 +269,7 @@ struct ItemsViewModelTests { @Test("Search calls networkService.searchItems with query and category") func testSearchCallsNetworkService() async throws { let mockNetwork = MockNetworkService(stubbedSearchItems: [.swiftUI]) - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator(networkService: mockNetwork)) + let viewModel = ItemsViewModel(session: makeSession(networkService: mockNetwork)) await viewModel.loadItems() viewModel.searchText = "swift" @@ -289,8 +289,8 @@ struct ItemsViewModelTests { @Test("Search error sets hasError and shows toast") func testSearchErrorSetsHasError() async throws { let mockNetwork = MockNetworkService(shouldThrowError: true) - let locator = makeServiceLocator(networkService: mockNetwork) - let viewModel = ItemsViewModel(serviceLocator: locator) + let session = makeSession(networkService: mockNetwork) + let viewModel = ItemsViewModel(session: session) viewModel.searchText = "swift" viewModel.didSelectCategory(viewModel.selectedCategory) @@ -301,14 +301,14 @@ struct ItemsViewModelTests { #expect(viewModel.items.isEmpty) #expect(viewModel.isSearching == false) - let toast: MockToastService = locator.resolve(for: .toast) + let toast: MockToastService = session.serviceLocator.resolve(for: .toast) #expect(toast.showToastCalled == true) } @Test("Clear search resets to filtered allItems") func testClearSearchResetsToAllItems() async throws { let mockNetwork = MockNetworkService(stubbedSearchItems: [.swiftUI]) - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator(networkService: mockNetwork)) + let viewModel = ItemsViewModel(session: makeSession(networkService: mockNetwork)) await viewModel.loadItems() let allItemsCount = viewModel.items.count @@ -329,23 +329,23 @@ struct ItemsViewModelTests { @Test("Initial hasError is false") func testInitialHasErrorFalse() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ItemsViewModel(session: makeSession()) #expect(viewModel.hasError == false) } @Test("Mock feature toggle service returns correct simulateErrors value") func testMockFeatureToggleSimulateErrors() async { - let locator = makeServiceLocator(simulateErrors: true) + let session = makeSession(simulateErrors: true) - let service: FeatureToggleServiceProtocol = locator.resolve(for: .featureToggles) + let service: FeatureToggleServiceProtocol = session.serviceLocator.resolve(for: .featureToggles) #expect(service.simulateErrors == true) } @Test("clearSearch always sets hasError to false") func testClearSearchSetsHasErrorFalse() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator(simulateErrors: true)) + let viewModel = ItemsViewModel(session: makeSession(simulateErrors: true)) // Manually set hasError to true to simulate error state viewModel.hasError = true @@ -358,7 +358,7 @@ struct ItemsViewModelTests { @Test("retry resets hasError before re-searching") func testRetryResetsHasError() async { - let viewModel = ItemsViewModel(serviceLocator: makeServiceLocator(simulateErrors: false)) + let viewModel = ItemsViewModel(session: makeSession(simulateErrors: false)) // Manually set hasError to true viewModel.hasError = true diff --git a/ViewModel/Tests/ViewModelTests/LoginViewModelTests.swift b/ViewModel/Tests/ViewModelTests/LoginViewModelTests.swift index 543f3174..8b5d59b6 100644 --- a/ViewModel/Tests/ViewModelTests/LoginViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/LoginViewModelTests.swift @@ -20,21 +20,21 @@ struct LoginViewModelTests { // MARK: - Setup - private func makeServiceLocator(shouldThrowError: Bool = false) -> ServiceLocator { + private func makeSession(shouldThrowError: Bool = false) -> MockSession { let locator = ServiceLocator() locator.register(MockLoggerService(), for: .logger) locator.register(MockNetworkService(shouldThrowError: shouldThrowError), for: .network) locator.register(MockFavoritesService(), for: .favorites) locator.register(MockFeatureToggleService(), for: .featureToggles) locator.register(MockToastService(), for: .toast) - return locator + return MockSession(serviceLocator: locator) } // MARK: - Initial State Tests @Test("Initial state has isLoggingIn false") func testInitialStateIsNotLoggingIn() async { - let viewModel = LoginViewModel(serviceLocator: makeServiceLocator()) + let viewModel = LoginViewModel(session: makeSession()) #expect(viewModel.isLoggingIn == false) } @@ -43,7 +43,7 @@ struct LoginViewModelTests { @Test("Login sets isLoggingIn to true") func testLoginSetsIsLoggingIn() async { - let viewModel = LoginViewModel(serviceLocator: makeServiceLocator()) + let viewModel = LoginViewModel(session: makeSession()) viewModel.login() @@ -52,7 +52,7 @@ struct LoginViewModelTests { @Test("Login calls onLogin after network request") func testLoginCallsOnLogin() async { - let viewModel = LoginViewModel(serviceLocator: makeServiceLocator()) + let viewModel = LoginViewModel(session: makeSession()) var loginCalled = false viewModel.onLogin = { loginCalled = true } @@ -68,7 +68,7 @@ struct LoginViewModelTests { @Test("Login prevents multiple simultaneous logins") func testLoginPreventsMultipleLogins() async { - let viewModel = LoginViewModel(serviceLocator: makeServiceLocator()) + let viewModel = LoginViewModel(session: makeSession()) var loginCallCount = 0 viewModel.onLogin = { loginCallCount += 1 } @@ -88,7 +88,7 @@ struct LoginViewModelTests { @Test("Login with nil coordinator completes without crash") func testLoginWithNilCoordinatorDoesNotCrash() async { - let viewModel = LoginViewModel(serviceLocator: makeServiceLocator()) + let viewModel = LoginViewModel(session: makeSession()) viewModel.login() #expect(viewModel.isLoggingIn == true) @@ -100,9 +100,9 @@ struct LoginViewModelTests { @Test("Login failure does not call onLogin and shows error toast") func testLoginFailureDoesNotCallOnLogin() async { - let locator = makeServiceLocator(shouldThrowError: true) + let session = makeSession(shouldThrowError: true) - let viewModel = LoginViewModel(serviceLocator: locator) + let viewModel = LoginViewModel(session: session) var loginCalled = false viewModel.onLogin = { loginCalled = true } @@ -112,7 +112,7 @@ struct LoginViewModelTests { #expect(loginCalled == false) #expect(viewModel.isLoggingIn == false) - let toastService: MockToastService = locator.resolve(for: .toast) + let toastService: MockToastService = session.serviceLocator.resolve(for: .toast) #expect(toastService.showToastCalled == true) #expect(toastService.lastType == .error) } diff --git a/ViewModel/Tests/ViewModelTests/ProfileViewModelTests.swift b/ViewModel/Tests/ViewModelTests/ProfileViewModelTests.swift index efdf1041..d409b3cf 100644 --- a/ViewModel/Tests/ViewModelTests/ProfileViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/ProfileViewModelTests.swift @@ -20,17 +20,17 @@ struct ProfileViewModelTests { // MARK: - Setup - private func makeServiceLocator() -> ServiceLocator { + private func makeSession() -> MockSession { let locator = ServiceLocator() locator.register(MockLoggerService(), for: .logger) - return locator + return MockSession(serviceLocator: locator) } // MARK: - Initialization Tests @Test("Initial state matches demo profile") func testInitialState() async { - let viewModel = ProfileViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ProfileViewModel(session: makeSession()) #expect(viewModel.userName == UserProfile.demo.name) #expect(viewModel.userEmail == UserProfile.demo.email) @@ -43,7 +43,7 @@ struct ProfileViewModelTests { @Test("Custom profile values are used") func testCustomProfileValues() async { let profile = UserProfile(name: "Test", email: "test@test.com", bio: "Bio", viewsCount: 1, favoritesCount: 2, daysCount: 3) - let viewModel = ProfileViewModel(profile: profile, serviceLocator: makeServiceLocator()) + let viewModel = ProfileViewModel(profile: profile, session: makeSession()) #expect(viewModel.userName == "Test") #expect(viewModel.userEmail == "test@test.com") @@ -54,7 +54,7 @@ struct ProfileViewModelTests { @Test("Dismiss calls onDismiss") func testDismissCallsOnDismiss() async { - let viewModel = ProfileViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ProfileViewModel(session: makeSession()) var dismissCalled = false viewModel.onDismiss = { dismissCalled = true } @@ -68,7 +68,7 @@ struct ProfileViewModelTests { @Test("Logout calls onLogout") func testLogoutCallsOnLogout() async { - let viewModel = ProfileViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ProfileViewModel(session: makeSession()) var logoutCalled = false viewModel.onLogout = { logoutCalled = true } @@ -82,7 +82,7 @@ struct ProfileViewModelTests { @Test("didTapGoToItems calls onGoToItems") func testDidTapGoToItemsCallsOnGoToItems() async { - let viewModel = ProfileViewModel(serviceLocator: makeServiceLocator()) + let viewModel = ProfileViewModel(session: makeSession()) var goToItemsCalled = false viewModel.onGoToItems = { goToItemsCalled = true } diff --git a/ViewModel/Tests/ViewModelTests/SettingsViewModelTests.swift b/ViewModel/Tests/ViewModelTests/SettingsViewModelTests.swift index c9772b16..94cdcdca 100644 --- a/ViewModel/Tests/ViewModelTests/SettingsViewModelTests.swift +++ b/ViewModel/Tests/ViewModelTests/SettingsViewModelTests.swift @@ -20,11 +20,11 @@ struct SettingsViewModelTests { // MARK: - Setup - private func makeServiceLocator( + private func makeSession( appearanceMode: AppearanceMode = .system, featuredCarousel: Bool = true, simulateErrors: Bool = false - ) -> (ServiceLocator, MockFeatureToggleService) { + ) -> (MockSession, MockFeatureToggleService) { let mockFeatureToggle = MockFeatureToggleService( featuredCarousel: featuredCarousel, simulateErrors: simulateErrors, @@ -35,15 +35,15 @@ struct SettingsViewModelTests { locator.register(MockLoggerService(), for: .logger) locator.register(mockFeatureToggle, for: .featureToggles) - return (locator, mockFeatureToggle) + return (MockSession(serviceLocator: locator), mockFeatureToggle) } // MARK: - Initialization Tests @Test("Initial state matches service defaults") func testInitialStateMatchesServiceDefaults() async { - let (locator, _) = makeServiceLocator() - let viewModel = SettingsViewModel(serviceLocator: locator) + let (session, _) = makeSession() + let viewModel = SettingsViewModel(session: session) #expect(viewModel.appearanceMode == .system) #expect(viewModel.featuredCarouselEnabled == true) @@ -55,8 +55,8 @@ struct SettingsViewModelTests { @Test("Changing appearance mode updates service") func testChangingAppearanceModeUpdatesService() async { - let (locator, mockService) = makeServiceLocator(appearanceMode: .system) - let viewModel = SettingsViewModel(serviceLocator: locator) + let (session, mockService) = makeSession(appearanceMode: .system) + let viewModel = SettingsViewModel(session: session) viewModel.appearanceMode = .dark @@ -65,8 +65,8 @@ struct SettingsViewModelTests { @Test("Appearance mode changes propagate to service") func testAppearanceModeChangesPropagateToService() async { - let (locator, mockService) = makeServiceLocator(appearanceMode: .dark) - let viewModel = SettingsViewModel(serviceLocator: locator) + let (session, mockService) = makeSession(appearanceMode: .dark) + let viewModel = SettingsViewModel(session: session) viewModel.appearanceMode = .light @@ -77,8 +77,8 @@ struct SettingsViewModelTests { @Test("Toggling featured carousel updates service") func testTogglingFeaturedCarouselUpdatesService() async { - let (locator, mockService) = makeServiceLocator(featuredCarousel: true) - let viewModel = SettingsViewModel(serviceLocator: locator) + let (session, mockService) = makeSession(featuredCarousel: true) + let viewModel = SettingsViewModel(session: session) viewModel.featuredCarouselEnabled = false @@ -87,8 +87,8 @@ struct SettingsViewModelTests { @Test("Toggling simulate errors updates service") func testTogglingSimulateErrorsUpdatesService() async { - let (locator, mockService) = makeServiceLocator(simulateErrors: false) - let viewModel = SettingsViewModel(serviceLocator: locator) + let (session, mockService) = makeSession(simulateErrors: false) + let viewModel = SettingsViewModel(session: session) viewModel.simulateErrorsEnabled = true @@ -99,8 +99,8 @@ struct SettingsViewModelTests { @Test("Reset appearance sets to system") func testResetAppearanceSetsToSystem() async { - let (locator, mockService) = makeServiceLocator(appearanceMode: .dark) - let viewModel = SettingsViewModel(serviceLocator: locator) + let (session, mockService) = makeSession(appearanceMode: .dark) + let viewModel = SettingsViewModel(session: session) viewModel.resetAppearance() @@ -110,9 +110,9 @@ struct SettingsViewModelTests { @Test("Reset feature toggles restores defaults") func testResetFeatureTogglesRestoresDefaults() async { - let (locator, mockService) = makeServiceLocator(featuredCarousel: false, simulateErrors: true) + let (session, mockService) = makeSession(featuredCarousel: false, simulateErrors: true) mockService.aiSummary = false - let viewModel = SettingsViewModel(serviceLocator: locator) + let viewModel = SettingsViewModel(session: session) viewModel.resetFeatureToggles() @@ -128,17 +128,17 @@ struct SettingsViewModelTests { @Test("AI Summary enabled initializes from service") func testAISummaryEnabledInitFromService() async { - let (locator, mockService) = makeServiceLocator() + let (session, mockService) = makeSession() mockService.aiSummary = false - let viewModel = SettingsViewModel(serviceLocator: locator) + let viewModel = SettingsViewModel(session: session) #expect(viewModel.aiSummaryEnabled == false) } @Test("Toggling AI Summary updates service") func testTogglingAISummaryUpdatesService() async { - let (locator, mockService) = makeServiceLocator() - let viewModel = SettingsViewModel(serviceLocator: locator) + let (session, mockService) = makeSession() + let viewModel = SettingsViewModel(session: session) viewModel.aiSummaryEnabled = false From 15eea1678710360e24a27da4989def6b9bff806c Mon Sep 17 00:00:00 2001 From: Charles Wang Date: Fri, 20 Mar 2026 23:34:39 +1100 Subject: [PATCH 6/9] Fix stale docs and SwiftLint warning from review Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 6 +++--- FunApp/FunApp/SceneDelegate.swift | 4 ++-- README.md | 12 ++++++------ ai-rules/general.md | 2 +- ai-rules/swift-style.md | 10 +++++----- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 613e1c32..6bba381c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,13 +73,13 @@ Consult these files for detailed guidance (not auto-loaded — read on demand): - ViewModels use closures for navigation (no coordinator protocols) - Navigation logic ONLY in Coordinators, never in Views - Protocol placement: Core = reusable abstractions, Model = domain-specific -- Instance-based ServiceLocator with `@Service` property wrapper (`ServiceLocatorProvider` conformance) +- Session-scoped ServiceLocator with `@Service` property wrapper — ViewModels conform to `SessionProvider`, store `let session: Session` - Combine over NotificationCenter for reactive state ## Testing - Swift Testing framework (`import Testing`, `@Test`, `#expect`, `@Suite`) -- Each test creates its own `ServiceLocator()` instance — no `.serialized` needed, tests run in parallel -- Use `makeServiceLocator()` helper to create per-test locator with mocks, pass via `serviceLocator:` param +- Each test creates its own `MockSession` (from `FunModelTestSupport`) — no `.serialized` needed, tests run in parallel +- Use `makeSession()` helper to create per-test session with mocks via `MockSession(serviceLocator:)`, pass via `session:` param - Consolidate thin init tests into a single test when they test the same concern - Centralized mocks in `Model/Sources/ModelTestSupport/Mocks/` - Snapshot tests with swift-snapshot-testing diff --git a/FunApp/FunApp/SceneDelegate.swift b/FunApp/FunApp/SceneDelegate.swift index d231e93a..ed06f949 100644 --- a/FunApp/FunApp/SceneDelegate.swift +++ b/FunApp/FunApp/SceneDelegate.swift @@ -65,9 +65,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func subscribeToDarkMode() { guard let coordinator = appCoordinator else { return } - let featureToggleService: FeatureToggleServiceProtocol = coordinator.serviceLocator.resolve(for: .featureToggles) + let toggles: FeatureToggleServiceProtocol = coordinator.serviceLocator.resolve(for: .featureToggles) darkModeCancellable?.cancel() - darkModeCancellable = featureToggleService.appearanceModePublisher + darkModeCancellable = toggles.appearanceModePublisher .sink { [weak self] mode in let style: UIUserInterfaceStyle = switch mode { case .system: .unspecified diff --git a/README.md b/README.md index b41c8174..583c084f 100644 --- a/README.md +++ b/README.md @@ -145,14 +145,14 @@ protocol Session: AnyObject, ServiceLocatorProvider { func teardown() // clean up session state } -// @Service resolves from the enclosing instance's serviceLocator -// via static subscript(_enclosingInstance:) — no global singleton -class HomeViewModel: ObservableObject, ServiceLocatorProvider { - let serviceLocator: ServiceLocator +// @Service resolves from session.serviceLocator automatically +// via SessionProvider protocol extension — no global singleton +class HomeViewModel: ObservableObject, SessionProvider { + let session: Session @Service(.network) private var networkService: NetworkServiceProtocol - init(serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + init(session: Session) { + self.session = session } } ``` diff --git a/ai-rules/general.md b/ai-rules/general.md index 95a857c6..9e9fe953 100644 --- a/ai-rules/general.md +++ b/ai-rules/general.md @@ -55,7 +55,7 @@ Services is a sibling to the UI stack — it depends on Model and Core but NOT o SwiftUI views are embedded in UIKit via UIViewControllers: ```swift // In Coordinator: -let viewModel = HomeViewModel(serviceLocator: serviceLocator) +let viewModel = HomeViewModel(session: session) viewModel.onShowDetail = { [weak self] item in self?.showDetail(for: item) } let viewController = HomeViewController(viewModel: viewModel) safePush(viewController) diff --git a/ai-rules/swift-style.md b/ai-rules/swift-style.md index 383bb8a8..5349bd8d 100644 --- a/ai-rules/swift-style.md +++ b/ai-rules/swift-style.md @@ -59,13 +59,13 @@ ## ServiceLocator & @Service ```swift -// Instance-based DI — no .shared singleton -class MyViewModel: ObservableObject, ServiceLocatorProvider { - let serviceLocator: ServiceLocator +// Session-scoped DI — no .shared singleton +class MyViewModel: ObservableObject, SessionProvider { + let session: Session @Service(.logger) private var logger: LoggerService - init(serviceLocator: ServiceLocator) { - self.serviceLocator = serviceLocator + init(session: Session) { + self.session = session } } ``` From 1afaeb4487b2ceb9748ac5ca460da69e9d0d79e1 Mon Sep 17 00:00:00 2001 From: Charles Wang Date: Sat, 21 Mar 2026 08:43:16 +1100 Subject: [PATCH 7/9] AppCoordinator conforms to SessionProvider, no throwaway ServiceLocator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session is created in init, activated in start(). No separate serviceLocator storage — SessionProvider extension provides it. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/Coordinator/AppCoordinator.swift | 21 +++++++++---------- FunApp/FunApp/SceneDelegate.swift | 4 ++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Coordinator/Sources/Coordinator/AppCoordinator.swift b/Coordinator/Sources/Coordinator/AppCoordinator.swift index 970ad9bb..d8d8967d 100644 --- a/Coordinator/Sources/Coordinator/AppCoordinator.swift +++ b/Coordinator/Sources/Coordinator/AppCoordinator.swift @@ -13,17 +13,16 @@ import FunUI import FunViewModel /// Main app coordinator that manages the root navigation and app flow -public final class AppCoordinator: BaseCoordinator, ServiceLocatorProvider { +public final class AppCoordinator: BaseCoordinator, SessionProvider { // MARK: - Services - public private(set) var serviceLocator = ServiceLocator() + public private(set) var session: Session @Service(.logger) private var logger: LoggerService // MARK: - Session Management private let sessionFactory: SessionFactory - private var currentSession: Session? // MARK: - App Flow State @@ -49,13 +48,15 @@ public final class AppCoordinator: BaseCoordinator, ServiceLocatorProvider { public init(navigationController: UINavigationController, sessionFactory: SessionFactory) { self.sessionFactory = sessionFactory + self.session = sessionFactory.makeSession(for: .login) super.init(navigationController: navigationController) } // MARK: - Start override public func start() { - let session = activateSession(for: currentFlow) + session.activate() + onSessionActivated?() switch currentFlow { case .login: showLoginFlow(session: session) @@ -66,15 +67,13 @@ public final class AppCoordinator: BaseCoordinator, ServiceLocatorProvider { // MARK: - Session Lifecycle - @discardableResult private func activateSession(for flow: AppFlow) -> Session { - currentSession?.teardown() - let session = sessionFactory.makeSession(for: flow) - session.activate() - currentSession = session - serviceLocator = session.serviceLocator + session.teardown() + let newSession = sessionFactory.makeSession(for: flow) + newSession.activate() + session = newSession onSessionActivated?() - return session + return newSession } // MARK: - Flow Management diff --git a/FunApp/FunApp/SceneDelegate.swift b/FunApp/FunApp/SceneDelegate.swift index ed06f949..1687406a 100644 --- a/FunApp/FunApp/SceneDelegate.swift +++ b/FunApp/FunApp/SceneDelegate.swift @@ -64,8 +64,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // MARK: - Dark Mode Observation private func subscribeToDarkMode() { - guard let coordinator = appCoordinator else { return } - let toggles: FeatureToggleServiceProtocol = coordinator.serviceLocator.resolve(for: .featureToggles) + guard let session = appCoordinator?.session else { return } + let toggles: FeatureToggleServiceProtocol = session.serviceLocator.resolve(for: .featureToggles) darkModeCancellable?.cancel() darkModeCancellable = toggles.appearanceModePublisher .sink { [weak self] mode in From 1c40140deb4462186a5c56afc17b964aa279a72a Mon Sep 17 00:00:00 2001 From: Charles Wang Date: Sat, 21 Mar 2026 08:49:49 +1100 Subject: [PATCH 8/9] Update in-app ServiceLocator description to session-scoped pattern Co-Authored-By: Claude Opus 4.6 (1M context) --- Model/Sources/Model/TechnologyDescriptions.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Model/Sources/Model/TechnologyDescriptions.swift b/Model/Sources/Model/TechnologyDescriptions.swift index b2a6fee9..ef178e8e 100644 --- a/Model/Sources/Model/TechnologyDescriptions.swift +++ b/Model/Sources/Model/TechnologyDescriptions.swift @@ -168,17 +168,17 @@ public enum TechnologyDescriptions { """ private static let serviceLocatorDescription = """ - Instance-based dependency injection using ServiceLocator + @Service property wrapper: + Session-scoped dependency injection using ServiceLocator + @Service property wrapper: - Conform to ServiceLocatorProvider: + Conform to SessionProvider: ```swift - class MyViewModel: ServiceLocatorProvider { - let serviceLocator: ServiceLocator + class MyViewModel: SessionProvider { + let session: Session @Service(.favorites) var favoritesService: FavoritesServiceProtocol } ``` - @Service resolves from the enclosing instance's serviceLocator via + @Service resolves from session.serviceLocator automatically via static subscript(_enclosingInstance:) — no global singleton needed. This enables easy mocking for tests while keeping injection simple. From a8bd4ed43a8ce3ada1c8e2576405a03992dff2b2 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:01:22 +0000 Subject: [PATCH 9/9] Address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract activateCurrentSession() helper so start() and activateSession(for:) share one path - Add @MainActor doc comment to ServiceLocatorProvider explaining the isolation requirement - Add MARK comment to SessionProvider extension clarifying ServiceLocatorProvider conformance - Add comment in SceneDelegate explaining why @Service can't be used for the dark mode subscription - Rename test variable login → loginSession for clarity Co-authored-by: Charles Wang --- .../Sources/Coordinator/AppCoordinator.swift | 16 +++++++++------- Core/Sources/Core/ServiceLocator.swift | 5 +++++ FunApp/FunApp/SceneDelegate.swift | 3 +++ Services/Tests/ServicesTests/SessionTests.swift | 12 ++++++------ 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/Coordinator/Sources/Coordinator/AppCoordinator.swift b/Coordinator/Sources/Coordinator/AppCoordinator.swift index d8d8967d..4d72dc24 100644 --- a/Coordinator/Sources/Coordinator/AppCoordinator.swift +++ b/Coordinator/Sources/Coordinator/AppCoordinator.swift @@ -55,8 +55,7 @@ public final class AppCoordinator: BaseCoordinator, SessionProvider { // MARK: - Start override public func start() { - session.activate() - onSessionActivated?() + activateCurrentSession() switch currentFlow { case .login: showLoginFlow(session: session) @@ -67,13 +66,16 @@ public final class AppCoordinator: BaseCoordinator, SessionProvider { // MARK: - Session Lifecycle + private func activateCurrentSession() { + session.activate() + onSessionActivated?() + } + private func activateSession(for flow: AppFlow) -> Session { session.teardown() - let newSession = sessionFactory.makeSession(for: flow) - newSession.activate() - session = newSession - onSessionActivated?() - return newSession + session = sessionFactory.makeSession(for: flow) + activateCurrentSession() + return session } // MARK: - Flow Management diff --git a/Core/Sources/Core/ServiceLocator.swift b/Core/Sources/Core/ServiceLocator.swift index 8143fbfe..ccc8ee7b 100644 --- a/Core/Sources/Core/ServiceLocator.swift +++ b/Core/Sources/Core/ServiceLocator.swift @@ -62,6 +62,9 @@ public class ServiceLocator { // MARK: - ServiceLocatorProvider /// Any type that holds a ServiceLocator instance for instance-based DI resolution. +/// +/// `@MainActor` is required because `ServiceLocator` itself is `@MainActor` — +/// any property that returns a `ServiceLocator` must also be isolated to the main actor. @MainActor public protocol ServiceLocatorProvider { var serviceLocator: ServiceLocator { get } @@ -76,6 +79,8 @@ public protocol SessionProvider: ServiceLocatorProvider { var session: Session { get } } +// MARK: - ServiceLocatorProvider + extension SessionProvider { public var serviceLocator: ServiceLocator { session.serviceLocator } } diff --git a/FunApp/FunApp/SceneDelegate.swift b/FunApp/FunApp/SceneDelegate.swift index 1687406a..db5e83fe 100644 --- a/FunApp/FunApp/SceneDelegate.swift +++ b/FunApp/FunApp/SceneDelegate.swift @@ -65,6 +65,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func subscribeToDarkMode() { guard let session = appCoordinator?.session else { return } + // @Service can't be used here: SceneDelegate is not a ServiceLocatorProvider, + // and the session (and its locator) changes on each transition — we must + // re-resolve from the current session's locator on every activation. let toggles: FeatureToggleServiceProtocol = session.serviceLocator.resolve(for: .featureToggles) darkModeCancellable?.cancel() darkModeCancellable = toggles.appearanceModePublisher diff --git a/Services/Tests/ServicesTests/SessionTests.swift b/Services/Tests/ServicesTests/SessionTests.swift index 496f0148..d3ed07f1 100644 --- a/Services/Tests/ServicesTests/SessionTests.swift +++ b/Services/Tests/ServicesTests/SessionTests.swift @@ -85,14 +85,14 @@ struct SessionTests { #expect(main.serviceLocator.isRegistered(for: .favorites)) main.teardown() - let login = LoginSession() - login.activate() + let loginSession = LoginSession() + loginSession.activate() // Login session has only its own services — no stale favorites - #expect(login.serviceLocator.isRegistered(for: .logger)) - #expect(login.serviceLocator.isRegistered(for: .network)) - #expect(login.serviceLocator.isRegistered(for: .featureToggles)) - #expect(!login.serviceLocator.isRegistered(for: .favorites)) + #expect(loginSession.serviceLocator.isRegistered(for: .logger)) + #expect(loginSession.serviceLocator.isRegistered(for: .network)) + #expect(loginSession.serviceLocator.isRegistered(for: .featureToggles)) + #expect(!loginSession.serviceLocator.isRegistered(for: .favorites)) } @Test("Favorites are fresh after session transition")