diff --git a/Package.swift b/Package.swift index a1f5cac..95abf84 100644 --- a/Package.swift +++ b/Package.swift @@ -24,9 +24,9 @@ let package = Package( from: "1.1.1" ), .package( - url: "https://github.com/0xOpenBytes/t", - from: "0.2.0" - ) + url: "https://github.com/0xLeif/swift-custom-dump", + from: "0.4.1" + ) ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -35,7 +35,7 @@ let package = Package( name: "CacheStore", dependencies: [ "c", - "t" + .product(name: "CustomDump", package: "swift-custom-dump") ] ), .testTarget( diff --git a/README.md b/README.md index a562029..2e7cb1a 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,205 @@ # CacheStore -*SwiftUI Observable Cache* +*SwiftUI State Management* ## What is `CacheStore`? -`CacheStore` is a SwiftUI framework to help with state. Define keyed values that you can share locally or globally in your projects. `CacheStore` uses [`c`](https://github.com/0xOpenBytes/c), which a simple composition framework. [`c`](https://github.com/0xOpenBytes/c) has the ability to create transformations that are either unidirectional or bidirectional. There is also a cache that values can be set and resolved, which is used in `CacheStore`. +`CacheStore` is a SwiftUI tate management framework that uses a dictionary as the state. Scoping creates a single source of truth for the parent state. `CacheStore` uses [`c`](https://github.com/0xOpenBytes/c), which a simple composition framework. [`c`](https://github.com/0xOpenBytes/c) has the ability to create transformations that are either unidirectional or bidirectional. + +### CacheStore Basic Idea + +A `[AnyHashable: Any]` can be used as the single source of truth for an app. Scoping can be done by limiting the known keys. Modification to the scoped value or parent value should be reflected throughout the app. ## Objects - `CacheStore`: An object that needs defined Keys to get and set values. - `Store`: An object that needs defined Keys, Actions, and Dependencies. (Preferred) + - `TestStore`: A testable wrapper around `Store` to make it easy to write XCTestCases -## Store +### Store -A `Store` is an object that you send actions to and read state from. Stores use a private `CacheStore` to manage state behind the scenes. All state changes must be defined in a `StoreActionHandler` where the state gets modified depending on an action. +A `Store` is an object that you send actions to and read state from. Stores use a `CacheStore` to manage state behind the scenes. All state changes must be defined in a `StoreActionHandler` where the state gets modified depending on an action. -## Basic Store Example +### TestStore -Here is a basic `Store` example where this is a Boolean variable called `isOn`. The only way you can modify that variable is be using defined actions for the given store. In this example there is only one action, toggle. +When creating tests you should use `TestStore` to send and receive actions while making expectations. If any expectation is false it will be reported in a `XCTestCase`. If there are any effects left at the end of the test, there will be a failure as all effects must be completed and all resulting actions handled. `TestStore` uses a FIFO (first in first out) queue to manage the effects. -```swift -enum StoreKey { - case isOn -} +## Basic Usage -enum Action { - case toggle -} - -let actionHandler = StoreActionHandler { (store: inout CacheStore, action: Action, _: Void) in - switch action { - case .toggle: - store.update(.isOn, as: Bool.self, updater: { $0?.toggle() }) - } -} +
+ Store Example -let store = Store( - initialValues: [.isOn: false], - actionHandler: actionHandler, - dependency: () -) +```swift +import CacheStore +import SwiftUI -try t.assert(store.get(.isOn), isEqualTo: false) +struct Post: Codable, Hashable { + var id: Int + var userId: Int + var title: String + var body: String +} -store.handle(action: .toggle) +enum StoreKey { + case url + case posts + case isLoading +} -try t.assert(store.get(.isOn), isEqualTo: true) -``` +enum Action { + case fetchPosts + case postsResponse(Result<[Post], Error>) +} -## Basic CacheStore Example +extension String: Error { } -Here is a simple application that has two files, an `App` file and `ContentView` file. The `App` contains the `StateObject` `CacheStore`. It then adds the `CacheStore` to the global cache using [`c`](https://github.com/0xOpenBytes/c). `ContentView` can then resolve the cache as an `ObservableObject` which can read or write to the cache. The cache can be injected into the `ContentView` directly, see `ContentView_Previews`, or indirectly, see `ContentView`. +struct Dependency { + var fetchPosts: (URL) async -> Result<[Post], Error> +} -```swift -import c -import CacheStore -import SwiftUI +extension Dependency { + static var mock: Dependency { + Dependency( + fetchPosts: { _ in + sleep(1) + return .success([Post(id: 1, userId: 1, title: "Mock", body: "Post")]) + } + ) + } + + static var live: Dependency { + Dependency { url in + do { + let (data, _) = try await URLSession.shared.data(from: url) + return .success(try JSONDecoder().decode([Post].self, from: data)) + } catch { + return .failure(error) + } + } + } +} -enum CacheKey: Hashable { - case someValue +let actionHandler = StoreActionHandler { cacheStore, action, dependency in + switch action { + case .fetchPosts: + struct FetchPostsID: Hashable { } + + guard let url = cacheStore.get(.url, as: URL.self) else { + return ActionEffect(.postsResponse(.failure("Key `.url` was not a URL"))) + } + + cacheStore.set(value: true, forKey: .isLoading) + + return ActionEffect(id: FetchPostsID()) { + .postsResponse(await dependency.fetchPosts(url)) + } + + case let .postsResponse(.success(posts)): + cacheStore.set(value: false, forKey: .isLoading) + cacheStore.set(value: posts, forKey: .posts) + + case let .postsResponse(.failure(error)): + cacheStore.set(value: false, forKey: .isLoading) + } + + return .none } -@main -struct DemoApp: App { - @StateObject var cacheStore = CacheStore( - initialValues: [.someValue: "🥳"] +struct ContentView: View { + @ObservedObject var store: Store = .init( + initialValues: [ + .url: URL(string: "https://jsonplaceholder.typicode.com/posts") as Any + ], + actionHandler: actionHandler, + dependency: .live ) + .debug - var body: some Scene { - c.set(value: cacheStore, forKey: "CacheStore") - - return WindowGroup { - VStack { - Text("@StateObject value: \(cacheStore.resolve(.someValue) as String)") - ContentView() + private var isLoading: Bool { + store.get(.isLoading, as: Bool.self) ?? true + } + + var body: some View { + if + !isLoading, + let posts = store.get(.posts, as: [Post].self) + { + List(posts, id: \.self) { post in + Text(post.title) } + } else { + ProgressView() + .onAppear { + store.handle(action: .fetchPosts) + } } } } - ``` -### ContentView +
+ +
+ Testing ```swift -import c import CacheStore -import SwiftUI +import XCTest +@testable import CacheStoreDemo -struct ContentView: View { - @ObservedObject var cacheStore: CacheStore = c.resolve("CacheStore") - - var stringValue: String { - cacheStore.resolve(.someValue) +class CacheStoreDemoTests: XCTestCase { + override func setUp() { + TestStoreFailure.handler = XCTFail } - var body: some View { - VStack { - Text("Current Value: \(stringValue)") - Button("Update Value") { - cacheStore.set(value: ":D", forKey: .someValue) - } + func testExample_success() throws { + let store = TestStore( + initialValues: [ + .url: URL(string: "https://jsonplaceholder.typicode.com/posts") as Any + ], + actionHandler: actionHandler, + dependency: .mock + ) + + store.send(.fetchPosts) { cacheStore in + cacheStore.set(value: true, forKey: .isLoading) + } + store.send(.fetchPosts) { cacheStore in + cacheStore.set(value: true, forKey: .isLoading) + } + + let expectedPosts: [Post] = [Post(id: 1, userId: 1, title: "Mock", body: "Post")] + + store.receive(.postsResponse(.success(expectedPosts))) { cacheStore in + cacheStore.set(value: false, forKey: .isLoading) + cacheStore.set(value: expectedPosts, forKey: .posts) } - .padding() } -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView( - cacheStore: CacheStore( - initialValues: [.someValue: "Preview Cache Value"] - ) + + func testExample_failure() throws { + let store = TestStore( + initialValues: [ + : + ], + actionHandler: actionHandler, + dependency: .mock ) + + store.send(.fetchPosts, expecting: { _ in }) + + store.receive(.postsResponse(.failure("Key `.url` was not a URL"))) { cacheStore in + cacheStore.set(value: false, forKey: .isLoading) + } } } - ``` + +
+ +*** + +## Acknowledgement of Dependencies +- [pointfreeco/swift-custom-dump](https://github.com/pointfreeco/swift-custom-dump) + + +## Inspiration +- [pointfreeco/swift-composable-architecture](https://github.com/pointfreeco/swift-composable-architecture) diff --git a/Sources/CacheStore/Actions/ActionHandling.swift b/Sources/CacheStore/Actions/ActionHandling.swift index 0397a63..06976e1 100644 --- a/Sources/CacheStore/Actions/ActionHandling.swift +++ b/Sources/CacheStore/Actions/ActionHandling.swift @@ -1,24 +1,64 @@ +import Foundation + +/// ActionHandlers can handle any value of `Action`. Normally `Action` is an enum. public protocol ActionHandling { + /// `Action` Type that is passed into the handle function associatedtype Action /// Handle the given `Action` func handle(action: Action) } +/// Async effect produced from an `Action` that can optionally produce another `Action` +public struct ActionEffect { + /// ID used to identify and cancel the effect + public let id: AnyHashable + /// Async closure that optionally produces an `Action` + public let effect: () async -> Action? + + /// No effect + public static var none: Self { + ActionEffect { nil } + } + + /// init for `ActionEffect` taking an async effect + public init( + id: AnyHashable = UUID(), + effect: @escaping () async -> Action? + ) { + self.id = id + self.effect = effect + } + + /// init for `ActionEffect` taking an immediate action + public init(_ action: Action) { + self.id = UUID() + self.effect = { action } + } +} + +/// Handles an `Action` that modifies a `CacheStore` using a `Dependency` public struct StoreActionHandler { - private let handler: (inout CacheStore, Action, Dependency) -> Void + private let handler: (inout CacheStore, Action, Dependency) -> ActionEffect? + /// init for `StoreActionHandler` public init( - _ handler: @escaping (inout CacheStore, Action, Dependency) -> Void + _ handler: @escaping (inout CacheStore, Action, Dependency) -> ActionEffect? ) { self.handler = handler } + /// `StoreActionHandler` that doesn't handle any `Action` public static var none: StoreActionHandler { - StoreActionHandler { _, _, _ in } + StoreActionHandler { _, _, _ in nil } } - public func handle(store: inout CacheStore, action: Action, dependency: Dependency) { + /// Mutate `CacheStore` for `Action` with `Dependency` + public func handle( + store: inout CacheStore, + action: Action, + dependency: Dependency + ) -> ActionEffect? { handler(&store, action, dependency) } } diff --git a/Sources/CacheStore/Stores/CacheStore.swift b/Sources/CacheStore/Stores/CacheStore.swift index 0005f68..17a3bd2 100644 --- a/Sources/CacheStore/Stores/CacheStore.swift +++ b/Sources/CacheStore/Stores/CacheStore.swift @@ -4,18 +4,37 @@ import SwiftUI // MARK: - +/// An `ObservableObject` that has a `cache` which is the source of truth for this object public class CacheStore: ObservableObject, Cacheable { + /// `Error` that reports the missing keys for the `CacheStore` + public struct MissingRequiredKeysError: LocalizedError { + /// Required keys + public let keys: Set + + /// init for `MissingRequiredKeysError` + public init(keys: Set) { + self.keys = keys + } + + /// Error description for `LocalizedError` + public var errorDescription: String? { + "Missing Required Keys: \(keys.map { "\($0)" }.joined(separator: ", "))" + } + } + private var lock: NSLock @Published var cache: [Key: Any] /// The values in the `cache` of type `Any` public var valuesInCache: [Key: Any] { cache } + /// init for `CacheStore` required public init(initialValues: [Key: Any]) { lock = NSLock() cache = initialValues } + /// Get the `Value` for the `Key` if it exists public func get(_ key: Key, as: Value.Type = Value.self) -> Value? { defer { lock.unlock() } lock.lock() @@ -42,8 +61,10 @@ public class CacheStore: ObservableObject, Cacheable { return value } + /// Resolve the `Value` for the `Key` by force casting `get` public func resolve(_ key: Key, as: Value.Type = Value.self) -> Value { get(key)! } + /// Set the `Value` for the `Key` public func set(value: Value, forKey key: Key) { guard Thread.isMainThread else { DispatchQueue.main.async { @@ -57,31 +78,41 @@ public class CacheStore: ObservableObject, Cacheable { lock.unlock() } + /// Require a set of keys otherwise throw an error @discardableResult public func require(keys: Set) throws -> Self { let missingKeys = keys .filter { contains($0) == false } guard missingKeys.isEmpty else { - throw c.MissingRequiredKeysError(keys: missingKeys) + throw MissingRequiredKeysError(keys: missingKeys) } return self } + /// Require a key otherwise throw an error @discardableResult public func require(_ key: Key) throws -> Self { try require(keys: [key]) } + /// Check to see if the cache contains a key public func contains(_ key: Key) -> Bool { - cache[key] != nil + lock.lock() + defer { lock.unlock() } + + return cache[key] != nil } + /// Get the values in the cache that are of the type `Value` public func valuesInCache( ofType: Value.Type = Value.self ) -> [Key: Value] { - cache.compactMapValues { $0 as? Value } + lock.lock() + defer { lock.unlock() } + + return cache.compactMapValues { $0 as? Value } } /// Update the value of a key by mutating the value passed into the `updater` parameter @@ -98,6 +129,7 @@ public class CacheStore: ObservableObject, Cacheable { } } + /// Remove the value for the key public func remove(_ key: Key) { guard Thread.isMainThread else { DispatchQueue.main.async { @@ -113,7 +145,13 @@ public class CacheStore: ObservableObject, Cacheable { // MARK: - Copying - public func copy() -> CacheStore { CacheStore(initialValues: cache) } + /// Create a copy of the current `CacheStore` cache + public func copy() -> CacheStore { + lock.lock() + defer { lock.unlock() } + + return CacheStore(initialValues: cache) + } } // MARK: - @@ -121,7 +159,10 @@ public class CacheStore: ObservableObject, Cacheable { public extension CacheStore { /// A publisher for the private `cache` that is mapped to a CacheStore var publisher: AnyPublisher { - $cache.map(CacheStore.init).eraseToAnyPublisher() + lock.lock() + defer { lock.unlock() } + + return $cache.map(CacheStore.init).eraseToAnyPublisher() } /// Creates a `ScopedCacheStore` with the given key transformation and default cache @@ -134,11 +175,13 @@ public extension CacheStore { scopedCacheStore.cache = defaultCache scopedCacheStore.parentCacheStore = self + lock.lock() cache.forEach { key, value in guard let scopedKey = keyTransformation.from(key) else { return } scopedCacheStore.cache[scopedKey] = value } + lock.unlock() return scopedCacheStore } @@ -165,3 +208,40 @@ public extension CacheStore { ) } } + +extension CacheStore { + func isCacheEqual(to updatedStore: CacheStore) -> Bool { + #if DEBUG + let cacheStoreCount = cache.count + #else + lock.lock() + let cacheStoreCount = cache.count + lock.unlock() + #endif + + guard cacheStoreCount == updatedStore.cache.count else { return false } + + return updatedStore.cache.map { key, value in + isValueEqual(toUpdatedValue: value, forKey: key) + } + .reduce(into: true) { result, condition in + guard condition else { + result = false + return + } + } + } + + func isValueEqual(toUpdatedValue updatedValue: Value, forKey key: Key) -> Bool { + #if !DEBUG + lock.lock() + defer { lock.unlock() } + #endif + + guard let storeValue: Value = get(key) else { + return false + } + + return "\(updatedValue)" == "\(storeValue)" + } +} diff --git a/Sources/CacheStore/Stores/Store.swift b/Sources/CacheStore/Stores/Store.swift index 31ea929..7e0f57c 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -1,27 +1,42 @@ import c import Combine +import CustomDump import SwiftUI // MARK: - +/// An `ObservableObject` that uses actions to modify the state which is a `CacheStore` public class Store: ObservableObject, ActionHandling { private var lock: NSLock private var isDebugging: Bool - private var store: CacheStore - private var actionHandler: StoreActionHandler - private let dependency: Dependency + private var effects: [AnyHashable: Task<(), Never>] + + var cacheStore: CacheStore + var actionHandler: StoreActionHandler + let dependency: Dependency /// The values in the `cache` of type `Any` - public var valuesInCache: [Key: Any] { store.valuesInCache } + public var valuesInCache: [Key: Any] { + lock.lock() + defer { lock.unlock() } + + return cacheStore.valuesInCache + } /// A publisher for the private `cache` that is mapped to a CacheStore public var publisher: AnyPublisher, Never> { - store.publisher + lock.lock() + defer { lock.unlock() } + + return cacheStore.publisher } /// An identifier of the Store and CacheStore var debugIdentifier: String { - let cacheStoreAddress = Unmanaged.passUnretained(store).toOpaque().debugDescription + lock.lock() + defer { lock.unlock() } + + let cacheStoreAddress = Unmanaged.passUnretained(cacheStore).toOpaque().debugDescription var storeDescription: String = "\(self)".replacingOccurrences(of: "CacheStore.", with: "") guard let index = storeDescription.firstIndex(of: "<") else { @@ -34,6 +49,7 @@ public class Store: ObservableObject, ActionH return "(Store: \(storeDescription), CacheStore: \(cacheStoreAddress))" } + /// init for `Store` public required init( initialValues: [Key: Any], actionHandler: StoreActionHandler, @@ -41,25 +57,35 @@ public class Store: ObservableObject, ActionH ) { lock = NSLock() isDebugging = false - store = CacheStore(initialValues: initialValues) + effects = [:] + cacheStore = CacheStore(initialValues: initialValues) self.actionHandler = actionHandler self.dependency = dependency } /// Get the value in the `cache` using the `key`. This returns an optional value. If the value is `nil`, that means either the value doesn't exist or the value is not able to be casted as `Value`. public func get(_ key: Key, as: Value.Type = Value.self) -> Value? { - store.get(key) + lock.lock() + defer { lock.unlock() } + + return cacheStore.get(key) } /// Resolve the value in the `cache` using the `key`. This function uses `get` and force casts the value. This should only be used when you know the value is always in the `cache`. public func resolve(_ key: Key, as: Value.Type = Value.self) -> Value { - store.resolve(key) + lock.lock() + defer { lock.unlock() } + + return cacheStore.resolve(key) } /// Checks to make sure the cache has the required keys, otherwise it will throw an error @discardableResult public func require(keys: Set) throws -> Self { - try store.require(keys: keys) + lock.lock() + defer { lock.unlock() } + + try cacheStore.require(keys: keys) return self } @@ -67,83 +93,48 @@ public class Store: ObservableObject, ActionH /// Checks to make sure the cache has the required key, otherwise it will throw an error @discardableResult public func require(_ key: Key) throws -> Self { - try store.require(keys: [key]) + lock.lock() + defer { lock.unlock() } + + try cacheStore.require(keys: [key]) return self } + /// Sends the action to be handled by the `Store` public func handle(action: Action) { guard Thread.isMainThread else { - DispatchQueue.main.async { - self.handle(action: action) + DispatchQueue.main.async { [weak self] in + self?.handle(action: action) } return } - lock.lock() - - if isDebugging { - print("[\(formattedDate)] 🟡 New Action: \(action) \(debugIdentifier)") - } - - var storeCopy = store.copy() - actionHandler.handle(store: &storeCopy, action: action, dependency: dependency) - - if isDebugging { - print( - """ - [\(formattedDate)] 📣 Handled Action: \(action) \(debugIdentifier) - --------------- State Output ------------ - """ - ) - } - - if isCacheEqual(to: storeCopy) { - if isDebugging { - print("\t🙅 No State Change") - } - } else { - if isDebugging { - print( - """ - \t⚠️ State Changed - \t\t--- Was --- - \t\t\(debuggingStateDelta(forUpdatedStore: store)) - \t\t----------- - \t\t*********** - \t\t--- Now --- - \t\t\(debuggingStateDelta(forUpdatedStore: storeCopy)) - \t\t----------- - """ - ) - } - - objectWillChange.send() - store.cache = storeCopy.cache - } - - if isDebugging { - print( - """ - --------------- State End --------------- - [\(formattedDate)] 🏁 End Action: \(action) \(debugIdentifier) - """ - ) - } - - lock.unlock() + _ = send(action) + } + + /// Cancel an effect with the ID + public func cancel(id: AnyHashable) { + effects[id]?.cancel() + effects[id] = nil } /// Checks if the given `key` has a value or not public func contains(_ key: Key) -> Bool { - store.contains(key) + lock.lock() + defer { lock.unlock() } + + return cacheStore.contains(key) } /// Returns a Dictionary containing only the key value pairs where the value is the same type as the generic type `Value` public func valuesInCache( ofType type: Value.Type = Value.self ) -> [Key: Value] { - store.valuesInCache(ofType: type) + lock.lock() + defer { lock.unlock() } + + return cacheStore.valuesInCache(ofType: type) } /// Creates a `ScopedStore` @@ -160,25 +151,30 @@ public class Store: ObservableObject, ActionH dependency: dependencyTransformation(dependency) ) - let scopedCacheStore = store.scope( + lock.lock() + defer { lock.unlock() } + + let scopedCacheStore = cacheStore.scope( keyTransformation: keyTransformation, defaultCache: defaultCache ) - scopedStore.store = scopedCacheStore + scopedStore.cacheStore = scopedCacheStore scopedStore.parentStore = self scopedStore.actionHandler = StoreActionHandler { (store: inout CacheStore, action: ScopedAction, dependency: ScopedDependency) in - actionHandler.handle(store: &store, action: action, dependency: dependency) + let effect = actionHandler.handle(store: &store, action: action, dependency: dependency) if let parentAction = actionTransformation(action) { scopedStore.parentStore?.handle(action: parentAction) } + + return effect } - store.cache.forEach { key, value in + cacheStore.cache.forEach { key, value in guard let scopedKey = keyTransformation.from(key) else { return } - scopedStore.store.cache[scopedKey] = value + scopedStore.cacheStore.cache[scopedKey] = value } return scopedStore @@ -259,49 +255,110 @@ public extension Store { // MARK: - Debugging extension Store { + /// Modifies and returns the `Store` with debugging mode on public var debug: Self { + lock.lock() isDebugging = true + lock.unlock() return self } - private var formattedDate: String { - let now = Date() - let formatter = DateFormatter() + func send(_ action: Action) -> ActionEffect? { + if isDebugging { + print("[\(formattedDate)] 🟡 New Action: \(customDump(action)) \(debugIdentifier)") + } - formatter.dateStyle = .short - formatter.timeStyle = .medium + var cacheStoreCopy = cacheStore.copy() - return formatter.string(from: now) - } - - private func isCacheEqual(to updatedStore: CacheStore) -> Bool { - guard store.cache.count == updatedStore.cache.count else { return false } + let actionEffect = actionHandler.handle(store: &cacheStoreCopy, action: action, dependency: dependency) - return updatedStore.cache.map { key, value in - isValueEqual(toUpdatedValue: value, forKey: key) + if let actionEffect = actionEffect { + if let runningEffect = effects[actionEffect.id] { + runningEffect.cancel() + } + + effects[actionEffect.id] = Task { + guard let nextAction = await actionEffect.effect() else { return } + + handle(action: nextAction) + } } - .reduce(into: true) { result, condition in - guard condition else { - result = false - return + + if isDebugging { + print( + """ + [\(formattedDate)] 📣 Handled Action: \(customDump(action)) \(debugIdentifier) + --------------- State Output ------------ + """ + ) + } + + let areCacheEqual = cacheStore.isCacheEqual(to: cacheStoreCopy) + + if areCacheEqual { + if isDebugging { + print("\t🙅 No State Change") } + } else { + if isDebugging { + if let diff = diff(cacheStore.cache, cacheStoreCopy.cache) { + print( + """ + \t⚠️ State Changed + \(diff) + """ + ) + } else { + print( + """ + \t⚠️ State Changed + \t\t--- Was --- + \t\t\(debuggingStateDelta(forUpdatedStore: cacheStore)) + \t\t----------- + \t\t*********** + \t\t--- Now --- + \t\t\(debuggingStateDelta(forUpdatedStore: cacheStoreCopy)) + \t\t----------- + """ + ) + } + } + + objectWillChange.send() + cacheStore.cache = cacheStoreCopy.cache } + + if isDebugging { + print( + """ + --------------- State End --------------- + [\(formattedDate)] 🏁 End Action: \(customDump(action)) \(debugIdentifier) + """ + ) + } + + return actionEffect } - private func isValueEqual(toUpdatedValue updatedValue: Value, forKey key: Key) -> Bool { - guard let storeValue: Value = store.get(key) else { - return false - } + private var formattedDate: String { + let now = Date() + let formatter = DateFormatter() - return "\(updatedValue)" == "\(storeValue)" + formatter.dateStyle = .short + formatter.timeStyle = .medium + + return formatter.string(from: now) } private func debuggingStateDelta(forUpdatedStore updatedStore: CacheStore) -> String { + lock.lock() + defer { lock.unlock() } + var updatedStateChanges: [String] = [] for (key, value) in updatedStore.valuesInCache { - let isValueEqual: Bool = isValueEqual(toUpdatedValue: value, forKey: key) + let isValueEqual: Bool = cacheStore.isValueEqual(toUpdatedValue: value, forKey: key) let valueInfo: String = "\(type(of: value))" let valueOutput: String diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift new file mode 100644 index 0000000..625f626 --- /dev/null +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -0,0 +1,177 @@ +#if DEBUG +import CustomDump +import Foundation + +/// Facade typealias for XCTFail without importing XCTest +public typealias FailureHandler = (_ message: String, _ file: StaticString, _ line: UInt) -> Void + +/// Static object to provide the `FailureHandler` to any `TestStore` +public enum TestStoreFailure { + public static var handler: FailureHandler! +} + +/// Testable `Store` where you can send and receive actions while expecting the changes +public class TestStore { + private let initFile: StaticString + private let initLine: UInt + private var nextAction: Action? + private var store: Store + private var effects: [ActionEffect] + + deinit { + guard effects.isEmpty else { + let effectIDs = effects.map { "- \($0.id)" }.joined(separator: "\n") + TestStoreFailure.handler("❌ \(effects.count) effect(s) left to receive:\n\(effectIDs)", initFile, initLine) + return + } + } + + /// init for `TestStore` + /// + /// **Make sure to set `TestStoreFailure.handler`** + /// + /// ``` + /// override func setUp() { + /// TestStoreFailure.handler = XCTFail + /// } + /// ``` + public required init( + initialValues: [Key: Any], + actionHandler: StoreActionHandler, + dependency: Dependency, + file: StaticString = #filePath, + line: UInt = #line + ) { + assert( + TestStoreFailure.handler != nil, + """ + Set `TestStoreFailure.handler` + + override func setUp() { + TestStoreFailure.handler = XCTFail + } + + """ + ) + + store = Store(initialValues: initialValues, actionHandler: actionHandler, dependency: dependency).debug + effects = [] + initFile = file + initLine = line + } + + /// Send an action and provide an expectation for the changes from handling the action + public func send( + _ action: Action, + file: StaticString = #filePath, + line: UInt = #line, + expecting: (inout CacheStore) throws -> Void + ) { + var expectedCacheStore = store.cacheStore.copy() + + let actionEffect = store.send(action) + + do { + try expecting(&expectedCacheStore) + } catch { + TestStoreFailure.handler("❌ Expectation failed", file, line) + return + } + + guard expectedCacheStore.isCacheEqual(to: store.cacheStore) else { + TestStoreFailure.handler( + """ + ❌ Expectation failed + --- Expected --- + \(customDump(expectedCacheStore.valuesInCache)) + ---------------- + **************** + ---- Actual ---- + \(customDump(store.cacheStore.valuesInCache)) + ---------------- + """, + file, + line + ) + return + } + + + if let actionEffect = actionEffect { + let predicate: (ActionEffect) -> Bool = { $0.id == actionEffect.id } + if effects.contains(where: predicate) { + effects.removeAll(where: predicate) + } + + effects.append(actionEffect) + } + } + + /// Cancel a certain effect + public func cancel(id: AnyHashable) { + store.cancel(id: id) + } + + /// Receive an action from the effects **FIFO** queue + public func receive( + _ action: Action, + file: StaticString = #filePath, + line: UInt = #line, + expecting: @escaping (inout CacheStore) throws -> Void + ) { + guard let effect = effects.first else { + TestStoreFailure.handler("❌ No effects to receive", file, line) + return + } + + effects.removeFirst() + + let sema = DispatchSemaphore(value: 0) + + Task { + nextAction = await effect.effect() + sema.signal() + } + + sema.wait() + + guard let nextAction = nextAction else { + return + } + + guard "\(action)" == "\(nextAction)" else { + TestStoreFailure.handler("❌ Action (\(customDump(action))) does not equal NextAction (\(customDump(nextAction)))", file, line) + return + } + + send(nextAction, file: file, line: line, expecting: expecting) + } + + /// Checks to make sure the cache has the required keys, otherwise it will fail + func require( + keys: Set, + file: StaticString = #filePath, + line: UInt = #line + ) { + do { + try store.require(keys: keys) + } catch { + let requiredKeys = keys.map { "\($0)" }.joined(separator: ", ") + TestStoreFailure.handler("❌ Store does not have requied keys (\(requiredKeys))", file, line) + } + } + + /// Checks to make sure the cache has the required key, otherwise it will fail + func require( + _ key: Key, + file: StaticString = #filePath, + line: UInt = #line + ) { + do { + try store.require(key) + } catch { + TestStoreFailure.handler("❌ Store does not have requied key (\(key))", file, line) + } + } +} +#endif diff --git a/Tests/CacheStoreTests/StoreTests.swift b/Tests/CacheStoreTests/StoreTests.swift index 2355120..122c0cf 100644 --- a/Tests/CacheStoreTests/StoreTests.swift +++ b/Tests/CacheStoreTests/StoreTests.swift @@ -4,76 +4,92 @@ import XCTest @testable import CacheStore class StoreTests: XCTestCase { + override func setUp() { + TestStoreFailure.handler = XCTFail + } + func testExample() { - XCTAssert( - t.suite(named: "Testing Store") { - struct SomeStruct { - var value: String - var otherValue: String - } - - class SomeClass { - var value: String - var otherValue: String - - init(value: String, otherValue: String) { - self.value = value - self.otherValue = otherValue - } - } - - enum StoreKey { - case isOn - case someStruct - case someClass - } + struct SomeStruct { + var value: String + var otherValue: String + } + + class SomeClass { + var value: String + var otherValue: String + + init(value: String, otherValue: String) { + self.value = value + self.otherValue = otherValue + } + } + + enum StoreKey { + case isOn + case someStruct + case someClass + } + + enum Action { + case toggle, nothing, removeValue, updateStruct, updateClass + } + + let actionHandler = StoreActionHandler { (store: inout CacheStore, action: Action, _: Void) in + switch action { + case .toggle: + store.update(.isOn, as: Bool.self, updater: { $0?.toggle() }) - enum Action { - case toggle, nothing, removeValue, updateStruct, updateClass - } + print("TOGGLE HERE: \(Date())") - let actionHandler = StoreActionHandler { (store: inout CacheStore, action: Action, _: Void) in - switch action { - case .toggle: - store.update(.isOn, as: Bool.self, updater: { $0?.toggle() }) - case .nothing: - print("Do nothing") - case .removeValue: - store.remove(.someStruct) - case .updateStruct: - store.update(.someStruct, as: SomeStruct.self, updater: { $0?.otherValue = "something" }) - case .updateClass: - store.update(.someClass, as: SomeClass.self, updater: { $0?.otherValue = "something else" }) - } + return ActionEffect(id: "toggle->nothing") { + sleep(3) + return .nothing } - let store = try Store( - initialValues: [ - .isOn: false, - .someStruct: SomeStruct(value: "init-struct", otherValue: "other"), - .someClass: SomeClass(value: "init-class", otherValue: "other"), - ], - actionHandler: actionHandler, - dependency: () - ) - .require(.isOn) - .debug - - try t.assert(store.contains(.isOn), isEqualTo: true) - try t.assert(store.get(.isOn), isEqualTo: false) - - store.handle(action: .toggle) - store.handle(action: .nothing) - - try t.assert(store.get(.isOn), isEqualTo: true) + case .nothing: + print("NOTHING HERE: \(Date())") + print("Do nothing") - store.handle(action: .updateStruct) + case .removeValue: + store.remove(.someStruct) - // No state changes for Referance Types - store.handle(action: .updateClass) + case .updateStruct: + store.update(.someStruct, as: SomeStruct.self, updater: { $0?.otherValue = "something" }) - store.handle(action: .removeValue) + case .updateClass: + store.update(.someClass, as: SomeClass.self, updater: { $0?.otherValue = "something else" }) } + + return .none + } + + let store = TestStore( + initialValues: [ + .isOn: false, + .someStruct: SomeStruct(value: "init-struct", otherValue: "other"), + .someClass: SomeClass(value: "init-class", otherValue: "other"), + ], + actionHandler: actionHandler, + dependency: () ) + + store.require(.isOn) + + store.send(.toggle, expecting: { $0.set(value: true, forKey: .isOn) }) + store.receive(.nothing, expecting: { _ in }) + + store.send(.toggle, expecting: { $0.set(value: false, forKey: .isOn) }) + store.receive(.nothing, expecting: { _ in }) + + store.send(.updateStruct, expecting: { + $0.update(.someStruct, as: SomeStruct.self, updater: { $0?.otherValue = "something" }) + }) + + // Class changes are ignored due to being reference types + store.send(.updateClass, expecting: { _ in }) + + store.send(.removeValue, expecting: { $0.remove(.someStruct) }) + + print("TEST COMPLETE") } }