diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index e63c38c0..48499587 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -85,3 +85,5 @@ jobs: uses: StanfordSpezi/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: coveragereports: Spezi-Package-iOS.xcresult Spezi-Package-watchOS.xcresult Spezi-Package-visionOS.xcresult Spezi-Package-tvOS.xcresult Spezi-Package-macOS.xcresult TestApp-iOS.xcresult TestApp-visionOS.xcresult + secrets: + token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/Sources/Spezi/Capabilities/ApplicationPropertyWrapper.swift b/Sources/Spezi/Capabilities/ApplicationPropertyWrapper.swift index 3f173bf0..f0aaad58 100644 --- a/Sources/Spezi/Capabilities/ApplicationPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/ApplicationPropertyWrapper.swift @@ -44,6 +44,11 @@ extension _ApplicationPropertyWrapper: SpeziPropertyWrapper { self.shadowCopy = spezi[keyPath: keyPath] } } + + func clear() { + spezi = nil + shadowCopy = nil + } } diff --git a/Sources/Spezi/Capabilities/Communication/CollectPropertyWrapper.swift b/Sources/Spezi/Capabilities/Communication/CollectPropertyWrapper.swift index 4f33cf5e..f113198f 100644 --- a/Sources/Spezi/Capabilities/Communication/CollectPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/Communication/CollectPropertyWrapper.swift @@ -38,6 +38,17 @@ public class _CollectPropertyWrapper { } +extension _CollectPropertyWrapper: StorageValueCollector { + public func retrieve>(from repository: Repository) { + injectedValues = repository[CollectedModuleValues.self]?.map { $0.value } ?? [] + } + + func clear() { + injectedValues = nil + } +} + + extension Module { /// The `@Collect` property wrapper can be used to retrieve data communicated by other `Module`s. /// @@ -63,10 +74,3 @@ extension Module { /// ``` public typealias Collect = _CollectPropertyWrapper } - - -extension _CollectPropertyWrapper: StorageValueCollector { - public func retrieve>(from repository: Repository) { - injectedValues = repository[CollectedModuleValue.self] ?? [] - } -} diff --git a/Sources/Spezi/Capabilities/Communication/CollectedModuleValue.swift b/Sources/Spezi/Capabilities/Communication/CollectedModuleValue.swift deleted file mode 100644 index 0f6c5a66..00000000 --- a/Sources/Spezi/Capabilities/Communication/CollectedModuleValue.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziFoundation - - -/// Provides the ``KnowledgeSource`` for any value we store in the ``SpeziStorage`` that is -/// provided or request from am ``Module``. -/// -/// For more information, look at the ``Module/Provide`` or ``Module/Collect`` property wrappers. -struct CollectedModuleValue: DefaultProvidingKnowledgeSource { - typealias Anchor = SpeziAnchor - - typealias Value = [ModuleValue] - - - static var defaultValue: [ModuleValue] { - [] - } - - - static func reduce(value: inout [ModuleValue], nextValue: [ModuleValue]) { - value.append(contentsOf: nextValue) - } -} diff --git a/Sources/Spezi/Capabilities/Communication/CollectedModuleValues.swift b/Sources/Spezi/Capabilities/Communication/CollectedModuleValues.swift new file mode 100644 index 00000000..93cdbb2f --- /dev/null +++ b/Sources/Spezi/Capabilities/Communication/CollectedModuleValues.swift @@ -0,0 +1,79 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziFoundation + +protocol AnyCollectModuleValue { + associatedtype Value + + var moduleReference: ModuleReference { get } +} + +protocol AnyCollectModuleValues { + associatedtype Value + + var values: [any AnyCollectModuleValue] { get } + + mutating func removeValues(from module: any Module) -> Bool + + func store(into storage: inout SpeziStorage) +} + + +struct CollectModuleValue: AnyCollectModuleValue { + let value: Value + let moduleReference: ModuleReference + + init(_ value: Value) { + self.value = value + + guard let module = Spezi.moduleInitContext else { + preconditionFailure("Tried to initialize CollectModuleValue with unknown module init context.") + } + self.moduleReference = ModuleReference(module) + } +} + +/// Provides the ``KnowledgeSource`` for any value we store in the ``SpeziStorage`` that is +/// provided or request from am ``Module``. +/// +/// For more information, look at the ``Module/Provide`` or ``Module/Collect`` property wrappers. +struct CollectedModuleValues: DefaultProvidingKnowledgeSource { + typealias Anchor = SpeziAnchor + + typealias Value = [CollectModuleValue] + + + static var defaultValue: Value { + [] + } +} + + +extension Array: AnyCollectModuleValues where Element: AnyCollectModuleValue { + typealias Value = Element.Value + + var values: [any AnyCollectModuleValue] { + self + } + + mutating func removeValues(from module: any Module) -> Bool { + let previousCount = count + removeAll { entry in + entry.moduleReference == ModuleReference(module) + } + return previousCount != count + } + + func store(into storage: inout SpeziStorage) { + guard let values = self as? [CollectModuleValue] else { + preconditionFailure("Unexpected array type: \(type(of: self))") + } + storage[CollectedModuleValues.self] = values + } +} diff --git a/Sources/Spezi/Capabilities/Communication/ProvidePropertyWrapper.swift b/Sources/Spezi/Capabilities/Communication/ProvidePropertyWrapper.swift index 1704399b..1af02946 100644 --- a/Sources/Spezi/Capabilities/Communication/ProvidePropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/Communication/ProvidePropertyWrapper.swift @@ -116,19 +116,21 @@ extension _ProvidePropertyWrapper: StorageValueProvider { } else if let wrapperWithArray = self as? CollectionBasedProvideProperty { wrapperWithArray.collectArrayElements(into: &repository) } else { - // concatenation is handled by the `CollectedModuleValue/reduce` implementation. - repository[CollectedModuleValue.self] = [storedValue] + repository.appendValues([CollectModuleValue(storedValue)]) } collected = true } + + func clear() { + collected = false + } } extension _ProvidePropertyWrapper: CollectionBasedProvideProperty where Value: AnyArray { func collectArrayElements>(into repository: inout Repository) { - // concatenation is handled by the `CollectedModuleValue/reduce` implementation. - repository[CollectedModuleValue.self] = storedValue.unwrappedArray + repository.appendValues(storedValue.unwrappedArray.map { CollectModuleValue($0) }) } } @@ -136,7 +138,16 @@ extension _ProvidePropertyWrapper: CollectionBasedProvideProperty where Value: A extension _ProvidePropertyWrapper: OptionalBasedProvideProperty where Value: AnyOptional { func collectOptional>(into repository: inout Repository) { if let storedValue = storedValue.unwrappedOptional { - repository[CollectedModuleValue.self] = [storedValue] + repository.appendValues([CollectModuleValue(storedValue)]) } } } + + +extension SharedRepository where Anchor == SpeziAnchor { + fileprivate mutating func appendValues(_ values: [CollectModuleValue]) { + var current = self[CollectedModuleValues.self] + current.append(contentsOf: values) + self[CollectedModuleValues.self] = current + } +} diff --git a/Sources/Spezi/Capabilities/Communication/StorageValueCollector.swift b/Sources/Spezi/Capabilities/Communication/StorageValueCollector.swift index 0aabe33b..8ec8c3ac 100644 --- a/Sources/Spezi/Capabilities/Communication/StorageValueCollector.swift +++ b/Sources/Spezi/Capabilities/Communication/StorageValueCollector.swift @@ -13,7 +13,7 @@ import SpeziFoundation /// data provided by other ``Module``s. /// /// Data requested through a Storage Value Collector might be provided through a ``_StorageValueProvider``. -protocol StorageValueCollector { +protocol StorageValueCollector: SpeziPropertyWrapper { /// This method is called to retrieve all the requested values from the given ``SpeziStorage`` repository. /// - Parameter repository: Provides access to the ``SpeziStorage`` repository for read access. func retrieve>(from repository: Repository) diff --git a/Sources/Spezi/Capabilities/Communication/StorageValueProvider.swift b/Sources/Spezi/Capabilities/Communication/StorageValueProvider.swift index 846eab7e..fde95cfb 100644 --- a/Sources/Spezi/Capabilities/Communication/StorageValueProvider.swift +++ b/Sources/Spezi/Capabilities/Communication/StorageValueProvider.swift @@ -13,7 +13,7 @@ import SpeziFoundation /// data with other ``Module``s. /// /// Data provided through a Storage Value Provider can be retrieved through a ``_StorageValueCollector``. -protocol StorageValueProvider { +protocol StorageValueProvider: SpeziPropertyWrapper { /// This method is called to collect all provided values into the given ``SpeziStorage`` repository. /// - Parameter repository: Provides access to the ``SpeziStorage`` repository. func collect>(into repository: inout Repository) diff --git a/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift b/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift index 1a5bb4a7..4f72c6e1 100644 --- a/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift @@ -47,6 +47,13 @@ public class _ModelPropertyWrapper { } +extension _ModelPropertyWrapper: SpeziPropertyWrapper { + func clear() { + collected = false + } +} + + extension Module { /// Places an observable object in the global view environment. /// diff --git a/Sources/Spezi/Capabilities/ViewModifier/ModifierPropertyWrapper.swift b/Sources/Spezi/Capabilities/ViewModifier/ModifierPropertyWrapper.swift index 8e76a2e0..b32e69ed 100644 --- a/Sources/Spezi/Capabilities/ViewModifier/ModifierPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/ViewModifier/ModifierPropertyWrapper.swift @@ -46,6 +46,13 @@ public class _ModifierPropertyWrapper { } +extension _ModifierPropertyWrapper: SpeziPropertyWrapper { + func clear() { + collected = false + } +} + + extension Module { /// Provide a SwiftUI `ViewModifier` to modify the global view hierarchy. /// diff --git a/Sources/Spezi/Capabilities/ViewModifier/ViewModifierProvider.swift b/Sources/Spezi/Capabilities/ViewModifier/ViewModifierProvider.swift index 4ace938e..ffece94b 100644 --- a/Sources/Spezi/Capabilities/ViewModifier/ViewModifierProvider.swift +++ b/Sources/Spezi/Capabilities/ViewModifier/ViewModifierProvider.swift @@ -28,7 +28,7 @@ protocol ViewModifierProvider { /// Defines the placement order of this view modifier. /// - /// `ViewModifier`s retrieved from a ``Module` might modify the view hierarchy in a different order than they + /// `ViewModifier`s retrieved from a ``Module`` might modify the view hierarchy in a different order than they /// are supplied. This is important to, e.g., ensure that modifiers injecting model types are placed at the outermost /// level to ensure other view modifiers supplied by the module can access those model types. var placement: ModifierPlacement { get } diff --git a/Sources/Spezi/Dependencies/DependencyManager.swift b/Sources/Spezi/Dependencies/DependencyManager.swift index 46108bb5..94a71057 100644 --- a/Sources/Spezi/Dependencies/DependencyManager.swift +++ b/Sources/Spezi/Dependencies/DependencyManager.swift @@ -9,34 +9,59 @@ import XCTRuntimeAssertions -/// A ``DependencyManager`` in Spezi is used to gather information about modules with dependencies. +/// Gather information about modules with dependencies. public class DependencyManager { - /// Collection of sorted modules after resolving all dependencies. - var sortedModules: [any Module] + /// Collection of already initialized modules. + private let existingModules: [any Module] + + /// Collection of initialized Modules. + /// + /// Order is determined by the dependency tree. This represents the result of the dependency resolution process. + private(set) var initializedModules: [any Module] + /// List of implicitly created Modules. + /// + /// A List of `ModuleReference`s that where implicitly created (e.g., due to another module requesting it as a Dependency and + /// conforming to ``DefaultInitializable``). + /// This list is important to keep for the unload mechanism. + private(set) var implicitlyCreatedModules: Set = [] + /// Collection of all modules with dependencies that are not yet processed. private var modulesWithDependencies: [any Module] - /// Collection used to keep track of modules with dependencies in the recursive search. + /// Recursive search stack to keep track of potential circular dependencies. private var searchStack: [any Module] = [] /// A ``DependencyManager`` in Spezi is used to gather information about modules with dependencies. - /// - Parameter module: The modules that should be resolved. - init(_ module: [any Module]) { - sortedModules = module.filter { $0.dependencyDeclarations.isEmpty } - modulesWithDependencies = module.filter { !$0.dependencyDeclarations.isEmpty } + /// + /// - Parameters: + /// - modules: The modules that should be resolved. + /// - existingModules: Collection of already initialized modules. + init(_ modules: [any Module], existing existingModules: [any Module] = []) { + // modules without dependencies are already considered resolved + self.initializedModules = modules.filter { $0.dependencyDeclarations.isEmpty } + + self.modulesWithDependencies = modules.filter { !$0.dependencyDeclarations.isEmpty } + + self.existingModules = existingModules } /// Resolves the dependency order. /// - /// After calling `resolve` you can safely access `sortedModules`. + /// After calling `resolve` you can safely access `initializedModules`. func resolve() { // Start the dependency resolution on the first module. - if let nextModule = modulesWithDependencies.first { + while let nextModule = modulesWithDependencies.first { push(nextModule) } - for module in sortedModules { + injectDependencies() + } + + private func injectDependencies() { + // We inject dependencies into existingModules as well as a new dependency might be an optional dependency from a existing module + // that wasn't previously injected. + for module in initializedModules + existingModules { for dependency in module.dependencyDeclarations { dependency.inject(from: self) } @@ -49,19 +74,21 @@ public class DependencyManager { for dependency in module.dependencyDeclarations { dependency.collect(into: self) // leads to calls to `require(_:defaultValue:)` } - resolvedAllDependencies(module) + finishSearch(module) } /// Communicate a requirement to a `DependencyManager` /// - Parameters: /// - dependency: The type of the dependency that should be resolved. - /// - defaultValue: A default instance of the dependency that is used when the `dependencyType` is not present in the `sortedModules` or `modulesWithDependencies`. + /// - defaultValue: A default instance of the dependency that is used when the `dependencyType` is not present in the `initializedModules` or `modulesWithDependencies`. func require(_ dependency: M.Type, defaultValue: (() -> M)?) { - // 1. Return if the depending module is found in the `sortedModules` collection. - if sortedModules.contains(where: { type(of: $0) == M.self }) { + // 1. Return if the depending module is found in the `initializedModules` collection. + if initializedModules.contains(where: { type(of: $0) == M.self }) + || existingModules.contains(where: { type(of: $0) == M.self }) { return } - + + // 2. Search for the required module is found in the `dependingModules` collection. // If not, use the default value calling the `defaultValue` auto-closure. guard let foundInModulesWithDependencies = modulesWithDependencies.first(where: { type(of: $0) == M.self }) else { @@ -71,9 +98,11 @@ public class DependencyManager { } let newModule = defaultValue() - + + implicitlyCreatedModules.insert(ModuleReference(newModule)) + guard !newModule.dependencyDeclarations.isEmpty else { - sortedModules.append(newModule) + initializedModules.append(newModule) return } @@ -102,7 +131,7 @@ public class DependencyManager { ) } - // If there is no cycle, resolved the dependencies of the module found in the `dependingModules`. + // If there is no cycle, resolve the dependencies of the module found in the `dependingModules`. push(foundInModulesWithDependencies) } @@ -111,38 +140,35 @@ public class DependencyManager { /// - Parameters: /// - module: The ``Module`` type to return. /// - optional: Flag indicating if it is a optional return. + /// - Returns: Returns the Module instance. Only optional, if `optional` is set to `true` and no Module was found. func retrieve(module: M.Type = M.self, optional: Bool = false) -> M? { - guard let module = sortedModules.first(where: { $0 is M }) as? M else { + guard let candidate = initializedModules.first(where: { type(of: $0) == M.self }) ?? existingModules.first(where: { type(of: $0) == M.self }), + let module = candidate as? M else { precondition(optional, "Could not located dependency of type \(M.self)!") return nil } return module } - - private func resolvedAllDependencies(_ dependingModule: any Module) { + + private func finishSearch(_ dependingModule: any Module) { guard !searchStack.isEmpty else { - preconditionFailure("Internal logic error in the `DependencyManager`") + preconditionFailure("Internal logic error in the `DependencyManager`. Search Stack is empty.") } let module = searchStack.removeLast() - + guard module === dependingModule else { - preconditionFailure("Internal logic error in the `DependencyManager`") + preconditionFailure("Internal logic error in the `DependencyManager`. Search Stack element was not the one we are resolving for.") } - - + + let dependingModulesCount = modulesWithDependencies.count modulesWithDependencies.removeAll(where: { $0 === dependingModule }) precondition( dependingModulesCount - 1 == modulesWithDependencies.count, - "Unexpected reduction of modules. Ensure that all your modules conform to the same `Standard`" + "Unexpected reduction of modules." ) - - sortedModules.append(dependingModule) - - // Call the dependency resolution mechanism on the next element in the `dependingModules` if we are not in a recursive search. - if searchStack.isEmpty, let nextModule = modulesWithDependencies.first { - push(nextModule) - } + + initializedModules.append(dependingModule) } } diff --git a/Sources/Spezi/Dependencies/Module+DependencyRelation.swift b/Sources/Spezi/Dependencies/Module+DependencyRelation.swift new file mode 100644 index 00000000..eca22f1c --- /dev/null +++ b/Sources/Spezi/Dependencies/Module+DependencyRelation.swift @@ -0,0 +1,22 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +extension Module { + func dependencyRelation(to module: any Module) -> DependencyRelation { + let relations = dependencyDeclarations.map { $0.dependencyRelation(to: module) } + + if relations.contains(.dependent) { + return .dependent + } else if relations.contains(.optional) { + return .optional + } else { + return .unrelated + } + } +} diff --git a/Sources/Spezi/Dependencies/ModuleReference.swift b/Sources/Spezi/Dependencies/ModuleReference.swift new file mode 100644 index 00000000..8816ebd3 --- /dev/null +++ b/Sources/Spezi/Dependencies/ModuleReference.swift @@ -0,0 +1,16 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +struct ModuleReference: Hashable { + private let id: ObjectIdentifier + + init(_ module: any Module) { + self.id = ObjectIdentifier(module) + } +} diff --git a/Sources/Spezi/Dependencies/DependencyBuilder.swift b/Sources/Spezi/Dependencies/Property/DependencyBuilder.swift similarity index 100% rename from Sources/Spezi/Dependencies/DependencyBuilder.swift rename to Sources/Spezi/Dependencies/Property/DependencyBuilder.swift diff --git a/Sources/Spezi/Dependencies/DependencyCollection.swift b/Sources/Spezi/Dependencies/Property/DependencyCollection.swift similarity index 82% rename from Sources/Spezi/Dependencies/DependencyCollection.swift rename to Sources/Spezi/Dependencies/Property/DependencyCollection.swift index a60bce56..a7ab8261 100644 --- a/Sources/Spezi/Dependencies/DependencyCollection.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyCollection.swift @@ -11,6 +11,11 @@ public struct DependencyCollection: DependencyDeclaration { let entries: [AnyDependencyContext] + var injectedDependencies: [any Module] { + entries.reduce(into: []) { result, dependencies in + result.append(contentsOf: dependencies.injectedDependencies) + } + } init(_ entries: [AnyDependencyContext]) { self.entries = entries @@ -45,6 +50,19 @@ public struct DependencyCollection: DependencyDeclaration { } + func dependencyRelation(to module: any Module) -> DependencyRelation { + let relations = entries.map { $0.dependencyRelation(to: module) } + + if relations.contains(.dependent) { + return .dependent + } else if relations.contains(.optional) { + return .optional + } else { + return .unrelated + } + } + + func collect(into dependencyManager: DependencyManager) { for entry in entries { entry.collect(into: dependencyManager) @@ -57,6 +75,12 @@ public struct DependencyCollection: DependencyDeclaration { } } + func uninjectDependencies() { + for entry in entries { + entry.uninjectDependencies() + } + } + private func singleDependencyContext() -> AnyDependencyContext { guard let dependency = entries.first else { preconditionFailure("DependencyCollection unexpectedly empty!") diff --git a/Sources/Spezi/Dependencies/DependencyCollectionBuilder.swift b/Sources/Spezi/Dependencies/Property/DependencyCollectionBuilder.swift similarity index 100% rename from Sources/Spezi/Dependencies/DependencyCollectionBuilder.swift rename to Sources/Spezi/Dependencies/Property/DependencyCollectionBuilder.swift diff --git a/Sources/Spezi/Dependencies/DependencyContext.swift b/Sources/Spezi/Dependencies/Property/DependencyContext.swift similarity index 77% rename from Sources/Spezi/Dependencies/DependencyContext.swift rename to Sources/Spezi/Dependencies/Property/DependencyContext.swift index a7b34724..ae19e52f 100644 --- a/Sources/Spezi/Dependencies/DependencyContext.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyContext.swift @@ -20,17 +20,43 @@ class DependencyContext: AnyDependencyContext { let defaultValue: (() -> Dependency)? private var injectedDependency: Dependency? + + var isOptional: Bool { + defaultValue == nil + } + + var injectedDependencies: [any Module] { + injectedDependency.map { [$0] } ?? [] + } + init(for type: Dependency.Type = Dependency.self, defaultValue: (() -> Dependency)? = nil) { self.defaultValue = defaultValue } + func dependencyRelation(to module: any Module) -> DependencyRelation { + let type = type(of: module) + + guard type == Dependency.self else { + return .unrelated + } + + if isOptional { + return .optional + } else { + return .dependent + } + } + func collect(into dependencyManager: DependencyManager) { dependencyManager.require(Dependency.self, defaultValue: defaultValue) } func inject(from dependencyManager: DependencyManager) { - precondition(injectedDependency == nil, "Dependency of type \(Dependency.self) is already injected!") - injectedDependency = dependencyManager.retrieve(optional: defaultValue == nil) + injectedDependency = dependencyManager.retrieve(optional: isOptional) + } + + func uninjectDependencies() { + injectedDependency = nil } func retrieve(dependency: M.Type) -> M { diff --git a/Sources/Spezi/Dependencies/DependencyDeclaration.swift b/Sources/Spezi/Dependencies/Property/DependencyDeclaration.swift similarity index 54% rename from Sources/Spezi/Dependencies/DependencyDeclaration.swift rename to Sources/Spezi/Dependencies/Property/DependencyDeclaration.swift index c7e21ed2..d546b0e8 100644 --- a/Sources/Spezi/Dependencies/DependencyDeclaration.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyDeclaration.swift @@ -6,17 +6,39 @@ // SPDX-License-Identifier: MIT // +/// The relationship of given `Module` to another `Module`. +enum DependencyRelation: Hashable { + /// The given module is a dependency of the other module. + case dependent + /// The given module is an optional dependency of the other module. + case optional + /// The given module is not a dependency of the other module. + case unrelated +} + /// Provides mechanism to communicate dependency requirements. /// /// This protocol allows to communicate dependency requirements of a ``Module`` to the ``DependencyManager``. protocol DependencyDeclaration { + /// List of injected dependencies. + var injectedDependencies: [any Module] { get } + /// Request from the ``DependencyManager`` to collect all dependencies. Mark required by calling `DependencyManager/require(_:defaultValue:)`. func collect(into dependencyManager: DependencyManager) /// Inject the dependency instance from the ``DependencyManager``. Use `DependencyManager/retrieve(module:)`. func inject(from dependencyManager: DependencyManager) + + /// Remove all dependency injections. + func uninjectDependencies() + + /// Determine the dependency relationship to a given module. + /// - Parameter module: The module to retrieve the dependency relationship for. + /// - Returns: Returns the `DependencyRelation` + func dependencyRelation(to module: any Module) -> DependencyRelation } + extension Module { var dependencyDeclarations: [DependencyDeclaration] { retrieveProperties(ofType: DependencyDeclaration.self) diff --git a/Sources/Spezi/Dependencies/DependencyPropertyWrapper.swift b/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift similarity index 90% rename from Sources/Spezi/Dependencies/DependencyPropertyWrapper.swift rename to Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift index edf1f2c5..5a223521 100644 --- a/Sources/Spezi/Dependencies/DependencyPropertyWrapper.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift @@ -30,9 +30,10 @@ private protocol ModuleArrayDependency { /// Refer to the documentation of ``Module/Dependency`` for information on how to use the `@Dependency` property wrapper. @propertyWrapper -public class _DependencyPropertyWrapper: DependencyDeclaration { // swiftlint:disable:this type_name +public class _DependencyPropertyWrapper { // swiftlint:disable:this type_name private let dependencies: DependencyCollection + /// The dependency value. public var wrappedValue: Value { if let singleModule = self as? SingleModuleDependency { return singleModule.wrappedValue(as: Value.self) @@ -55,7 +56,25 @@ public class _DependencyPropertyWrapper: DependencyDeclaration { // swift // this init is placed here directly, otherwise Swift has problems resolving this init self.init(wrappedValue: Value()) } +} + + +extension _DependencyPropertyWrapper: SpeziPropertyWrapper { + func clear() { + uninjectDependencies() + } +} + +extension _DependencyPropertyWrapper: DependencyDeclaration { + var injectedDependencies: [any Module] { + dependencies.injectedDependencies + } + + + func dependencyRelation(to module: any Module) -> DependencyRelation { + dependencies.dependencyRelation(to: module) + } func collect(into dependencyManager: DependencyManager) { dependencies.collect(into: dependencyManager) @@ -64,6 +83,10 @@ public class _DependencyPropertyWrapper: DependencyDeclaration { // swift func inject(from dependencyManager: DependencyManager) { dependencies.inject(from: dependencyManager) } + + func uninjectDependencies() { + dependencies.uninjectDependencies() + } } diff --git a/Sources/Spezi/Dependencies/Module+Dependencies.swift b/Sources/Spezi/Dependencies/Property/Module+Dependencies.swift similarity index 100% rename from Sources/Spezi/Dependencies/Module+Dependencies.swift rename to Sources/Spezi/Dependencies/Property/Module+Dependencies.swift diff --git a/Sources/Spezi/Spezi/KnowledgeSources/LaunchOptionsKey.swift b/Sources/Spezi/Spezi/KnowledgeSources/LaunchOptionsKey.swift index b1db01f1..59f8b460 100644 --- a/Sources/Spezi/Spezi/KnowledgeSources/LaunchOptionsKey.swift +++ b/Sources/Spezi/Spezi/KnowledgeSources/LaunchOptionsKey.swift @@ -17,7 +17,7 @@ public struct LaunchOptionsKey: DefaultProvidingKnowledgeSource { public typealias Value = [UIApplication.LaunchOptionsKey: Any] #elseif os(macOS) /// Currently not supported as ``SpeziAppDelegate/applicationWillFinishLaunching(_:)`` on macOS - /// is executed after the initialization of ``Spezi`` via `View/spezi(_:)` is done, breaking our initialization assumption in ``SpeziAppDelegate/applicationWillFinishLaunching(_:)``. + /// is executed after the initialization of ``Spezi/Spezi`` via `View/spezi(_:)` is done, breaking our initialization assumption in ``SpeziAppDelegate/applicationWillFinishLaunching(_:)``. public typealias Value = [Never: Any] #else // os(watchOS) public typealias Value = [Never: Any] diff --git a/Sources/Spezi/Spezi/Spezi.swift b/Sources/Spezi/Spezi/Spezi.swift index 62ac8afe..fe059b28 100644 --- a/Sources/Spezi/Spezi/Spezi.swift +++ b/Sources/Spezi/Spezi/Spezi.swift @@ -10,13 +10,23 @@ import os import SpeziFoundation import SwiftUI +import XCTRuntimeAssertions /// A ``SharedRepository`` implementation that is anchored to ``SpeziAnchor``. /// -/// This represents the central ``Spezi`` storage module. +/// This represents the central ``Spezi/Spezi`` storage module. @_documentation(visibility: internal) -public typealias SpeziStorage = HeapRepository +public typealias SpeziStorage = ValueRepository + + +private struct ImplicitlyCreatedModulesKey: DefaultProvidingKnowledgeSource { + typealias Value = Set + typealias Anchor = SpeziAnchor + + static let defaultValue: Value = [] +} + /// Open-source framework for rapid development of modern, interoperable digital health applications. /// @@ -58,27 +68,56 @@ public typealias SpeziStorage = HeapRepository /// The ``Module`` documentation provides more information about the structure of modules. /// Refer to the ``Configuration`` documentation to learn more about the Spezi configuration. /// +/// ### Dynamically Loading Modules +/// +/// While the above examples demonstrated how Modules are configured within your ``SpeziAppDelegate``, they can also be loaded and unloaded dynamically based on demand. +/// To do so you, you need to access the global `Spezi` instance from within your Module. +/// +/// Below is a short code example: +/// ```swift +/// class ExampleModule: Module { +/// @Application(\.spezi) +/// var spezi +/// +/// func userAuthenticated() { +/// spezi.loadModule(AccountManagement()) +/// // ... +/// } +/// } +/// ``` +/// /// ## Topics /// /// ### Properties /// - ``logger`` /// - ``launchOptions`` +/// - ``spezi`` /// /// ### Actions /// - ``registerRemoteNotifications`` /// - ``unregisterRemoteNotifications`` +@Observable public class Spezi { static let logger = Logger(subsystem: "edu.stanford.spezi", category: "Spezi") - + @TaskLocal static var moduleInitContext: (any Module)? - - /// A shared repository to store any ``KnowledgeSource``s restricted to the ``SpeziAnchor``. + + let standard: any Standard + /// A shared repository to store any `KnowledgeSource`s restricted to the ``SpeziAnchor``. /// /// Every `Module` automatically conforms to `KnowledgeSource` and is stored within this storage object. fileprivate(set) var storage: SpeziStorage - /// Array of all SwiftUI `ViewModifiers` collected using ``_ModifierPropertyWrapper`` from the configured ``Module``s. - var viewModifiers: [any ViewModifier] + private var _viewModifiers: [ModuleReference: [any ViewModifier]] = [:] + + /// Array of all SwiftUI `ViewModifiers` collected using `_ModifierPropertyWrapper` from the configured ``Module``s. + /// + /// Any changes to this property will cause a complete re-render of the SwiftUI view hierarchy. See `SpeziViewModifier`. + var viewModifiers: [any ViewModifier] { + _viewModifiers.reduce(into: []) { partialResult, entry in + partialResult.append(contentsOf: entry.value) + } + } /// A collection of ``Spezi/Spezi`` `LifecycleHandler`s. @available( @@ -89,27 +128,59 @@ public class Spezi { Otherwise use the SwiftUI onReceive(_:perform:) for UI related notifications. """ ) - - @_spi(Spezi) public var lifecycleHandler: [LifecycleHandler] { storage.collect(allOf: LifecycleHandler.self) } - + var notificationTokenHandler: [NotificationTokenHandler] { storage.collect(allOf: NotificationTokenHandler.self) } - + var notificationHandler: [NotificationHandler] { storage.collect(allOf: NotificationHandler.self) } - - + + var modules: [any Module] { + storage.collect(allOf: (any Module).self) + } + + private var implicitlyCreatedModules: Set { + get { + storage[ImplicitlyCreatedModulesKey.self] + } + set { + if newValue.isEmpty { + storage[ImplicitlyCreatedModulesKey.self] = nil + } else { + storage[ImplicitlyCreatedModulesKey.self] = newValue + } + } + } + + /// Access the global Spezi instance. + /// + /// Access the global Spezi instance using the ``Module/Application`` property wrapper inside your ``Module``. + /// + /// Below is a short code example on how to access the Spezi instance. + /// + /// ```swift + /// class ExampleModule: Module { + /// @Application(\.spezi) + /// var spezi + /// } + /// ``` + public var spezi: Spezi { + // this seems nonsensical, but is essential to support Spezi access from the @Application modifier + self + } + + convenience init(from configuration: Configuration, storage: consuming SpeziStorage = SpeziStorage()) { self.init(standard: configuration.standard, modules: configuration.modules.elements, storage: storage) } - + /// Create a new Spezi instance. /// /// - Parameters: @@ -122,57 +193,205 @@ public class Spezi { modules: [any Module], storage: consuming SpeziStorage = SpeziStorage() ) { - // mutable property, as StorageValueProvider has inout protocol requirement. - var storage = consume storage - var collectedModifiers: [any ViewModifier] = [] - - let dependencyManager = DependencyManager(modules + [standard]) + self.standard = standard + self.storage = consume storage + + self.loadModules([self.standard] + modules) + } + + /// Load a new Module. + /// + /// Loads a new Spezi ``Module`` resolving all dependencies. + /// - Note: Trying to load the same ``Module`` instance multiple times results in a runtime crash. + /// + /// - Important: While ``Module/Modifier`` and ``Module/Model`` properties and the ``EnvironmentAccessible`` protocol + /// are generally supported with dynamically loaded Modules, they will cause the global SwiftUI view hierarchy to re-render. + /// This might be undesirable und will cause interruptions. Therefore, avoid dynamcially loading Modules with these properties. + /// + /// - Parameter module: The new Module instance to load. + public func loadModule(_ module: any Module) { + loadModules([module]) + } + + private func loadModules(_ modules: [any Module]) { + precondition(Self.moduleInitContext == nil, "Modules cannot be loaded within the `configure()` method.") + let existingModules = self.modules + + let dependencyManager = DependencyManager(modules, existing: existingModules) dependencyManager.resolve() + + implicitlyCreatedModules.formUnion(dependencyManager.implicitlyCreatedModules) + + // we pass through the whole list of modules once to collect all @Provide values + for module in dependencyManager.initializedModules { + Self.$moduleInitContext.withValue(module) { + module.collectModuleValues(into: &storage) + } + } + + for module in dependencyManager.initializedModules { + self.initModule(module) + } + + + // Newly loaded modules might have @Provide values that need to be updated in @Collect properties in existing modules. + for existingModule in existingModules { + existingModule.injectModuleValues(from: storage) + } + } + + /// Unload a Module. + /// + /// Unloads a ``Module`` from the Spezi system. + /// - Important: Unloading a ``Module`` that is still required by other modules results in a runtime crash. + /// However, unloading a Module that is the **optional** dependency of another Module works. + /// + /// Unloading a Module will recursively unload its dependencies that were not loaded explicitly. + /// + /// - Parameter module: The Module to unload. + public func unloadModule(_ module: any Module) { + precondition(Self.moduleInitContext == nil, "Modules cannot be unloaded within the `configure()` method.") + + guard module.isLoaded(in: self) else { + return // module is not loaded + } + + let dependents = retrieveDependingModules(module) + precondition(dependents.isEmpty, "Tried to unload Module \(type(of: module)) that is still required by peer Modules: \(dependents)") + + module.clearModule(from: self) + + implicitlyCreatedModules.remove(ModuleReference(module)) + + removeCollectValues(for: module) - for module in dependencyManager.sortedModules { - // we pass through the whole list of modules once to collect all @Provide values - module.collectModuleValues(into: &storage) + // this check is important. Change to viewModifiers re-renders the whole SwiftUI view hierarchy. So avoid to do it unnecessarily + if _viewModifiers[ModuleReference(module)] != nil { + _viewModifiers[ModuleReference(module)] = nil + } + + // re-injecting all dependencies ensures that the unloaded module is cleared from optional Dependencies from + // pre-existing Modules. + let dependencyManager = DependencyManager([], existing: modules) + dependencyManager.resolve() + + + // Check if we need to unload additional modules that were not explicitly created. + // For example a explicitly loaded Module might have recursive @Dependency declarations that are automatically loaded. + // Such modules are unloaded as well if they are no longer required. + for dependencyDeclaration in module.dependencyDeclarations { + let dependencies = dependencyDeclaration.injectedDependencies + for dependency in dependencies { + guard implicitlyCreatedModules.contains(ModuleReference(dependency)) else { + // we only recursively unload modules that have been created implicitly + continue + } + + guard retrieveDependingModules(dependency).isEmpty else { + continue + } + + unloadModule(dependency) + } + } + + module.clear() + } + + private func removeCollectValues(for module: any Module) { + let valueContainers = storage.collect(allOf: (any AnyCollectModuleValues).self) + + var changed = false + for var container in valueContainers { + let didChange = container.removeValues(from: module) + guard didChange else { + continue + } + + changed = true + container.store(into: &storage) + } + + guard changed else { + return } + + for module in modules { + module.injectModuleValues(from: storage) + } + } - self.storage = storage - self.viewModifiers = [] // init all properties, we will store the final result later on + /// Initialize a Module. + /// + /// Call this method to initialize a Module, injecting necessary information into Spezi property wrappers. + /// + /// - Parameters: + /// - module: The module to initialize. + private func initModule(_ module: any Module) { + precondition(!module.isLoaded(in: self), "Tried to initialize Module \(type(of: module)) that was already loaded!") - for module in dependencyManager.sortedModules { - Self.$moduleInitContext.withValue(module) { - module.inject(standard: standard) - module.inject(spezi: self) + Self.$moduleInitContext.withValue(module) { + module.inject(spezi: self) - // supply modules values to all @Collect - module.injectModuleValues(from: self.storage) + // supply modules values to all @Collect + module.injectModuleValues(from: storage) - module.configure() - module.storeModule(into: self) + module.configure() + module.storeModule(into: self) - collectedModifiers.append(contentsOf: module.viewModifiers) + let viewModifiers = module.viewModifiers + // this check is important. Change to viewModifiers re-renders the whole SwiftUI view hierarchy. So avoid to do it unnecessarily + if !viewModifiers.isEmpty { + _viewModifiers[ModuleReference(module), default: []].append(contentsOf: module.viewModifiers) + } - // If a module is @Observable, we automatically inject it view the `ModelModifier` into the environment. - if let observable = module as? EnvironmentAccessible { - collectedModifiers.append(observable.viewModifier) - } + // If a module is @Observable, we automatically inject it view the `ModelModifier` into the environment. + if let observable = module as? EnvironmentAccessible { + _viewModifiers[ModuleReference(module), default: []].append(observable.viewModifier) } } - - self.viewModifiers = collectedModifiers } - + /// Determine if a application property is stored as a copy in a `@Application` property wrapper. func createsCopy(_ keyPath: KeyPath) -> Bool { keyPath == \.logger // loggers are created per Module. } + + private func retrieveDependingModules(_ dependency: any Module, considerOptionals: Bool = false) -> [any Module] { + var result: [any Module] = [] + + for module in modules { + switch module.dependencyRelation(to: dependency) { + case .dependent: + result.append(module) + case .optional: + if considerOptionals { + result.append(module) + } + case .unrelated: + continue + } + } + + return result + } } extension Module { - func storeModule(into spezi: Spezi) { + fileprivate func storeModule(into spezi: Spezi) { guard let value = self as? Value else { spezi.logger.warning("Could not store \(Self.self) in the SpeziStorage as the `Value` typealias was modified.") return } spezi.storage[Self.self] = value } + + fileprivate func isLoaded(in spezi: Spezi) -> Bool { + spezi.storage[Self.self] != nil + } + + fileprivate func clearModule(from spezi: Spezi) { + spezi.storage[Self.self] = nil + } } diff --git a/Sources/Spezi/Spezi/SpeziAppDelegate.swift b/Sources/Spezi/Spezi/SpeziAppDelegate.swift index ccb15486..511ad798 100644 --- a/Sources/Spezi/Spezi/SpeziAppDelegate.swift +++ b/Sources/Spezi/Spezi/SpeziAppDelegate.swift @@ -170,9 +170,11 @@ open class SpeziAppDelegate: NSObject, ApplicationDelegate { } } - return await group.reduce(into: []) { result, backgroundFetchResult in + var result: Set = [] + for await backgroundFetchResult in group { result.insert(backgroundFetchResult) } + return result } if result.contains(.failed) { diff --git a/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift b/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift index 6a30447c..80eebd42 100644 --- a/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift +++ b/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift @@ -47,13 +47,15 @@ class SpeziNotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegat } var hasSpecified = false - let unionOptions: UNNotificationPresentationOptions = await group.reduce(into: []) { result, options in + + var unionOptions: UNNotificationPresentationOptions = [] + for await options in group { guard let options else { - return + continue } hasSpecified = true - result.formUnion(options) + unionOptions.formUnion(options) } if hasSpecified { diff --git a/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift b/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift index ffe3f0b4..d29fc9d0 100644 --- a/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift +++ b/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift @@ -8,7 +8,20 @@ protocol SpeziPropertyWrapper { + /// Inject the global Spezi instance. + /// + /// This call happens right before ``Module/configure()-5pa83`` is called. + /// An empty default implementation is provided. + /// - Parameter spezi: The global ``Spezi/Spezi`` instance. func inject(spezi: Spezi) + + /// Clear the property wrapper state before the Module is unloaded. + func clear() +} + + +extension SpeziPropertyWrapper { + func inject(spezi: Spezi) {} } @@ -18,4 +31,10 @@ extension Module { wrapper.inject(spezi: spezi) } } + + func clear() { + for wrapper in retrieveProperties(ofType: SpeziPropertyWrapper.self) { + wrapper.clear() + } + } } diff --git a/Sources/Spezi/Spezi/View+Spezi.swift b/Sources/Spezi/Spezi/View+Spezi.swift index afc87d61..e0972ae4 100644 --- a/Sources/Spezi/Spezi/View+Spezi.swift +++ b/Sources/Spezi/Spezi/View+Spezi.swift @@ -11,16 +11,17 @@ import SwiftUI struct SpeziViewModifier: ViewModifier { - let speziViewModifiers: [any ViewModifier] + @State private var spezi: Spezi - init(_ anySpezi: Spezi) { - self.speziViewModifiers = anySpezi.viewModifiers + init(_ spezi: Spezi) { + self.spezi = spezi } func body(content: Content) -> some View { - speziViewModifiers.modify(content) + spezi.viewModifiers + .modify(content) } } diff --git a/Sources/Spezi/Standard/AnyStandardPropertyWrapper.swift b/Sources/Spezi/Standard/AnyStandardPropertyWrapper.swift deleted file mode 100644 index 5f58f4fc..00000000 --- a/Sources/Spezi/Standard/AnyStandardPropertyWrapper.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - - -protocol AnyStandardPropertyWrapper { - func inject(standard: S) -} - - -extension Module { - func inject(standard: any Standard) { - for standardPropertyWrapper in retrieveProperties(ofType: AnyStandardPropertyWrapper.self) { - standardPropertyWrapper.inject(standard: standard) - } - } -} diff --git a/Sources/Spezi/Standard/StandardPropertyWrapper.swift b/Sources/Spezi/Standard/StandardPropertyWrapper.swift index e512c3b1..7b3669c0 100644 --- a/Sources/Spezi/Standard/StandardPropertyWrapper.swift +++ b/Sources/Spezi/Standard/StandardPropertyWrapper.swift @@ -11,14 +11,14 @@ import XCTRuntimeAssertions /// Refer to ``Module/StandardActor`` for information on how to use the `@StandardActor` property wrapper. Do not use the `_StandardPropertyWrapper` directly. @propertyWrapper -public class _StandardPropertyWrapper: AnyStandardPropertyWrapper { +public class _StandardPropertyWrapper { // swiftlint:disable:previous type_name // We want the _StandardPropertyWrapper type to be hidden from autocompletion and document generation. private var standard: Constraint? - /// The injected ``Standard`` that is resolved by ``Spezi`` + /// The injected ``Standard`` that is resolved by ``Spezi/Spezi``. public var wrappedValue: Constraint { guard let standard else { preconditionFailure( @@ -33,35 +33,42 @@ public class _StandardPropertyWrapper: AnyStandardPropertyWrapper { /// Refer to ``Module/StandardActor`` for information on how to use the `@StandardActor` property wrapper. Do not use the `_StandardPropertyWrapper` directly. - public init(_ constraint: Constraint.Type = Constraint.self) { } - - - func inject(standard: S) { - guard let standard = standard as? Constraint else { + public init(_ constraint: Constraint.Type = Constraint.self) {} +} + + +extension _StandardPropertyWrapper: SpeziPropertyWrapper { + func inject(spezi: Spezi) { + guard let standard = spezi.standard as? Constraint else { + let standardType = type(of: spezi.standard) preconditionFailure( """ The `Standard` defined in the `Configuration` does not conform to \(String(describing: Constraint.self)). - + Ensure that you define an appropriate standard in your configuration in your `SpeziAppDelegate` subclass ... ``` var configuration: Configuration { - Configuration(standard: \(String(describing: S.self))()) { + Configuration(standard: \(String(describing: standardType))()) { // ... } } ``` - + ... and that your standard conforms to \(String(describing: Constraint.self)): - + ```swift - actor \(String(describing: S.self)): Standard, \(String(describing: Constraint.self)) { + actor \(String(describing: standardType)): Standard, \(String(describing: Constraint.self)) { // ... } ``` """ ) } - + self.standard = standard } + + func clear() { + standard = nil + } } diff --git a/Tests/SpeziTests/DependenciesTests/DependencyBuilderTests.swift b/Tests/SpeziTests/DependenciesTests/DependencyBuilderTests.swift index bcb6d22b..7bab8c8d 100644 --- a/Tests/SpeziTests/DependenciesTests/DependencyBuilderTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DependencyBuilderTests.swift @@ -38,9 +38,9 @@ final class DependencyBuilderTests: XCTestCase { let module = ExampleDependencyModule { ExampleDependentModule() } - let sortedModules = DependencyManager.resolve([module]) - XCTAssertEqual(sortedModules.count, 2) - _ = try XCTUnwrap(sortedModules[0] as? ExampleDependentModule) - _ = try XCTUnwrap(sortedModules[1] as? ExampleDependencyModule) + let initializedModules = DependencyManager.resolve([module]) + XCTAssertEqual(initializedModules.count, 2) + _ = try XCTUnwrap(initializedModules[0] as? ExampleDependentModule) + _ = try XCTUnwrap(initializedModules[1] as? ExampleDependencyModule) } } diff --git a/Tests/SpeziTests/DependenciesTests/DependencyManager+OneShot.swift b/Tests/SpeziTests/DependenciesTests/DependencyManager+OneShot.swift index 045c3cb3..127f3fd6 100644 --- a/Tests/SpeziTests/DependenciesTests/DependencyManager+OneShot.swift +++ b/Tests/SpeziTests/DependenciesTests/DependencyManager+OneShot.swift @@ -13,6 +13,6 @@ extension DependencyManager { static func resolve(_ modules: [any Module]) -> [any Module] { let dependencyManager = DependencyManager(modules) dependencyManager.resolve() - return dependencyManager.sortedModules + return dependencyManager.initializedModules } } diff --git a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift index 83d02510..c1591520 100644 --- a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift @@ -6,40 +6,67 @@ // SPDX-License-Identifier: MIT // -@testable import Spezi +@_spi(Spezi) @testable import Spezi import SwiftUI import XCTest import XCTRuntimeAssertions private final class TestModule1: Module { + let deinitExpectation: XCTestExpectation + @Dependency var testModule2 = TestModule2() @Dependency var testModule3: TestModule3 + + @Provide var num: Int = 1 + + init(deinitExpectation: XCTestExpectation = XCTestExpectation()) { + self.deinitExpectation = deinitExpectation + } + + deinit { + deinitExpectation.fulfill() + } } private final class TestModule2: Module { @Dependency var testModule4 = TestModule4() @Dependency var testModule5 = TestModule5() @Dependency var testModule3: TestModule3 + + @Provide var num: Int = 2 } -private final class TestModule3: Module, DefaultInitializable { +private final class TestModule3: Module, DefaultInitializable, EnvironmentAccessible { + // EnvironmentAccessible conformance tests that `ModelModifier(model: self)` are removed and no memory leaks occur in Module unloading let state: Int + let deinitExpectation: XCTestExpectation + + @Provide var num: Int = 3 convenience init() { self.init(state: 0) } - init(state: Int) { + init(state: Int, deinitExpectation: XCTestExpectation = .init()) { self.state = state + self.deinitExpectation = deinitExpectation + } + + deinit { + deinitExpectation.fulfill() } } private final class TestModule4: Module { @Dependency var testModule5 = TestModule5() + + @Provide var num: Int = 4 } -private final class TestModule5: Module {} +private final class TestModule5: Module { + @Provide var num: Int = 5 +} private final class TestModule6: Module {} @@ -62,6 +89,27 @@ private final class TestModuleItself: Module { private final class OptionalModuleDependency: Module { @Dependency var testModule3: TestModule3? + + @Collect var nums: [Int] +} + +private final class AllPropertiesModule: Module { + @Observable + class MyModel {} + struct MyViewModifier: ViewModifier { + func body(content: Content) -> some View { + content + } + } + + @Dependency var testModule3: TestModule3 + @Application(\.logger) var logger + @Application(\.spezi) var spezi + @Collect var nums: [Int] + @Provide var num: Int = 3 + @Model var model = MyModel() + @Modifier var modifier = MyViewModifier() + @StandardActor var defaultStandard: any Standard } private final class OptionalDependencyWithRuntimeDefault: Module { @@ -76,23 +124,150 @@ private final class OptionalDependencyWithRuntimeDefault: Module { final class DependencyTests: XCTestCase { + func testLoadingAdditionalDependency() throws { + let spezi = Spezi(standard: DefaultStandard(), modules: [OptionalModuleDependency()]) + + var modules = spezi.modules + func getModule(_ module: M.Type = M.self) throws -> M { + try XCTUnwrap(modules.first(where: { $0 is M }) as? M) + } + + XCTAssertEqual(modules.count, 2) + _ = try getModule(DefaultStandard.self) + var optionalModuleDependency: OptionalModuleDependency = try getModule() + + XCTAssertNil(optionalModuleDependency.testModule3) + + spezi.loadModule(TestModule3()) + + modules = spezi.modules + XCTAssertEqual(modules.count, 3) + _ = try getModule(DefaultStandard.self) + optionalModuleDependency = try getModule() + var testModule3: TestModule3 = try getModule() + + XCTAssert(optionalModuleDependency.testModule3 === testModule3) + + spezi.loadModule(TestModule1()) + + modules = spezi.modules + XCTAssertEqual(modules.count, 7) + + _ = try getModule(DefaultStandard.self) + let testModule1: TestModule1 = try getModule() + let testModule2: TestModule2 = try getModule() + testModule3 = try getModule() + let testModule4: TestModule4 = try getModule() + let testModule5: TestModule5 = try getModule() + optionalModuleDependency = try getModule() + + XCTAssert(testModule4.testModule5 === testModule5) + XCTAssert(testModule2.testModule5 === testModule5) + XCTAssert(testModule2.testModule4 === testModule4) + XCTAssert(testModule2.testModule3 === testModule3) + XCTAssert(testModule1.testModule2 === testModule2) + XCTAssert(testModule1.testModule3 === testModule3) + XCTAssert(testModule1.testModule2.testModule3 === testModule3) + XCTAssert(testModule1.testModule2.testModule4.testModule5 === testModule5) + XCTAssert(optionalModuleDependency.testModule3 === testModule3) + } + + func testImpossibleUnloading() throws { + let module3 = TestModule3() + let spezi = Spezi(standard: DefaultStandard(), modules: [TestModule1(), module3]) + + try XCTRuntimePrecondition { + // cannot unload module that other modules still depend on + spezi.unloadModule(module3) + } + } + + func testMultiLoading() throws { + let module = AllPropertiesModule() + let spezi = Spezi(standard: DefaultStandard(), modules: [module]) + + spezi.unloadModule(module) + spezi.loadModule(module) + spezi.unloadModule(module) + } + + func testUnloadingDependencies() throws { + func runUnloadingTests(deinitExpectation1: XCTestExpectation, deinitExpectation3: XCTestExpectation) throws -> Spezi { + let optionalModule = OptionalModuleDependency() + let module3 = TestModule3(state: 0, deinitExpectation: deinitExpectation3) + let module1 = TestModule1(deinitExpectation: deinitExpectation1) + + let spezi = Spezi(standard: DefaultStandard(), modules: [optionalModule]) + + // test loading and unloading of @Collect/@Provide property values + XCTAssertEqual(optionalModule.nums, []) + + spezi.loadModule(module3) + XCTAssertEqual(optionalModule.nums, [3]) + + spezi.loadModule(module1) + XCTAssertEqual(optionalModule.nums, [3, 5, 4, 2, 1]) + + spezi.unloadModule(module1) + XCTAssertEqual(optionalModule.nums, [3]) + + var modules = spezi.modules + func getModule(_ module: M.Type = M.self) throws -> M { + try XCTUnwrap(modules.first(where: { $0 is M }) as? M) + } + + let optionalModuleLoaded: OptionalModuleDependency = try getModule() + let module3Loaded: TestModule3 = try getModule() + + XCTAssertNil(modules.first(where: { $0 is TestModule1 })) + XCTAssertNil(modules.first(where: { $0 is TestModule2 })) + XCTAssertNil(modules.first(where: { $0 is TestModule4 })) + XCTAssertNil(modules.first(where: { $0 is TestModule5 })) + + XCTAssert(optionalModuleLoaded.testModule3 === module3Loaded) + + spezi.unloadModule(module3) + XCTAssertEqual(optionalModule.nums, []) + + modules = spezi.modules + + XCTAssertNil(modules.first(where: { $0 is TestModule1 })) + XCTAssertNil(modules.first(where: { $0 is TestModule2 })) + XCTAssertNil(modules.first(where: { $0 is TestModule3 })) + XCTAssertNil(modules.first(where: { $0 is TestModule4 })) + XCTAssertNil(modules.first(where: { $0 is TestModule5 })) + + XCTAssertNil(try getModule(OptionalModuleDependency.self).testModule3) + return spezi + } + + let deinitExpectation1 = XCTestExpectation(description: "Deinit TestModule1") + let deinitExpectation3 = XCTestExpectation(description: "Deinit TestModule3") + + // make sure we keep the reference to `Spezi`, but loose all references to TestModule3 to test deinit getting called + let spezi = try runUnloadingTests(deinitExpectation1: deinitExpectation1, deinitExpectation3: deinitExpectation3) + _ = spezi // silence warning + + wait(for: [deinitExpectation1, deinitExpectation3]) + } + func testModuleDependencyChain() throws { let modules: [any Module] = [ TestModule6(), TestModule1(), TestModule7() ] - let sortedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolve(modules) - XCTAssertEqual(sortedModules.count, 7) + XCTAssertEqual(initializedModules.count, 7) - _ = try XCTUnwrap(sortedModules[0] as? TestModule6) - let testModuleMock5 = try XCTUnwrap(sortedModules[1] as? TestModule5) - let testModuleMock4 = try XCTUnwrap(sortedModules[2] as? TestModule4) - let testModuleMock3 = try XCTUnwrap(sortedModules[3] as? TestModule3) - let testModuleMock2 = try XCTUnwrap(sortedModules[4] as? TestModule2) - let testModuleMock1 = try XCTUnwrap(sortedModules[5] as? TestModule1) - _ = try XCTUnwrap(sortedModules[6] as? TestModule7) + _ = try XCTUnwrap(initializedModules[0] as? TestModule6) + let testModuleMock5 = try XCTUnwrap(initializedModules[1] as? TestModule5) + let testModuleMock4 = try XCTUnwrap(initializedModules[2] as? TestModule4) + let testModuleMock3 = try XCTUnwrap(initializedModules[3] as? TestModule3) + let testModuleMock2 = try XCTUnwrap(initializedModules[4] as? TestModule2) + let testModuleMock1 = try XCTUnwrap(initializedModules[5] as? TestModule1) + _ = try XCTUnwrap(initializedModules[6] as? TestModule7) XCTAssert(testModuleMock4.testModule5 === testModuleMock5) XCTAssert(testModuleMock2.testModule5 === testModuleMock5) @@ -109,14 +284,14 @@ final class DependencyTests: XCTestCase { TestModule2(), TestModule5() ] - let sortedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolve(modules) - XCTAssertEqual(sortedModules.count, 4) + XCTAssertEqual(initializedModules.count, 4) - let testModule5 = try XCTUnwrap(sortedModules[0] as? TestModule5) - let testModule4 = try XCTUnwrap(sortedModules[1] as? TestModule4) - let testModule3 = try XCTUnwrap(sortedModules[2] as? TestModule3) - let testModule2 = try XCTUnwrap(sortedModules[3] as? TestModule2) + let testModule5 = try XCTUnwrap(initializedModules[0] as? TestModule5) + let testModule4 = try XCTUnwrap(initializedModules[1] as? TestModule4) + let testModule3 = try XCTUnwrap(initializedModules[2] as? TestModule3) + let testModule2 = try XCTUnwrap(initializedModules[3] as? TestModule2) XCTAssert(testModule4.testModule5 === testModule5) XCTAssert(testModule2.testModule5 === testModule5) @@ -130,13 +305,13 @@ final class DependencyTests: XCTestCase { TestModule4(), TestModule4() ] - let sortedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolve(modules) - XCTAssertEqual(sortedModules.count, 3) + XCTAssertEqual(initializedModules.count, 3) - let testModule5 = try XCTUnwrap(sortedModules[0] as? TestModule5) - let testModule40 = try XCTUnwrap(sortedModules[1] as? TestModule4) - let testModule41 = try XCTUnwrap(sortedModules[2] as? TestModule4) + let testModule5 = try XCTUnwrap(initializedModules[0] as? TestModule5) + let testModule40 = try XCTUnwrap(initializedModules[1] as? TestModule4) + let testModule41 = try XCTUnwrap(initializedModules[2] as? TestModule4) XCTAssert(testModule40 !== testModule41) @@ -149,15 +324,15 @@ final class DependencyTests: XCTestCase { TestModule2(), TestModule2() ] - let sortedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolve(modules) - XCTAssertEqual(sortedModules.count, 5) + XCTAssertEqual(initializedModules.count, 5) - let testModule5 = try XCTUnwrap(sortedModules[0] as? TestModule5) - let testModule4 = try XCTUnwrap(sortedModules[1] as? TestModule4) - let testModule3 = try XCTUnwrap(sortedModules[2] as? TestModule3) - let testModule20 = try XCTUnwrap(sortedModules[3] as? TestModule2) - let testModule21 = try XCTUnwrap(sortedModules[4] as? TestModule2) + let testModule5 = try XCTUnwrap(initializedModules[0] as? TestModule5) + let testModule4 = try XCTUnwrap(initializedModules[1] as? TestModule4) + let testModule3 = try XCTUnwrap(initializedModules[2] as? TestModule3) + let testModule20 = try XCTUnwrap(initializedModules[3] as? TestModule2) + let testModule21 = try XCTUnwrap(initializedModules[4] as? TestModule2) XCTAssert(testModule4.testModule5 === testModule5) @@ -177,11 +352,11 @@ final class DependencyTests: XCTestCase { func testModuleNoDependency() throws { let modules: [any Module] = [TestModule5()] - let sortedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolve(modules) - XCTAssertEqual(sortedModules.count, 1) + XCTAssertEqual(initializedModules.count, 1) - _ = try XCTUnwrap(sortedModules[0] as? TestModule5) + _ = try XCTUnwrap(initializedModules[0] as? TestModule5) } func testModuleNoDependencyMultipleTimes() throws { @@ -190,13 +365,13 @@ final class DependencyTests: XCTestCase { TestModule5(), TestModule5() ] - let sortedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolve(modules) - XCTAssertEqual(sortedModules.count, 3) + XCTAssertEqual(initializedModules.count, 3) - _ = try XCTUnwrap(sortedModules[0] as? TestModule5) - _ = try XCTUnwrap(sortedModules[1] as? TestModule5) - _ = try XCTUnwrap(sortedModules[2] as? TestModule5) + _ = try XCTUnwrap(initializedModules[0] as? TestModule5) + _ = try XCTUnwrap(initializedModules[1] as? TestModule5) + _ = try XCTUnwrap(initializedModules[2] as? TestModule5) } func testModuleCycle() throws { diff --git a/Tests/SpeziTests/DependenciesTests/DynamicDependenciesTests.swift b/Tests/SpeziTests/DependenciesTests/DynamicDependenciesTests.swift index 13538dad..af28613a 100644 --- a/Tests/SpeziTests/DependenciesTests/DynamicDependenciesTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DynamicDependenciesTests.swift @@ -119,10 +119,10 @@ final class DynamicDependenciesTests: XCTestCase { return } - let sortedModules = DependencyManager.resolve(modules) - XCTAssertEqual(sortedModules.count, dynamicDependenciesTestCase.expectedNumberOfModules) + let initializedModules = DependencyManager.resolve(modules) + XCTAssertEqual(initializedModules.count, dynamicDependenciesTestCase.expectedNumberOfModules) - try sortedModules.moduleOfType(TestModule1.self).evaluateExpectations() + try initializedModules.moduleOfType(TestModule1.self).evaluateExpectations() } } } diff --git a/Tests/SpeziTests/ModuleTests/ModuleBuilderTests.swift b/Tests/SpeziTests/ModuleTests/ModuleBuilderTests.swift index c6284b02..1a7782da 100644 --- a/Tests/SpeziTests/ModuleTests/ModuleBuilderTests.swift +++ b/Tests/SpeziTests/ModuleTests/ModuleBuilderTests.swift @@ -88,8 +88,13 @@ final class ModuleBuilderTests: XCTestCase { condition: true, expectations: expectations ) - - _ = Spezi(standard: MockStandard(), modules: modules.elements) + + let dependencyManager = DependencyManager(modules.elements) + dependencyManager.resolve() + for module in dependencyManager.initializedModules { + module.configure() + } + try expectations.wait() } @@ -105,7 +110,12 @@ final class ModuleBuilderTests: XCTestCase { expectations: expectations ) - _ = Spezi(standard: MockStandard(), modules: modules.elements) + let dependencyManager = DependencyManager(modules.elements) + dependencyManager.resolve() + for module in dependencyManager.initializedModules { + module.configure() + } + try expectations.wait() } } diff --git a/Tests/SpeziTests/ModuleTests/ModuleTests.swift b/Tests/SpeziTests/ModuleTests/ModuleTests.swift index f0be4f76..78301bed 100644 --- a/Tests/SpeziTests/ModuleTests/ModuleTests.swift +++ b/Tests/SpeziTests/ModuleTests/ModuleTests.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -@testable import Spezi +@_spi(Spezi) @testable import Spezi import SwiftUI import XCTest import XCTRuntimeAssertions @@ -42,6 +42,16 @@ final class ModuleTests: XCTestCase { wait(for: [expectation]) } + func testSpezi() throws { + let spezi = Spezi(standard: DefaultStandard(), modules: [DependingTestModule()]) + + let modules = spezi.modules + XCTAssertEqual(modules.count, 3) + XCTAssert(modules.contains(where: { $0 is DefaultStandard })) + XCTAssert(modules.contains(where: { $0 is DependingTestModule })) + XCTAssert(modules.contains(where: { $0 is TestModule })) + } + func testPreviewModifier() throws { let expectation = XCTestExpectation(description: "Preview Module") expectation.assertForOverFulfill = true