diff --git a/.github/workflows/macOS.yml b/.github/workflows/macOS.yml index d49a1e2..1fc0047 100644 --- a/.github/workflows/macOS.yml +++ b/.github/workflows/macOS.yml @@ -19,3 +19,5 @@ jobs: - uses: actions/checkout@v3 - name: Build for release run: swift build -v -c release + - name: Test + run: swift test -v diff --git a/Package.swift b/Package.swift index 86c3879..4141c5b 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,4 @@ // swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -12,7 +11,6 @@ let package = Package( .tvOS(.v16) ], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "AppState", targets: ["AppState"] @@ -22,8 +20,6 @@ let package = Package( .package(url: "https://github.com/0xLeif/Cache", from: "2.0.0") ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. .target( name: "AppState", dependencies: [ diff --git a/README.md b/README.md index ea151a6..62baa73 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,18 @@ AppState is a Swift Package that simplifies the management of application state - **State:** Dedicated struct type for encapsulating and broadcasting value changes within the app's scope. +- **Dependency:** Dedicated struct type for encapsulating dependencies within the app's scope. + - **Scope:** Representation of a specific context within an app, defined by a unique name and ID. - **AppState (property wrapper):** A property wrapper that elegantly bridges `Application.State` with `SwiftUI` for seamless integration. +- **AppDependency (property wrapper):** A property wrapper that simplifies the handling of dependencies throughout your application. + ### Requirements - Swift 5.7 or later + - iOS 16.0 or later - watchOS 9.0 or later - macOS 13.0 or later @@ -114,6 +119,111 @@ struct ContentView: View { } ``` +### Defining Dependencies + +`Dependency` is a feature provided by AppState, allowing you to define shared resources or services in your application. + +To define a dependency, you should extend the `Application` object. Here's an example of defining a `networkService` dependency: + +```swift +extension Application { + var networkService: Dependency { + dependency(NetworkService()) + } +} +``` + +In this example, `Dependency` represents a type safe container for `NetworkService`. + +### Reading and Using Dependencies + +Once you've defined a dependency, you can access it anywhere in your app: + +```swift +let networkService = Application.dependency(\.networkService) +``` + +This approach allows you to work with dependencies in a type-safe manner, avoiding the need to manually handle errors related to incorrect types. + +### AppDependency Property Wrapper + +AppState provides the `@AppDependency` property wrapper that simplifies access to dependencies. When you annotate a property with `@AppDependency`, it fetches the appropriate dependency from the `Application` object directly. + +```swift +struct ContentView: View { + @AppDependency(\.networkService) var networkService + + // Your view code +} +``` + +In this case, ContentView has access to the networkService dependency and can use it within its code. + +### Using Dependency with ObservableObject + +When your dependency is an `ObservableObject`, any changes to it will automatically update your SwiftUI views. Make sure your service conforms to the `ObservableObject` protocol. To do this, you should not use the `@AppDependency` property wrapper, but instead use the `@ObservedObject` property wrapper. + +Here's an example: + +```swift +class DataService: ObservableObject { + @AppState(\.someData) var data: [String] +} + +extension Application { + var dataService: Dependency { + dependency(DataService()) + } +} + +struct ContentView: View { + @ObservedObject var dataService = Application.dependency(\.dataService) + + var body: some View { + List(dataService.data, id: \.self) { item in + Text(item) + } + .onAppear { + dataService.fetchData() + } + } +} +``` + +In this example, whenever data in `DataService` changes, SwiftUI automatically updates the `ContentView`. + +### Testing with Mock Dependencies + +One of the great advantages of using `Dependency` in AppState is the capability to replace dependencies with mock versions during testing. This is incredibly useful for isolating parts of your application for unit testing. + +You can replace a dependency by calling the `override(_:_:)` function. This function returns a `DependencyOverrideToken`, you'll want to hold onto this token for as long as you want the mock dependency to be effective. When the token is deallocated, the dependency reverts back to its original condition. + +Here's an example: + +```swift +// Real network service +extension Application { + var networkService: Dependency { + dependency(initial: NetworkService()) + } +} + +// Mock network service +class MockNetworkService: NetworkServiceType { + // Your mock implementation +} + +func testNetworkService() { + // Keep hold of the `DependencyOverride` for the duration of your test. + let networkOverride = Application.override(\.networkService, with: MockNetworkService()) + + let mockNetworkService = Application.dependency(\.networkService) + + // Once done, you can allow the `tokDependencyOverrideen` to be deallocated + // or call `override.cancel()` to revert back to the original service. +} +``` + ## License AppState is released under the MIT License. See [LICENSE](https://github.com/0xLeif/AppState/blob/main/LICENSE) for more information. diff --git a/Sources/AppState/AppDependency.swift b/Sources/AppState/AppDependency.swift new file mode 100644 index 0000000..11167fb --- /dev/null +++ b/Sources/AppState/AppDependency.swift @@ -0,0 +1,24 @@ +import Combine +import SwiftUI + +/// The `@AppDependency` property wrapper is a feature provided by AppState, intended to simplify dependency handling throughout your application. It makes it easy to access, share, and manage dependencies in a neat and Swift idiomatic way. +@propertyWrapper public struct AppDependency { + /// Path for accessing `Dependency` from Application. + private let keyPath: KeyPath> + + /// Represents the current value of the `Dependency`. + public var wrappedValue: Value { + Application.dependency(keyPath) + } + + /** + Initializes the AppDependency with a `keyPath` for accessing `Dependency` in Application. + + - Parameter keyPath: The `KeyPath` for accessing `Dependency` in Application. + */ + public init( + _ keyPath: KeyPath> + ) { + self.keyPath = keyPath + } +} diff --git a/Sources/AppState/AppState.swift b/Sources/AppState/AppState.swift index baf36cc..0f48114 100644 --- a/Sources/AppState/AppState.swift +++ b/Sources/AppState/AppState.swift @@ -15,11 +15,14 @@ import SwiftUI app.value(keyPath: keyPath).value } nonmutating set { - let key = app.value(keyPath: keyPath).scope.key + let scope = app.value(keyPath: keyPath).scope app.cache.set( - value: newValue, - forKey: key + value: Application.State( + initial: newValue, + scope: scope + ), + forKey: scope.key ) } } diff --git a/Sources/AppState/Application+Dependency.swift b/Sources/AppState/Application+Dependency.swift new file mode 100644 index 0000000..50c0cc3 --- /dev/null +++ b/Sources/AppState/Application+Dependency.swift @@ -0,0 +1,55 @@ +extension Application { + /// `Dependency` struct encapsulates dependencies used throughout the app. + public struct Dependency: CustomStringConvertible { + /// The dependency value. + let value: Value + + /// The scope in which this state exists. + let scope: Scope + + /** + Initializes a new dependency within a given scope with an initial value. + + A Dependency allows for defining services or shared objects across the app. It is designed to be read-only and can only be changed by re-initializing it, ensuring thread-safety in your app. + + - Parameters: + - value: The initial value of the dependency. + - scope: The scope in which the dependency exists. + */ + init( + _ value: Value, + scope: Scope + ) { + self.value = value + self.scope = scope + } + + public var description: String { + "Dependency<\(Value.self)>(\(value)) (\(scope.key))" + } + } + + /// `DependencyOverride` provides a handle to revert a dependency override operation. + public class DependencyOverride { + /// Closure to be invoked when the dependency override is cancelled. This closure typically contains logic to revert the overrides on the dependency. + private let cancelOverride: () -> Void + + /** + Initializes a `DependencyOverride` instance. + + - Parameter cancelOverride: The closure to be invoked when the + dependency override is cancelled. + */ + init(cancelOverride: @escaping () -> Void) { + self.cancelOverride = cancelOverride + } + + /// Automatically cancels the override when `DependencyOverride` instance is deallocated. + deinit { cancel() } + + /// Cancels the override and resets the Dependency back to its value before the override. + public func cancel() { + cancelOverride() + } + } +} diff --git a/Sources/AppState/Application+State.swift b/Sources/AppState/Application+State.swift index 8add9eb..2eb0938 100644 --- a/Sources/AppState/Application+State.swift +++ b/Sources/AppState/Application+State.swift @@ -1,6 +1,6 @@ extension Application { /// `State` encapsulates the value within the application's scope and allows any changes to be propagated throughout the scoped area. - public struct State { + public struct State: CustomStringConvertible { /// A private backing storage for the value. private var _value: Value @@ -8,18 +8,21 @@ extension Application { public var value: Value { get { guard - let value = shared.cache.get( + let state = shared.cache.get( scope.key, - as: Value.self + as: State.self ) else { return _value } - return value + return state._value } set { _value = newValue shared.cache.set( - value: newValue, + value: Application.State( + initial: newValue, + scope: scope + ), forKey: scope.key ) } @@ -42,5 +45,9 @@ extension Application { self._value = value self.scope = scope } + + public var description: String { + "State<\(Value.self)>(\(value)) (\(scope.key))" + } } } diff --git a/Sources/AppState/Application+internal.swift b/Sources/AppState/Application+internal.swift deleted file mode 100644 index cef4316..0000000 --- a/Sources/AppState/Application+internal.swift +++ /dev/null @@ -1,18 +0,0 @@ -extension Application { - /// Generates a specific identifier string for given code context - /// - /// - Parameters: - /// - fileID: The file identifier of the code, generally provided by `#fileID` directive. - /// - function: The function name of the code, generally provided by `#function` directive. - /// - line: The line number of the code, generally provided by `#line` directive. - /// - column: The column number of the code, generally provided by `#column` directive. - /// - Returns: A string representing the specific location and context of the code. The format is `[@|]`. - static func codeID( - fileID: StaticString, - function: StaticString, - line: Int, - column: Int - ) -> String { - "\(fileID)[\(function)@\(line)|\(column)]" - } -} diff --git a/Sources/AppState/Application+public.swift b/Sources/AppState/Application+public.swift index f5995f9..aeeb224 100644 --- a/Sources/AppState/Application+public.swift +++ b/Sources/AppState/Application+public.swift @@ -2,20 +2,65 @@ public extension Application { /// Provides a description of the current application state static var description: String { let state = shared.cache.allValues - .sorted { lhsKeyValue, rhsKeyValue in - lhsKeyValue.key < rhsKeyValue.key - } .map { key, value in - "- \(value) (\(key))" + "\t- \(value)" } + .sorted(by: <) .joined(separator: "\n") return """ - App: + { \(state) + } """ } + /** + Retrieves a state from Application instance using the provided keypath. + + - Parameter keyPath: KeyPath of the state value to be fetched + - Returns: The requested state of type `Value`. + */ + static func dependency(_ keyPath: KeyPath>) -> Value { + shared.value(keyPath: keyPath).value + } + + /** + Overrides the specified `Dependency` with the given value. This is particularly useful for SwiftUI Previews and Unit Tests. + - Parameters: + - keyPath: Key path of the dependency to be overridden. + - value: The new value to override the current dependency. + + - Returns: A `DependencyOverride` object. You should retain this token for as long as you want your override to be effective. Once the token is deallocated or the `cancel()` method is called on it, the original dependency is restored. + + Note: If the `DependencyOverride` object gets deallocated without calling `cancel()`, it will automatically cancel the override, restoring the original dependency. + */ + static func `override`( + _ keyPath: KeyPath>, + with value: Value + ) -> DependencyOverride { + let dependency = shared.value(keyPath: keyPath) + + shared.cache.set( + value: Dependency(value, scope: dependency.scope), + forKey: dependency.scope.key + ) + + return DependencyOverride { + shared.cache.set(value: dependency, forKey: dependency.scope.key) + } + } + + /** + Retrieves a state from Application instance using the provided keypath. + + - Parameter keyPath: KeyPath of the state value to be fetched + - Returns: The requested state of type `Value`. + */ + static func state(_ keyPath: KeyPath>) -> State { + shared.value(keyPath: keyPath) + } + /** Retrieves a dependency for the provided `id`. If dependency is not present, it is created once using the provided closure. @@ -23,36 +68,43 @@ public extension Application { - object: The closure returning the dependency. - feature: The name of the feature to which the dependency belongs, default is "App". - id: The specific identifier for this dependency. - - Returns: The requested dependency of type `Value`. + - Returns: The requested dependency of type `Dependency`. */ - static func dependency( + func dependency( _ object: @autoclosure () -> Value, feature: String = "App", id: String - ) -> Value { + ) -> Dependency { let scope = Scope(name: feature, id: id) let key = scope.key - guard let value = shared.cache.get(key, as: Value.self) else { + guard let dependency = cache.get(key, as: Dependency.self) else { let value = object() - shared.cache.set(value: value, forKey: key) - return value + let dependency = Dependency( + value, + scope: scope + ) + + cache.set(value: dependency, forKey: key) + + return dependency } - return value + return dependency } + // Overloaded version of `dependency(_:feature:id:)` function where id is generated from the code context. - static func dependency( + func dependency( _ object: @autoclosure () -> Value, _ fileID: StaticString = #fileID, _ function: StaticString = #function, _ line: Int = #line, _ column: Int = #column - ) -> Value { + ) -> Dependency { dependency( object(), - id: codeID( + id: Application.codeID( fileID: fileID, function: function, line: line, @@ -61,16 +113,6 @@ public extension Application { ) } - /** - Retrieves a state from Application instance using the provided keypath. - - - Parameter keyPath: KeyPath of the state value to be fetched - - Returns: The requested state of type `Value`. - */ - static func state(_ keyPath: KeyPath) -> Value { - shared.value(keyPath: keyPath) - } - /** Retrieves a state for the provided `id`. If the state is not present, it initializes a new state with the `initial` value. @@ -114,5 +156,4 @@ public extension Application { ) ) } - } diff --git a/Sources/AppState/Application.swift b/Sources/AppState/Application.swift index b6424a7..60b42a1 100644 --- a/Sources/AppState/Application.swift +++ b/Sources/AppState/Application.swift @@ -7,8 +7,27 @@ public class Application: ObservableObject { /// Singleton shared instance of `Application` static let shared: Application = Application() + /** + Generates a specific identifier string for given code context + + - Parameters: + - fileID: The file identifier of the code, generally provided by `#fileID` directive. + - function: The function name of the code, generally provided by `#function` directive. + - line: The line number of the code, generally provided by `#line` directive. + - column: The column number of the code, generally provided by `#column` directive. + - Returns: A string representing the specific location and context of the code. The format is `[@|]`. + */ + static func codeID( + fileID: StaticString, + function: StaticString, + line: Int, + column: Int + ) -> String { + "\(fileID)[\(function)@\(line)|\(column)]" + } + /// Logger specifically for AppState - public static let logger: Logger = Logger(subsystem: "Application", category: "AppState") + public static let logger: Logger = Logger(subsystem: "AppState", category: "Application") private let lock: NSLock private var bag: Set @@ -16,9 +35,7 @@ public class Application: ObservableObject { /// Cache to store values let cache: Cache - deinit { - bag.removeAll() - } + deinit { bag.removeAll() } private init() { lock = NSLock() diff --git a/Tests/AppStateTests/AppStateTests.swift b/Tests/AppStateTests/AppStateTests.swift index 29baf79..e935a8a 100644 --- a/Tests/AppStateTests/AppStateTests.swift +++ b/Tests/AppStateTests/AppStateTests.swift @@ -1,35 +1,76 @@ +import SwiftUI import XCTest @testable import AppState -protocol Networking { } +protocol Networking { + func fetch() +} + +struct NetworkService: Networking { + func fetch() { + fatalError() + } +} + +struct MockNetworking: Networking { + func fetch() { -struct NetworkService: Networking { } -struct MockNetworking: Networking { } + } +} extension Application { - // DI - Live - static var networking: Networking { + var networking: Dependency { dependency(NetworkService()) } - // SwiftUI State / App State - var isLoading: Application.State { + var isLoading: State { state(initial: false) } - // SwiftUI State / App State var username: State { state(initial: "Leif") } - // SwiftUI State / App State var colors: State<[String: CGColor]> { state(initial: ["primary": CGColor(red: 1, green: 0, blue: 1, alpha: 1)]) } } +class ExampleViewModel: ObservableObject { + @AppState(\.username) var username + + func testPropertyWrapper() { + username = "Hello, ExampleView" + } +} + +struct ExampleView: View { + @AppDependency(\.networking) var networking + @AppState(\.username) var username + @AppState(\.isLoading) var isLoading + + var body: some View { fatalError() } + + func testPropertyWrappers() { + username = "Hello, ExampleView" + networking.fetch() + _ = Toggle(isOn: $isLoading) { + Text("Is Loading") + } + } +} + final class AppStateTests: XCTestCase { - func testExample() throws { + override class func tearDown() { + Application.logger.debug("\(Application.description)") + } + + override func tearDown() { + var username: Application.State = Application.state(\.username) + username.value = "Leif" + } + + func testState() { var appState: Application.State = Application.state(\.username) XCTAssertEqual(appState.value, "Leif") @@ -39,4 +80,42 @@ final class AppStateTests: XCTestCase { XCTAssertEqual(appState.value, "0xL") XCTAssertEqual(Application.state(\.username).value, "0xL") } + + func testDependency() { + let networkingOverride = Application.override(\.networking, with: MockNetworking()) + + let mockNetworking = Application.dependency(\.networking) + + XCTAssertNotNil(mockNetworking as? MockNetworking) + + mockNetworking.fetch() + + networkingOverride.cancel() + + let networkingService = Application.dependency(\.networking) + + XCTAssertNotNil(networkingService as? NetworkService) + } + + func testPropertyWrappers() { + let exampleView = ExampleView() + + let networkingOverride = Application.override(\.networking, with: MockNetworking()) + defer { networkingOverride.cancel() } + + XCTAssertNotNil(exampleView.networking as? MockNetworking) + XCTAssertEqual(exampleView.username, "Leif") + + exampleView.testPropertyWrappers() + + XCTAssertEqual(exampleView.username, "Hello, ExampleView") + + let viewModel = ExampleViewModel() + + XCTAssertEqual(viewModel.username, "Hello, ExampleView") + + viewModel.username = "Hello, ViewModel" + + XCTAssertEqual(viewModel.username, "Hello, ViewModel") + } }