From 0c5127fad1acaab192bf6f56b5b45c33c7cd96f8 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 25 May 2022 20:56:40 -0600 Subject: [PATCH 01/25] Init async effects --- .../CacheStore/Actions/ActionHandling.swift | 14 +++-- Sources/CacheStore/Stores/CacheStore.swift | 24 ++++++-- Sources/CacheStore/Stores/Store.swift | 60 ++++++++++++++++--- Tests/CacheStoreTests/StoreTests.swift | 5 +- 4 files changed, 86 insertions(+), 17 deletions(-) diff --git a/Sources/CacheStore/Actions/ActionHandling.swift b/Sources/CacheStore/Actions/ActionHandling.swift index 0397a63..52cd505 100644 --- a/Sources/CacheStore/Actions/ActionHandling.swift +++ b/Sources/CacheStore/Actions/ActionHandling.swift @@ -5,20 +5,26 @@ public protocol ActionHandling { func handle(action: Action) } +public typealias ActionEffect = () async -> Action? + public struct StoreActionHandler { - private let handler: (inout CacheStore, Action, Dependency) -> Void + private let handler: (inout CacheStore, Action, Dependency) -> ActionEffect? public init( - _ handler: @escaping (inout CacheStore, Action, Dependency) -> Void + _ handler: @escaping (inout CacheStore, Action, Dependency) -> ActionEffect? ) { self.handler = handler } public static var none: StoreActionHandler { - StoreActionHandler { _, _, _ in } + StoreActionHandler { _, _, _ in nil } } - public func handle(store: inout CacheStore, action: Action, dependency: 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..e4ba023 100644 --- a/Sources/CacheStore/Stores/CacheStore.swift +++ b/Sources/CacheStore/Stores/CacheStore.swift @@ -75,13 +75,19 @@ public class CacheStore: ObservableObject, Cacheable { } public func contains(_ key: Key) -> Bool { - cache[key] != nil + defer { lock.unlock() } + lock.lock() + + return cache[key] != nil } public func valuesInCache( ofType: Value.Type = Value.self ) -> [Key: Value] { - cache.compactMapValues { $0 as? Value } + defer { lock.unlock() } + lock.lock() + + return cache.compactMapValues { $0 as? Value } } /// Update the value of a key by mutating the value passed into the `updater` parameter @@ -113,7 +119,12 @@ public class CacheStore: ObservableObject, Cacheable { // MARK: - Copying - public func copy() -> CacheStore { CacheStore(initialValues: cache) } + public func copy() -> CacheStore { + defer { lock.unlock() } + lock.lock() + + return CacheStore(initialValues: cache) + } } // MARK: - @@ -121,7 +132,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() + defer { lock.unlock() } + lock.lock() + + return $cache.map(CacheStore.init).eraseToAnyPublisher() } /// Creates a `ScopedCacheStore` with the given key transformation and default cache @@ -134,11 +148,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 } diff --git a/Sources/CacheStore/Stores/Store.swift b/Sources/CacheStore/Stores/Store.swift index 31ea929..6837307 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -12,15 +12,26 @@ public class Store: ObservableObject, ActionH private let dependency: Dependency /// The values in the `cache` of type `Any` - public var valuesInCache: [Key: Any] { store.valuesInCache } + public var valuesInCache: [Key: Any] { + defer { lock.unlock() } + lock.lock() + + return store.valuesInCache + } /// A publisher for the private `cache` that is mapped to a CacheStore public var publisher: AnyPublisher, Never> { - store.publisher + defer { lock.unlock() } + lock.lock() + + return store.publisher } /// An identifier of the Store and CacheStore var debugIdentifier: String { + defer { lock.unlock() } + lock.lock() + let cacheStoreAddress = Unmanaged.passUnretained(store).toOpaque().debugDescription var storeDescription: String = "\(self)".replacingOccurrences(of: "CacheStore.", with: "") @@ -48,18 +59,26 @@ public class Store: ObservableObject, ActionH /// 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) + defer { lock.unlock() } + lock.lock() + + return store.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) + defer { lock.unlock() } + lock.lock() + + return store.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 { + lock.lock() try store.require(keys: keys) + lock.unlock() return self } @@ -67,7 +86,9 @@ 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 { + lock.lock() try store.require(keys: [key]) + lock.unlock() return self } @@ -87,7 +108,13 @@ public class Store: ObservableObject, ActionH } var storeCopy = store.copy() - actionHandler.handle(store: &storeCopy, action: action, dependency: dependency) + if let effect = actionHandler.handle(store: &storeCopy, action: action, dependency: dependency) { + Task { + guard let nextAction = await effect() else { return } + + handle(action: nextAction) + } + } if isDebugging { print( @@ -136,14 +163,20 @@ public class Store: ObservableObject, ActionH /// Checks if the given `key` has a value or not public func contains(_ key: Key) -> Bool { - store.contains(key) + defer { lock.unlock() } + lock.lock() + + return store.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) + defer { lock.unlock() } + lock.lock() + + return store.valuesInCache(ofType: type) } /// Creates a `ScopedStore` @@ -160,6 +193,9 @@ public class Store: ObservableObject, ActionH dependency: dependencyTransformation(dependency) ) + defer { lock.unlock() } + lock.lock() + let scopedCacheStore = store.scope( keyTransformation: keyTransformation, defaultCache: defaultCache @@ -168,11 +204,13 @@ public class Store: ObservableObject, ActionH scopedStore.store = 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 @@ -260,7 +298,9 @@ public extension Store { extension Store { public var debug: Self { + lock.lock() isDebugging = true + lock.unlock() return self } @@ -276,7 +316,9 @@ extension Store { } private func isCacheEqual(to updatedStore: CacheStore) -> Bool { + lock.lock() guard store.cache.count == updatedStore.cache.count else { return false } + lock.unlock() return updatedStore.cache.map { key, value in isValueEqual(toUpdatedValue: value, forKey: key) @@ -290,9 +332,11 @@ extension Store { } private func isValueEqual(toUpdatedValue updatedValue: Value, forKey key: Key) -> Bool { + lock.lock() guard let storeValue: Value = store.get(key) else { return false } + lock.unlock() return "\(updatedValue)" == "\(storeValue)" } diff --git a/Tests/CacheStoreTests/StoreTests.swift b/Tests/CacheStoreTests/StoreTests.swift index 2355120..dc6fa93 100644 --- a/Tests/CacheStoreTests/StoreTests.swift +++ b/Tests/CacheStoreTests/StoreTests.swift @@ -36,6 +36,7 @@ class StoreTests: XCTestCase { switch action { case .toggle: store.update(.isOn, as: Bool.self, updater: { $0?.toggle() }) + return { .nothing } case .nothing: print("Do nothing") case .removeValue: @@ -45,6 +46,8 @@ class StoreTests: XCTestCase { case .updateClass: store.update(.someClass, as: SomeClass.self, updater: { $0?.otherValue = "something else" }) } + + return .none } let store = try Store( @@ -63,7 +66,7 @@ class StoreTests: XCTestCase { try t.assert(store.get(.isOn), isEqualTo: false) store.handle(action: .toggle) - store.handle(action: .nothing) +// store.handle(action: .nothing) try t.assert(store.get(.isOn), isEqualTo: true) From 88a5a023f55e8e5ada1566b18d56de7f89416156 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 26 May 2022 19:13:27 -0600 Subject: [PATCH 02/25] Update TestStore to send and receive actions with expectations --- Sources/CacheStore/Stores/CacheStore.swift | 14 ++- Sources/CacheStore/Stores/Store.swift | 45 ++++----- Sources/CacheStore/Stores/TestStore.swift | 108 +++++++++++++++++++++ Tests/CacheStoreTests/StoreTests.swift | 35 ++++--- 4 files changed, 164 insertions(+), 38 deletions(-) create mode 100644 Sources/CacheStore/Stores/TestStore.swift diff --git a/Sources/CacheStore/Stores/CacheStore.swift b/Sources/CacheStore/Stores/CacheStore.swift index e4ba023..ceb473b 100644 --- a/Sources/CacheStore/Stores/CacheStore.swift +++ b/Sources/CacheStore/Stores/CacheStore.swift @@ -5,6 +5,18 @@ import SwiftUI // MARK: - public class CacheStore: ObservableObject, Cacheable { + public struct MissingRequiredKeysError: LocalizedError { + public let keys: Set + + public init(keys: Set) { + self.keys = keys + } + + public var errorDescription: String? { + "Missing Required Keys: \(keys.map { "\($0)" }.joined(separator: ", "))" + } + } + private var lock: NSLock @Published var cache: [Key: Any] @@ -63,7 +75,7 @@ public class CacheStore: ObservableObject, Cacheable { .filter { contains($0) == false } guard missingKeys.isEmpty else { - throw c.MissingRequiredKeysError(keys: missingKeys) + throw MissingRequiredKeysError(keys: missingKeys) } return self diff --git a/Sources/CacheStore/Stores/Store.swift b/Sources/CacheStore/Stores/Store.swift index 6837307..9d16567 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -7,16 +7,17 @@ import SwiftUI 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 + + var cacheStore: CacheStore + var actionHandler: StoreActionHandler + let dependency: Dependency /// The values in the `cache` of type `Any` public var valuesInCache: [Key: Any] { defer { lock.unlock() } lock.lock() - return store.valuesInCache + return cacheStore.valuesInCache } /// A publisher for the private `cache` that is mapped to a CacheStore @@ -24,7 +25,7 @@ public class Store: ObservableObject, ActionH defer { lock.unlock() } lock.lock() - return store.publisher + return cacheStore.publisher } /// An identifier of the Store and CacheStore @@ -32,7 +33,7 @@ public class Store: ObservableObject, ActionH defer { lock.unlock() } lock.lock() - let cacheStoreAddress = Unmanaged.passUnretained(store).toOpaque().debugDescription + let cacheStoreAddress = Unmanaged.passUnretained(cacheStore).toOpaque().debugDescription var storeDescription: String = "\(self)".replacingOccurrences(of: "CacheStore.", with: "") guard let index = storeDescription.firstIndex(of: "<") else { @@ -52,7 +53,7 @@ public class Store: ObservableObject, ActionH ) { lock = NSLock() isDebugging = false - store = CacheStore(initialValues: initialValues) + cacheStore = CacheStore(initialValues: initialValues) self.actionHandler = actionHandler self.dependency = dependency } @@ -62,7 +63,7 @@ public class Store: ObservableObject, ActionH defer { lock.unlock() } lock.lock() - return store.get(key) + 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`. @@ -70,14 +71,14 @@ public class Store: ObservableObject, ActionH defer { lock.unlock() } lock.lock() - return store.resolve(key) + 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 { lock.lock() - try store.require(keys: keys) + try cacheStore.require(keys: keys) lock.unlock() return self @@ -87,7 +88,7 @@ public class Store: ObservableObject, ActionH @discardableResult public func require(_ key: Key) throws -> Self { lock.lock() - try store.require(keys: [key]) + try cacheStore.require(keys: [key]) lock.unlock() return self @@ -107,7 +108,7 @@ public class Store: ObservableObject, ActionH print("[\(formattedDate)] 🟡 New Action: \(action) \(debugIdentifier)") } - var storeCopy = store.copy() + var storeCopy = cacheStore.copy() if let effect = actionHandler.handle(store: &storeCopy, action: action, dependency: dependency) { Task { guard let nextAction = await effect() else { return } @@ -135,7 +136,7 @@ public class Store: ObservableObject, ActionH """ \t⚠️ State Changed \t\t--- Was --- - \t\t\(debuggingStateDelta(forUpdatedStore: store)) + \t\t\(debuggingStateDelta(forUpdatedStore: cacheStore)) \t\t----------- \t\t*********** \t\t--- Now --- @@ -146,7 +147,7 @@ public class Store: ObservableObject, ActionH } objectWillChange.send() - store.cache = storeCopy.cache + cacheStore.cache = storeCopy.cache } if isDebugging { @@ -166,7 +167,7 @@ public class Store: ObservableObject, ActionH defer { lock.unlock() } lock.lock() - return store.contains(key) + 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` @@ -176,7 +177,7 @@ public class Store: ObservableObject, ActionH defer { lock.unlock() } lock.lock() - return store.valuesInCache(ofType: type) + return cacheStore.valuesInCache(ofType: type) } /// Creates a `ScopedStore` @@ -196,12 +197,12 @@ public class Store: ObservableObject, ActionH defer { lock.unlock() } lock.lock() - let scopedCacheStore = store.scope( + 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 let effect = actionHandler.handle(store: &store, action: action, dependency: dependency) @@ -213,10 +214,10 @@ public class Store: ObservableObject, ActionH 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 @@ -317,7 +318,7 @@ extension Store { private func isCacheEqual(to updatedStore: CacheStore) -> Bool { lock.lock() - guard store.cache.count == updatedStore.cache.count else { return false } + guard cacheStore.cache.count == updatedStore.cache.count else { return false } lock.unlock() return updatedStore.cache.map { key, value in @@ -333,7 +334,7 @@ extension Store { private func isValueEqual(toUpdatedValue updatedValue: Value, forKey key: Key) -> Bool { lock.lock() - guard let storeValue: Value = store.get(key) else { + guard let storeValue: Value = cacheStore.get(key) else { return false } lock.unlock() diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift new file mode 100644 index 0000000..3957952 --- /dev/null +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -0,0 +1,108 @@ +import Foundation + +public class TestStore { + struct TestStoreEffect { + let id: UUID + let effect: () async -> Action? + } + + struct TestStoreError: LocalizedError { + let reason: String + + var errorDescription: String? { + reason + } + } + + var store: Store + var effects: [TestStoreEffect] + var nextAction: Action? + + deinit { + // XCT Execption + guard effects.isEmpty else { + print("Uh Oh! We still have \(effects.count) effects!") + return + } + } + + public required init( + initialValues: [Key: Any], + actionHandler: StoreActionHandler, + dependency: Dependency + ) { + store = Store(initialValues: initialValues, actionHandler: actionHandler, dependency: dependency) + effects = [] + } + + public func send(_ action: Action, expecting: (inout CacheStore) throws -> Void) throws { + var expectedCacheStore = store.cacheStore.copy() + + let effect = store.actionHandler.handle( + store: &store.cacheStore, + action: action, + dependency: store.dependency + ) + + try expecting(&expectedCacheStore) + + guard "\(expectedCacheStore.valuesInCache)" == "\(store.cacheStore.valuesInCache)" else { + throw TestStoreError( + reason: """ + \n--- Expected --- + \(expectedCacheStore.valuesInCache) + ---------------- + **************** + ---- Actual ---- + \(store.cacheStore.valuesInCache) + ---------------- + """ + ) + } + + if let effect = effect { + effects.append(TestStoreEffect(id: UUID(), effect: effect)) + } + } + + public func receive(_ action: Action, expecting: @escaping (inout CacheStore) throws -> Void) throws { + guard let effect = effects.first else { + throw TestStoreError(reason: "No effects to receive") + } + + 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 { + throw TestStoreError(reason: "Action (\(action)) does not equal NextAction (\(nextAction))") + } + + try self.send(nextAction, expecting: expecting) + } +} + +public extension TestStore { + func require(keys: Set) throws -> Self { + try store.require(keys: keys) + + return self + } + + func require(_ key: Key) throws -> Self { + try store.require(key) + + return self + } +} diff --git a/Tests/CacheStoreTests/StoreTests.swift b/Tests/CacheStoreTests/StoreTests.swift index dc6fa93..fc8fe41 100644 --- a/Tests/CacheStoreTests/StoreTests.swift +++ b/Tests/CacheStoreTests/StoreTests.swift @@ -26,6 +26,7 @@ class StoreTests: XCTestCase { case isOn case someStruct case someClass + case rtandom } enum Action { @@ -36,8 +37,15 @@ class StoreTests: XCTestCase { switch action { case .toggle: store.update(.isOn, as: Bool.self, updater: { $0?.toggle() }) - return { .nothing } + + print("HERE: \(Date())") + + return { + sleep(3) + return .nothing + } case .nothing: + print("HERE: \(Date())") print("Do nothing") case .removeValue: store.remove(.someStruct) @@ -50,7 +58,7 @@ class StoreTests: XCTestCase { return .none } - let store = try Store( + let store = try TestStore( initialValues: [ .isOn: false, .someStruct: SomeStruct(value: "init-struct", otherValue: "other"), @@ -59,23 +67,20 @@ class StoreTests: XCTestCase { actionHandler: actionHandler, dependency: () ) - .require(.isOn) - .debug + .require(keys: [.rtandom, .isOn]) - try t.assert(store.contains(.isOn), isEqualTo: true) - try t.assert(store.get(.isOn), isEqualTo: false) + try store.send(.toggle, expecting: { $0.set(value: true, forKey: .isOn) }) - store.handle(action: .toggle) -// store.handle(action: .nothing) + try store.receive(.nothing, expecting: { _ in }) + + try store.send(.updateStruct, expecting: { + $0.update(.someStruct, as: SomeStruct.self, updater: { $0?.otherValue = "something" }) + }) - try t.assert(store.get(.isOn), isEqualTo: true) + // Class changes are ignored due to being reference types + try store.send(.updateClass, expecting: { _ in }) - store.handle(action: .updateStruct) - - // No state changes for Referance Types - store.handle(action: .updateClass) - - store.handle(action: .removeValue) + try store.send(.removeValue, expecting: { $0.remove(.someStruct) }) } ) } From df8d69c85a0a7f57f9e704d45bafd559925b24f3 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Tue, 31 May 2022 21:05:46 -0600 Subject: [PATCH 03/25] Update test store and locks --- .../CacheStore/Actions/ActionHandling.swift | 24 ++- Sources/CacheStore/Stores/CacheStore.swift | 8 +- Sources/CacheStore/Stores/Store.swift | 113 +++++++++++--- Sources/CacheStore/Stores/TestStore.swift | 100 ++++++++---- Tests/CacheStoreTests/StoreTests.swift | 143 +++++++++--------- 5 files changed, 263 insertions(+), 125 deletions(-) diff --git a/Sources/CacheStore/Actions/ActionHandling.swift b/Sources/CacheStore/Actions/ActionHandling.swift index 52cd505..bea3715 100644 --- a/Sources/CacheStore/Actions/ActionHandling.swift +++ b/Sources/CacheStore/Actions/ActionHandling.swift @@ -1,3 +1,5 @@ +import Foundation + public protocol ActionHandling { associatedtype Action @@ -5,7 +7,27 @@ public protocol ActionHandling { func handle(action: Action) } -public typealias ActionEffect = () async -> Action? +public struct ActionEffect { + public let id: AnyHashable + public let effect: () async -> Action? + + public static var none: Self { + ActionEffect { nil } + } + + public init( + id: AnyHashable = UUID(), + effect: @escaping () async -> Action? + ) { + self.id = id + self.effect = effect + } + + public init(_ action: Action) { + self.id = UUID() + self.effect = { action } + } +} public struct StoreActionHandler { private let handler: (inout CacheStore, Action, Dependency) -> ActionEffect? diff --git a/Sources/CacheStore/Stores/CacheStore.swift b/Sources/CacheStore/Stores/CacheStore.swift index ceb473b..646a818 100644 --- a/Sources/CacheStore/Stores/CacheStore.swift +++ b/Sources/CacheStore/Stores/CacheStore.swift @@ -87,8 +87,8 @@ public class CacheStore: ObservableObject, Cacheable { } public func contains(_ key: Key) -> Bool { - defer { lock.unlock() } lock.lock() + defer { lock.unlock() } return cache[key] != nil } @@ -96,8 +96,8 @@ public class CacheStore: ObservableObject, Cacheable { public func valuesInCache( ofType: Value.Type = Value.self ) -> [Key: Value] { - defer { lock.unlock() } lock.lock() + defer { lock.unlock() } return cache.compactMapValues { $0 as? Value } } @@ -132,8 +132,8 @@ public class CacheStore: ObservableObject, Cacheable { // MARK: - Copying public func copy() -> CacheStore { - defer { lock.unlock() } lock.lock() + defer { lock.unlock() } return CacheStore(initialValues: cache) } @@ -144,8 +144,8 @@ public class CacheStore: ObservableObject, Cacheable { public extension CacheStore { /// A publisher for the private `cache` that is mapped to a CacheStore var publisher: AnyPublisher { - defer { lock.unlock() } lock.lock() + defer { lock.unlock() } return $cache.map(CacheStore.init).eraseToAnyPublisher() } diff --git a/Sources/CacheStore/Stores/Store.swift b/Sources/CacheStore/Stores/Store.swift index 9d16567..1244a82 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -7,6 +7,7 @@ import SwiftUI public class Store: ObservableObject, ActionHandling { private var lock: NSLock private var isDebugging: Bool + private var effects: [AnyHashable: Task<(), Never>] var cacheStore: CacheStore var actionHandler: StoreActionHandler @@ -14,24 +15,24 @@ public class Store: ObservableObject, ActionH /// The values in the `cache` of type `Any` public var valuesInCache: [Key: Any] { - defer { lock.unlock() } 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> { - defer { lock.unlock() } lock.lock() + defer { lock.unlock() } return cacheStore.publisher } /// An identifier of the Store and CacheStore var debugIdentifier: String { - defer { lock.unlock() } lock.lock() + defer { lock.unlock() } let cacheStoreAddress = Unmanaged.passUnretained(cacheStore).toOpaque().debugDescription var storeDescription: String = "\(self)".replacingOccurrences(of: "CacheStore.", with: "") @@ -53,6 +54,7 @@ public class Store: ObservableObject, ActionH ) { lock = NSLock() isDebugging = false + effects = [:] cacheStore = CacheStore(initialValues: initialValues) self.actionHandler = actionHandler self.dependency = dependency @@ -60,16 +62,16 @@ public class Store: ObservableObject, ActionH /// 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? { - defer { lock.unlock() } 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 { - defer { lock.unlock() } lock.lock() + defer { lock.unlock() } return cacheStore.resolve(key) } @@ -78,8 +80,9 @@ public class Store: ObservableObject, ActionH @discardableResult public func require(keys: Set) throws -> Self { lock.lock() + defer { lock.unlock() } + try cacheStore.require(keys: keys) - lock.unlock() return self } @@ -88,30 +91,36 @@ public class Store: ObservableObject, ActionH @discardableResult public func require(_ key: Key) throws -> Self { lock.lock() + defer { lock.unlock() } + try cacheStore.require(keys: [key]) - lock.unlock() return self } 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() + defer { lock.unlock() } if isDebugging { print("[\(formattedDate)] 🟡 New Action: \(action) \(debugIdentifier)") } var storeCopy = cacheStore.copy() - if let effect = actionHandler.handle(store: &storeCopy, action: action, dependency: dependency) { - Task { - guard let nextAction = await effect() else { return } + if let actionEffect = actionHandler.handle(store: &storeCopy, action: action, dependency: dependency) { + if let runningEffect = effects[actionEffect.id] { + runningEffect.cancel() + } + + effects[actionEffect.id] = Task { + guard let nextAction = await actionEffect.effect() else { return } handle(action: nextAction) } @@ -158,14 +167,12 @@ public class Store: ObservableObject, ActionH """ ) } - - lock.unlock() } /// Checks if the given `key` has a value or not public func contains(_ key: Key) -> Bool { - defer { lock.unlock() } lock.lock() + defer { lock.unlock() } return cacheStore.contains(key) } @@ -174,8 +181,8 @@ public class Store: ObservableObject, ActionH public func valuesInCache( ofType type: Value.Type = Value.self ) -> [Key: Value] { - defer { lock.unlock() } lock.lock() + defer { lock.unlock() } return cacheStore.valuesInCache(ofType: type) } @@ -194,8 +201,8 @@ public class Store: ObservableObject, ActionH dependency: dependencyTransformation(dependency) ) - defer { lock.unlock() } lock.lock() + defer { lock.unlock() } let scopedCacheStore = cacheStore.scope( keyTransformation: keyTransformation, @@ -306,6 +313,71 @@ extension Store { return self } + func send(_ action: Action) -> ActionEffect? { + if isDebugging { + print("[\(formattedDate)] 🟡 New Action: \(action) \(debugIdentifier)") + } + + var storeCopy = cacheStore.copy() + let actionEffect = actionHandler.handle(store: &storeCopy, action: action, dependency: dependency) + + 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) + } + } + + 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: cacheStore)) + \t\t----------- + \t\t*********** + \t\t--- Now --- + \t\t\(debuggingStateDelta(forUpdatedStore: storeCopy)) + \t\t----------- + """ + ) + } + + objectWillChange.send() + cacheStore.cache = storeCopy.cache + } + + if isDebugging { + print( + """ + --------------- State End --------------- + [\(formattedDate)] 🏁 End Action: \(action) \(debugIdentifier) + """ + ) + } + + return actionEffect + } + private var formattedDate: String { let now = Date() let formatter = DateFormatter() @@ -318,9 +390,11 @@ extension Store { private func isCacheEqual(to updatedStore: CacheStore) -> Bool { lock.lock() - guard cacheStore.cache.count == updatedStore.cache.count else { return false } + let cacheStoreCount = cacheStore.cache.count lock.unlock() + guard cacheStoreCount == updatedStore.cache.count else { return false } + return updatedStore.cache.map { key, value in isValueEqual(toUpdatedValue: value, forKey: key) } @@ -334,10 +408,11 @@ extension Store { private func isValueEqual(toUpdatedValue updatedValue: Value, forKey key: Key) -> Bool { lock.lock() + defer { lock.unlock() } + guard let storeValue: Value = cacheStore.get(key) else { return false } - lock.unlock() return "\(updatedValue)" == "\(storeValue)" } diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift index 3957952..7c839a4 100644 --- a/Sources/CacheStore/Stores/TestStore.swift +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -1,4 +1,5 @@ import Foundation +import XCTest public class TestStore { struct TestStoreEffect { @@ -6,14 +7,15 @@ public class TestStore { let effect: () async -> Action? } - struct TestStoreError: LocalizedError { - let reason: String + public struct TestStoreError: LocalizedError { + public let reason: String - var errorDescription: String? { - reason - } + public var errorDescription: String? { reason } } + private let initFile: StaticString + private let initLine: UInt + var store: Store var effects: [TestStoreEffect] var nextAction: Action? @@ -21,7 +23,8 @@ public class TestStore { deinit { // XCT Execption guard effects.isEmpty else { - print("Uh Oh! We still have \(effects.count) effects!") + let effectIDs = effects.map(\.id.uuidString).joined(separator: ", ") + XCTFail("\(effects.count) effect(s) left to receive (\(effectIDs))", file: initFile, line: initLine) return } } @@ -29,26 +32,35 @@ public class TestStore { public required init( initialValues: [Key: Any], actionHandler: StoreActionHandler, - dependency: Dependency + dependency: Dependency, + file: StaticString = #filePath, + line: UInt = #line ) { - store = Store(initialValues: initialValues, actionHandler: actionHandler, dependency: dependency) + store = Store(initialValues: initialValues, actionHandler: actionHandler, dependency: dependency).debug effects = [] + initFile = file + initLine = line } - public func send(_ action: Action, expecting: (inout CacheStore) throws -> Void) throws { + public func send( + _ action: Action, + file: StaticString = #filePath, + line: UInt = #line, + expecting: (inout CacheStore) throws -> Void + ) { var expectedCacheStore = store.cacheStore.copy() - let effect = store.actionHandler.handle( - store: &store.cacheStore, - action: action, - dependency: store.dependency - ) + let actionEffect = store.send(action) - try expecting(&expectedCacheStore) + do { + try expecting(&expectedCacheStore) + } catch { + XCTAssert(false, file: file, line: line) + } guard "\(expectedCacheStore.valuesInCache)" == "\(store.cacheStore.valuesInCache)" else { - throw TestStoreError( - reason: """ + XCTFail( + """ \n--- Expected --- \(expectedCacheStore.valuesInCache) ---------------- @@ -56,18 +68,27 @@ public class TestStore { ---- Actual ---- \(store.cacheStore.valuesInCache) ---------------- - """ + """, + file: file, + line: line ) + return } - if let effect = effect { - effects.append(TestStoreEffect(id: UUID(), effect: effect)) + if let actionEffect = actionEffect { + effects.append(TestStoreEffect(id: UUID(), effect: actionEffect.effect)) } } - public func receive(_ action: Action, expecting: @escaping (inout CacheStore) throws -> Void) throws { + public func receive( + _ action: Action, + file: StaticString = #filePath, + line: UInt = #line, + expecting: @escaping (inout CacheStore) throws -> Void + ) { guard let effect = effects.first else { - throw TestStoreError(reason: "No effects to receive") + XCTFail("No effects to receive", file: file, line: line) + return } effects.removeFirst() @@ -86,23 +107,38 @@ public class TestStore { } guard "\(action)" == "\(nextAction)" else { - throw TestStoreError(reason: "Action (\(action)) does not equal NextAction (\(nextAction))") + XCTFail("Action (\(action)) does not equal NextAction (\(nextAction))", file: file, line: line) + return } - try self.send(nextAction, expecting: expecting) + send(nextAction, expecting: expecting) } } + public extension TestStore { - func require(keys: Set) throws -> Self { - try store.require(keys: keys) - - return self + func require( + keys: Set, + file: StaticString = #filePath, + line: UInt = #line + ) { + do { + try store.require(keys: keys) + } catch { + let requiredKeys = keys.map { "\($0)" }.joined(separator: ", ") + XCTFail("Store does not have requied keys (\(requiredKeys))", file: file, line: line) + } } - func require(_ key: Key) throws -> Self { - try store.require(key) - - return self + func require( + _ key: Key, + file: StaticString = #filePath, + line: UInt = #line + ) { + do { + try store.require(key) + } catch { + XCTFail("Store does not have requied key (\(key))", file: file, line: line) + } } } diff --git a/Tests/CacheStoreTests/StoreTests.swift b/Tests/CacheStoreTests/StoreTests.swift index fc8fe41..1f79f8c 100644 --- a/Tests/CacheStoreTests/StoreTests.swift +++ b/Tests/CacheStoreTests/StoreTests.swift @@ -5,83 +5,88 @@ import XCTest class StoreTests: XCTestCase { 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 - case rtandom - } + 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() }) - - print("HERE: \(Date())") - - return { - sleep(3) - return .nothing - } - case .nothing: - print("HERE: \(Date())") - 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 .none + return ActionEffect(id: "toggle->nothing") { + sleep(3) + return .nothing } - let store = try TestStore( - initialValues: [ - .isOn: false, - .someStruct: SomeStruct(value: "init-struct", otherValue: "other"), - .someClass: SomeClass(value: "init-class", otherValue: "other"), - ], - actionHandler: actionHandler, - dependency: () - ) - .require(keys: [.rtandom, .isOn]) - - try store.send(.toggle, expecting: { $0.set(value: true, forKey: .isOn) }) + case .nothing: + print("NOTHING HERE: \(Date())") + print("Do nothing") - try store.receive(.nothing, expecting: { _ in }) - - try store.send(.updateStruct, expecting: { - $0.update(.someStruct, as: SomeStruct.self, updater: { $0?.otherValue = "something" }) - }) + case .removeValue: + store.remove(.someStruct) - // Class changes are ignored due to being reference types - try store.send(.updateClass, expecting: { _ in }) + case .updateStruct: + store.update(.someStruct, as: SomeStruct.self, updater: { $0?.otherValue = "something" }) - try store.send(.removeValue, expecting: { $0.remove(.someStruct) }) + 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") } } From 75228e7a193aeb62425ebc4fb310a91b5e816e13 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Tue, 31 May 2022 21:36:27 -0600 Subject: [PATCH 04/25] Remove TestActionEffect --- Sources/CacheStore/Stores/TestStore.swift | 25 +++++++---------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift index 7c839a4..4c3b47e 100644 --- a/Sources/CacheStore/Stores/TestStore.swift +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -2,28 +2,16 @@ import Foundation import XCTest public class TestStore { - struct TestStoreEffect { - let id: UUID - let effect: () async -> Action? - } - - public struct TestStoreError: LocalizedError { - public let reason: String - - public var errorDescription: String? { reason } - } - private let initFile: StaticString private let initLine: UInt + private var nextAction: Action? - var store: Store - var effects: [TestStoreEffect] - var nextAction: Action? + public private(set) var store: Store + public private(set) var effects: [ActionEffect] deinit { - // XCT Execption guard effects.isEmpty else { - let effectIDs = effects.map(\.id.uuidString).joined(separator: ", ") + let effectIDs = effects.map { "\($0)" }.joined(separator: ", ") XCTFail("\(effects.count) effect(s) left to receive (\(effectIDs))", file: initFile, line: initLine) return } @@ -55,7 +43,8 @@ public class TestStore { do { try expecting(&expectedCacheStore) } catch { - XCTAssert(false, file: file, line: line) + XCTFail("Expectation failed", file: file, line: line) + return } guard "\(expectedCacheStore.valuesInCache)" == "\(store.cacheStore.valuesInCache)" else { @@ -76,7 +65,7 @@ public class TestStore { } if let actionEffect = actionEffect { - effects.append(TestStoreEffect(id: UUID(), effect: actionEffect.effect)) + effects.append(actionEffect) } } From c0a9dd2c06acd1f5833e42b0fdd2b5d84ecb6672 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Tue, 31 May 2022 22:06:18 -0600 Subject: [PATCH 05/25] Update test XCTFail messages --- Sources/CacheStore/Stores/TestStore.swift | 17 +++++++++-------- Tests/CacheStoreTests/StoreTests.swift | 1 - 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift index 4c3b47e..ca8a341 100644 --- a/Sources/CacheStore/Stores/TestStore.swift +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -11,8 +11,8 @@ public class TestStore { deinit { guard effects.isEmpty else { - let effectIDs = effects.map { "\($0)" }.joined(separator: ", ") - XCTFail("\(effects.count) effect(s) left to receive (\(effectIDs))", file: initFile, line: initLine) + let effectIDs = effects.map { "- \($0.id)" }.joined(separator: "\n") + XCTFail("❌ \(effects.count) effect(s) left to receive:\n\(effectIDs)", file: initFile, line: initLine) return } } @@ -43,14 +43,15 @@ public class TestStore { do { try expecting(&expectedCacheStore) } catch { - XCTFail("Expectation failed", file: file, line: line) + XCTFail("❌ Expectation failed", file: file, line: line) return } guard "\(expectedCacheStore.valuesInCache)" == "\(store.cacheStore.valuesInCache)" else { XCTFail( """ - \n--- Expected --- + ❌ Expectation failed + --- Expected --- \(expectedCacheStore.valuesInCache) ---------------- **************** @@ -76,7 +77,7 @@ public class TestStore { expecting: @escaping (inout CacheStore) throws -> Void ) { guard let effect = effects.first else { - XCTFail("No effects to receive", file: file, line: line) + XCTFail("❌ No effects to receive", file: file, line: line) return } @@ -96,7 +97,7 @@ public class TestStore { } guard "\(action)" == "\(nextAction)" else { - XCTFail("Action (\(action)) does not equal NextAction (\(nextAction))", file: file, line: line) + XCTFail("❌ Action (\(action)) does not equal NextAction (\(nextAction))", file: file, line: line) return } @@ -115,7 +116,7 @@ public extension TestStore { try store.require(keys: keys) } catch { let requiredKeys = keys.map { "\($0)" }.joined(separator: ", ") - XCTFail("Store does not have requied keys (\(requiredKeys))", file: file, line: line) + XCTFail("❌ Store does not have requied keys (\(requiredKeys))", file: file, line: line) } } @@ -127,7 +128,7 @@ public extension TestStore { do { try store.require(key) } catch { - XCTFail("Store does not have requied key (\(key))", file: file, line: line) + XCTFail("❌ Store does not have requied key (\(key))", file: file, line: line) } } } diff --git a/Tests/CacheStoreTests/StoreTests.swift b/Tests/CacheStoreTests/StoreTests.swift index 1f79f8c..7104b03 100644 --- a/Tests/CacheStoreTests/StoreTests.swift +++ b/Tests/CacheStoreTests/StoreTests.swift @@ -86,7 +86,6 @@ class StoreTests: XCTestCase { store.send(.removeValue, expecting: { $0.remove(.someStruct) }) - print("TEST COMPLETE") } } From 6325b33b72e1fdc3a8e55cf6d702fbc956044c3a Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 1 Jun 2022 17:49:13 -0600 Subject: [PATCH 06/25] Add canImport for XCTest --- Sources/CacheStore/Stores/TestStore.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift index ca8a341..547f42f 100644 --- a/Sources/CacheStore/Stores/TestStore.swift +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -1,3 +1,4 @@ +#if canImport(XCTest) import Foundation import XCTest @@ -132,3 +133,4 @@ public extension TestStore { } } } +#endif From c454d4a76ca620f01e647a98e159a1a74a227be5 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 1 Jun 2022 18:27:21 -0600 Subject: [PATCH 07/25] Update to not use XCTFail --- Sources/CacheStore/Stores/TestStore.swift | 39 ++++++++++++++++------- Tests/CacheStoreTests/StoreTests.swift | 4 +++ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift index 547f42f..18e2791 100644 --- a/Sources/CacheStore/Stores/TestStore.swift +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -1,6 +1,11 @@ -#if canImport(XCTest) +#if DEBUG import Foundation -import XCTest + +public typealias FailureHandler = (_ message: String, _ file: StaticString, _ line: UInt) -> Void + +public enum TestStoreFailure { + public static var handler: FailureHandler! +} public class TestStore { private let initFile: StaticString @@ -13,7 +18,7 @@ public class TestStore { deinit { guard effects.isEmpty else { let effectIDs = effects.map { "- \($0.id)" }.joined(separator: "\n") - XCTFail("❌ \(effects.count) effect(s) left to receive:\n\(effectIDs)", file: initFile, line: initLine) + TestStoreFailure.handler("❌ \(effects.count) effect(s) left to receive:\n\(effectIDs)", initFile, initLine) return } } @@ -25,6 +30,18 @@ public class TestStore { 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 @@ -44,12 +61,12 @@ public class TestStore { do { try expecting(&expectedCacheStore) } catch { - XCTFail("❌ Expectation failed", file: file, line: line) + TestStoreFailure.handler("❌ Expectation failed", file, line) return } guard "\(expectedCacheStore.valuesInCache)" == "\(store.cacheStore.valuesInCache)" else { - XCTFail( + TestStoreFailure.handler( """ ❌ Expectation failed --- Expected --- @@ -60,8 +77,8 @@ public class TestStore { \(store.cacheStore.valuesInCache) ---------------- """, - file: file, - line: line + file, + line ) return } @@ -78,7 +95,7 @@ public class TestStore { expecting: @escaping (inout CacheStore) throws -> Void ) { guard let effect = effects.first else { - XCTFail("❌ No effects to receive", file: file, line: line) + TestStoreFailure.handler("❌ No effects to receive", file, line) return } @@ -98,7 +115,7 @@ public class TestStore { } guard "\(action)" == "\(nextAction)" else { - XCTFail("❌ Action (\(action)) does not equal NextAction (\(nextAction))", file: file, line: line) + TestStoreFailure.handler("❌ Action (\(action)) does not equal NextAction (\(nextAction))", file, line) return } @@ -117,7 +134,7 @@ public extension TestStore { try store.require(keys: keys) } catch { let requiredKeys = keys.map { "\($0)" }.joined(separator: ", ") - XCTFail("❌ Store does not have requied keys (\(requiredKeys))", file: file, line: line) + TestStoreFailure.handler("❌ Store does not have requied keys (\(requiredKeys))", file, line) } } @@ -129,7 +146,7 @@ public extension TestStore { do { try store.require(key) } catch { - XCTFail("❌ Store does not have requied key (\(key))", file: file, line: line) + TestStoreFailure.handler("❌ Store does not have requied key (\(key))", file, line) } } } diff --git a/Tests/CacheStoreTests/StoreTests.swift b/Tests/CacheStoreTests/StoreTests.swift index 7104b03..122c0cf 100644 --- a/Tests/CacheStoreTests/StoreTests.swift +++ b/Tests/CacheStoreTests/StoreTests.swift @@ -4,6 +4,10 @@ import XCTest @testable import CacheStore class StoreTests: XCTestCase { + override func setUp() { + TestStoreFailure.handler = XCTFail + } + func testExample() { struct SomeStruct { var value: String From 01fd43294f924c488a42bc4f8e41e8a481e5d5e6 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 1 Jun 2022 19:42:16 -0600 Subject: [PATCH 08/25] Improve testing --- Sources/CacheStore/Stores/CacheStore.swift | 31 +++++++++++++ Sources/CacheStore/Stores/Store.swift | 51 +++++----------------- Sources/CacheStore/Stores/TestStore.swift | 2 +- 3 files changed, 43 insertions(+), 41 deletions(-) diff --git a/Sources/CacheStore/Stores/CacheStore.swift b/Sources/CacheStore/Stores/CacheStore.swift index 646a818..85d52a1 100644 --- a/Sources/CacheStore/Stores/CacheStore.swift +++ b/Sources/CacheStore/Stores/CacheStore.swift @@ -193,3 +193,34 @@ public extension CacheStore { ) } } + +extension CacheStore { + func isCacheEqual(to updatedStore: CacheStore) -> Bool { + lock.lock() + let cacheStoreCount = cache.count + lock.unlock() + + 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 { + lock.lock() + defer { lock.unlock() } + + 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 1244a82..2d4185a 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -113,8 +113,8 @@ public class Store: ObservableObject, ActionH print("[\(formattedDate)] 🟡 New Action: \(action) \(debugIdentifier)") } - var storeCopy = cacheStore.copy() - if let actionEffect = actionHandler.handle(store: &storeCopy, action: action, dependency: dependency) { + var cacheStoreCopy = cacheStore.copy() + if let actionEffect = actionHandler.handle(store: &cacheStoreCopy, action: action, dependency: dependency) { if let runningEffect = effects[actionEffect.id] { runningEffect.cancel() } @@ -135,7 +135,7 @@ public class Store: ObservableObject, ActionH ) } - if isCacheEqual(to: storeCopy) { + if cacheStore.isCacheEqual(to: cacheStoreCopy) { if isDebugging { print("\t🙅 No State Change") } @@ -149,14 +149,14 @@ public class Store: ObservableObject, ActionH \t\t----------- \t\t*********** \t\t--- Now --- - \t\t\(debuggingStateDelta(forUpdatedStore: storeCopy)) + \t\t\(debuggingStateDelta(forUpdatedStore: cacheStoreCopy)) \t\t----------- """ ) } objectWillChange.send() - cacheStore.cache = storeCopy.cache + cacheStore.cache = cacheStoreCopy.cache } if isDebugging { @@ -318,8 +318,8 @@ extension Store { print("[\(formattedDate)] 🟡 New Action: \(action) \(debugIdentifier)") } - var storeCopy = cacheStore.copy() - let actionEffect = actionHandler.handle(store: &storeCopy, action: action, dependency: dependency) + var cacheStoreCopy = cacheStore.copy() + let actionEffect = actionHandler.handle(store: &cacheStoreCopy, action: action, dependency: dependency) if let actionEffect = actionEffect { if let runningEffect = effects[actionEffect.id] { @@ -342,7 +342,7 @@ extension Store { ) } - if isCacheEqual(to: storeCopy) { + if cacheStore.isCacheEqual(to: cacheStoreCopy) { if isDebugging { print("\t🙅 No State Change") } @@ -356,14 +356,14 @@ extension Store { \t\t----------- \t\t*********** \t\t--- Now --- - \t\t\(debuggingStateDelta(forUpdatedStore: storeCopy)) + \t\t\(debuggingStateDelta(forUpdatedStore: cacheStoreCopy)) \t\t----------- """ ) } objectWillChange.send() - cacheStore.cache = storeCopy.cache + cacheStore.cache = cacheStoreCopy.cache } if isDebugging { @@ -387,41 +387,12 @@ extension Store { return formatter.string(from: now) } - - private func isCacheEqual(to updatedStore: CacheStore) -> Bool { - lock.lock() - let cacheStoreCount = cacheStore.cache.count - lock.unlock() - - 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 - } - } - } - - private func isValueEqual(toUpdatedValue updatedValue: Value, forKey key: Key) -> Bool { - lock.lock() - defer { lock.unlock() } - - guard let storeValue: Value = cacheStore.get(key) else { - return false - } - - return "\(updatedValue)" == "\(storeValue)" - } private func debuggingStateDelta(forUpdatedStore updatedStore: CacheStore) -> String { 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 index 18e2791..8c23fd2 100644 --- a/Sources/CacheStore/Stores/TestStore.swift +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -65,7 +65,7 @@ public class TestStore { return } - guard "\(expectedCacheStore.valuesInCache)" == "\(store.cacheStore.valuesInCache)" else { + guard expectedCacheStore.isCacheEqual(to: store.cacheStore) else { TestStoreFailure.handler( """ ❌ Expectation failed From 66ed54fd585b8c8e5017f9526f93062fd94deb03 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 1 Jun 2022 19:49:02 -0600 Subject: [PATCH 09/25] Improve lockingh --- Sources/CacheStore/Stores/Store.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/CacheStore/Stores/Store.swift b/Sources/CacheStore/Stores/Store.swift index 2d4185a..8b56da5 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -314,6 +314,9 @@ extension Store { } func send(_ action: Action) -> ActionEffect? { + lock.lock() + defer { lock.unlock() } + if isDebugging { print("[\(formattedDate)] 🟡 New Action: \(action) \(debugIdentifier)") } @@ -389,6 +392,9 @@ extension Store { } private func debuggingStateDelta(forUpdatedStore updatedStore: CacheStore) -> String { + lock.lock() + defer { lock.unlock() } + var updatedStateChanges: [String] = [] for (key, value) in updatedStore.valuesInCache { From f16b50627eebd0d806b704a8273bcfd4481b1b16 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 1 Jun 2022 20:00:42 -0600 Subject: [PATCH 10/25] Seperate test and release locking --- Sources/CacheStore/Stores/CacheStore.swift | 16 +++++++++++----- Sources/CacheStore/Stores/Store.swift | 13 ++++++------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Sources/CacheStore/Stores/CacheStore.swift b/Sources/CacheStore/Stores/CacheStore.swift index 85d52a1..07c6b10 100644 --- a/Sources/CacheStore/Stores/CacheStore.swift +++ b/Sources/CacheStore/Stores/CacheStore.swift @@ -196,9 +196,13 @@ public extension CacheStore { extension CacheStore { func isCacheEqual(to updatedStore: CacheStore) -> Bool { - lock.lock() - let cacheStoreCount = cache.count - lock.unlock() + #if DEBUG + let cacheStoreCount = cache.count + #else + lock.lock() + let cacheStoreCount = cache.count + lock.unlock() + #endif guard cacheStoreCount == updatedStore.cache.count else { return false } @@ -214,8 +218,10 @@ extension CacheStore { } func isValueEqual(toUpdatedValue updatedValue: Value, forKey key: Key) -> Bool { - lock.lock() - defer { lock.unlock() } + #if !DEBUG + lock.lock() + defer { lock.unlock() } + #endif guard let storeValue: Value = get(key) else { return false diff --git a/Sources/CacheStore/Stores/Store.swift b/Sources/CacheStore/Stores/Store.swift index 8b56da5..308f8ed 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -134,7 +134,7 @@ public class Store: ObservableObject, ActionH """ ) } - + if cacheStore.isCacheEqual(to: cacheStoreCopy) { if isDebugging { print("\t🙅 No State Change") @@ -314,14 +314,12 @@ extension Store { } func send(_ action: Action) -> ActionEffect? { - lock.lock() - defer { lock.unlock() } - if isDebugging { print("[\(formattedDate)] 🟡 New Action: \(action) \(debugIdentifier)") } var cacheStoreCopy = cacheStore.copy() + let actionEffect = actionHandler.handle(store: &cacheStoreCopy, action: action, dependency: dependency) if let actionEffect = actionEffect { @@ -344,8 +342,10 @@ extension Store { """ ) } - - if cacheStore.isCacheEqual(to: cacheStoreCopy) { + + let areCacheEqual = cacheStore.isCacheEqual(to: cacheStoreCopy) + + if areCacheEqual { if isDebugging { print("\t🙅 No State Change") } @@ -365,7 +365,6 @@ extension Store { ) } - objectWillChange.send() cacheStore.cache = cacheStoreCopy.cache } From 16e025e0eec9d34d0e91471c20a0065ef8f5fa01 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 1 Jun 2022 20:26:06 -0600 Subject: [PATCH 11/25] Remove effects that have the same id --- Sources/CacheStore/Stores/TestStore.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift index 8c23fd2..633fb52 100644 --- a/Sources/CacheStore/Stores/TestStore.swift +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -83,8 +83,14 @@ public class TestStore { return } + if let actionEffect = actionEffect { - effects.append(actionEffect) + let predicate: (ActionEffect) -> Bool = { $0.id == actionEffect.id } + if effects.contains(where: predicate) { + effects.removeAll(where: predicate) + } else { + effects.append(actionEffect) + } } } From 0cf65947a193334be02b0343329c1a496c8ff8b0 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 1 Jun 2022 20:27:03 -0600 Subject: [PATCH 12/25] Always append the new effecy --- Sources/CacheStore/Stores/TestStore.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift index 633fb52..f3b46d0 100644 --- a/Sources/CacheStore/Stores/TestStore.swift +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -88,9 +88,9 @@ public class TestStore { let predicate: (ActionEffect) -> Bool = { $0.id == actionEffect.id } if effects.contains(where: predicate) { effects.removeAll(where: predicate) - } else { - effects.append(actionEffect) } + + effects.append(actionEffect) } } From ce2b98c8db1618be1009a26ee7518864c8626308 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 1 Jun 2022 20:33:28 -0600 Subject: [PATCH 13/25] Remove locks from handle since it is only on the main thread --- Sources/CacheStore/Stores/Store.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/CacheStore/Stores/Store.swift b/Sources/CacheStore/Stores/Store.swift index 308f8ed..393e58c 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -106,9 +106,6 @@ public class Store: ObservableObject, ActionH return } - lock.lock() - defer { lock.unlock() } - if isDebugging { print("[\(formattedDate)] 🟡 New Action: \(action) \(debugIdentifier)") } From 53a5f5ec7055f92979a71fbf490431e6512ea8f4 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 1 Jun 2022 21:07:04 -0600 Subject: [PATCH 14/25] Dump action for debugging --- Sources/CacheStore/Stores/Store.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/CacheStore/Stores/Store.swift b/Sources/CacheStore/Stores/Store.swift index 393e58c..b42175b 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -126,7 +126,7 @@ public class Store: ObservableObject, ActionH if isDebugging { print( """ - [\(formattedDate)] 📣 Handled Action: \(action) \(debugIdentifier) + [\(formattedDate)] 📣 Handled Action: \(dump(action)) \(debugIdentifier) --------------- State Output ------------ """ ) @@ -160,7 +160,7 @@ public class Store: ObservableObject, ActionH print( """ --------------- State End --------------- - [\(formattedDate)] 🏁 End Action: \(action) \(debugIdentifier) + [\(formattedDate)] 🏁 End Action: \(dump(action)) \(debugIdentifier) """ ) } @@ -312,7 +312,7 @@ extension Store { func send(_ action: Action) -> ActionEffect? { if isDebugging { - print("[\(formattedDate)] 🟡 New Action: \(action) \(debugIdentifier)") + print("[\(formattedDate)] 🟡 New Action: \(dump(action)) \(debugIdentifier)") } var cacheStoreCopy = cacheStore.copy() @@ -334,7 +334,7 @@ extension Store { if isDebugging { print( """ - [\(formattedDate)] 📣 Handled Action: \(action) \(debugIdentifier) + [\(formattedDate)] 📣 Handled Action: \(dump(action)) \(debugIdentifier) --------------- State Output ------------ """ ) From 70ff833343a0d3532b2b4a0d0ba44eee5265091c Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 1 Jun 2022 21:25:28 -0600 Subject: [PATCH 15/25] Add custom-dump --- Package.swift | 9 +++-- Sources/CacheStore/Stores/Store.swift | 44 ++++++++++++++--------- Sources/CacheStore/Stores/TestStore.swift | 7 ++-- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/Package.swift b/Package.swift index a1f5cac..df23afb 100644 --- a/Package.swift +++ b/Package.swift @@ -26,7 +26,11 @@ let package = Package( .package( url: "https://github.com/0xOpenBytes/t", from: "0.2.0" - ) + ), + .package( + url: "https://github.com/pointfreeco/swift-custom-dump", + from: "0.4.0" + ) ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -35,7 +39,8 @@ let package = Package( name: "CacheStore", dependencies: [ "c", - "t" + "t", + .product(name: "CustomDump", package: "swift-custom-dump") ] ), .testTarget( diff --git a/Sources/CacheStore/Stores/Store.swift b/Sources/CacheStore/Stores/Store.swift index b42175b..467a69c 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -1,5 +1,6 @@ import c import Combine +import CustomDump import SwiftUI // MARK: - @@ -126,7 +127,7 @@ public class Store: ObservableObject, ActionH if isDebugging { print( """ - [\(formattedDate)] 📣 Handled Action: \(dump(action)) \(debugIdentifier) + [\(formattedDate)] 📣 Handled Action: \(customDump(action)) \(debugIdentifier) --------------- State Output ------------ """ ) @@ -160,7 +161,7 @@ public class Store: ObservableObject, ActionH print( """ --------------- State End --------------- - [\(formattedDate)] 🏁 End Action: \(dump(action)) \(debugIdentifier) + [\(formattedDate)] 🏁 End Action: \(customDump(action)) \(debugIdentifier) """ ) } @@ -312,7 +313,7 @@ extension Store { func send(_ action: Action) -> ActionEffect? { if isDebugging { - print("[\(formattedDate)] 🟡 New Action: \(dump(action)) \(debugIdentifier)") + print("[\(formattedDate)] 🟡 New Action: \(customDump(action)) \(debugIdentifier)") } var cacheStoreCopy = cacheStore.copy() @@ -334,7 +335,7 @@ extension Store { if isDebugging { print( """ - [\(formattedDate)] 📣 Handled Action: \(dump(action)) \(debugIdentifier) + [\(formattedDate)] 📣 Handled Action: \(customDump(action)) \(debugIdentifier) --------------- State Output ------------ """ ) @@ -348,18 +349,27 @@ extension Store { } } else { if isDebugging { - 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----------- - """ - ) + 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----------- + """ + ) + } } cacheStore.cache = cacheStoreCopy.cache @@ -369,7 +379,7 @@ extension Store { print( """ --------------- State End --------------- - [\(formattedDate)] 🏁 End Action: \(action) \(debugIdentifier) + [\(formattedDate)] 🏁 End Action: \(customDump(action)) \(debugIdentifier) """ ) } diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift index f3b46d0..a6a9911 100644 --- a/Sources/CacheStore/Stores/TestStore.swift +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -1,4 +1,5 @@ #if DEBUG +import CustomDump import Foundation public typealias FailureHandler = (_ message: String, _ file: StaticString, _ line: UInt) -> Void @@ -70,11 +71,11 @@ public class TestStore { """ ❌ Expectation failed --- Expected --- - \(expectedCacheStore.valuesInCache) + \(customDump(expectedCacheStore.valuesInCache)) ---------------- **************** ---- Actual ---- - \(store.cacheStore.valuesInCache) + \(customDump(store.cacheStore.valuesInCache)) ---------------- """, file, @@ -121,7 +122,7 @@ public class TestStore { } guard "\(action)" == "\(nextAction)" else { - TestStoreFailure.handler("❌ Action (\(action)) does not equal NextAction (\(nextAction))", file, line) + TestStoreFailure.handler("❌ Action (\(customDump(action))) does not equal NextAction (\(customDump(nextAction)))", file, line) return } From 74150cc96c5b1632a26b2d8b10732523404ba3c8 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 1 Jun 2022 21:35:11 -0600 Subject: [PATCH 16/25] Use fork that doesn't print extra --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index df23afb..2d54278 100644 --- a/Package.swift +++ b/Package.swift @@ -28,8 +28,8 @@ let package = Package( from: "0.2.0" ), .package( - url: "https://github.com/pointfreeco/swift-custom-dump", - from: "0.4.0" + url: "https://github.com/0xLeif/swift-custom-dump", + from: "0.4.1" ) ], targets: [ From 63c276d1bb353450f7f55fc2e7b8466695b50520 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Wed, 1 Jun 2022 21:38:50 -0600 Subject: [PATCH 17/25] Handle uses send --- Sources/CacheStore/Stores/Store.swift | 60 +-------------------------- 1 file changed, 2 insertions(+), 58 deletions(-) diff --git a/Sources/CacheStore/Stores/Store.swift b/Sources/CacheStore/Stores/Store.swift index 467a69c..327ee04 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -107,64 +107,7 @@ public class Store: ObservableObject, ActionH return } - if isDebugging { - print("[\(formattedDate)] 🟡 New Action: \(action) \(debugIdentifier)") - } - - var cacheStoreCopy = cacheStore.copy() - if let actionEffect = actionHandler.handle(store: &cacheStoreCopy, action: action, dependency: dependency) { - if let runningEffect = effects[actionEffect.id] { - runningEffect.cancel() - } - - effects[actionEffect.id] = Task { - guard let nextAction = await actionEffect.effect() else { return } - - handle(action: nextAction) - } - } - - if isDebugging { - print( - """ - [\(formattedDate)] 📣 Handled Action: \(customDump(action)) \(debugIdentifier) - --------------- State Output ------------ - """ - ) - } - - if cacheStore.isCacheEqual(to: cacheStoreCopy) { - if isDebugging { - print("\t🙅 No State Change") - } - } else { - if isDebugging { - 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) - """ - ) - } + _ = send(action) } /// Checks if the given `key` has a value or not @@ -372,6 +315,7 @@ extension Store { } } + objectWillChange.send() cacheStore.cache = cacheStoreCopy.cache } From 45f09f44dd2180984f636db36a35141b8e227f5c Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 2 Jun 2022 18:06:46 -0600 Subject: [PATCH 18/25] Add cancel effect function and fix TestStore send logging --- Sources/CacheStore/Stores/Store.swift | 5 +++++ Sources/CacheStore/Stores/TestStore.swift | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/CacheStore/Stores/Store.swift b/Sources/CacheStore/Stores/Store.swift index 327ee04..15a15c8 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -110,6 +110,11 @@ public class Store: ObservableObject, ActionH _ = send(action) } + 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 { lock.lock() diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift index a6a9911..1058993 100644 --- a/Sources/CacheStore/Stores/TestStore.swift +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -126,7 +126,7 @@ public class TestStore { return } - send(nextAction, expecting: expecting) + send(nextAction, file: file, line: line, expecting: expecting) } } From 811aa891a4d35a2ce3a20e495024b1366b274c17 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 2 Jun 2022 18:10:20 -0600 Subject: [PATCH 19/25] Add cancel to TestStore --- Sources/CacheStore/Stores/TestStore.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift index 1058993..32fc1a5 100644 --- a/Sources/CacheStore/Stores/TestStore.swift +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -95,6 +95,10 @@ public class TestStore { } } + public func cancel(id: AnyHashable) { + store.cancel(id: id) + } + public func receive( _ action: Action, file: StaticString = #filePath, From 8b9f119e768a10d1a3fe35d111a3350b3c0c36a1 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 2 Jun 2022 19:38:46 -0600 Subject: [PATCH 20/25] Add documentation comments --- .../CacheStore/Actions/ActionHandling.swift | 12 +++++++++ Sources/CacheStore/Stores/CacheStore.swift | 15 +++++++++++ Sources/CacheStore/Stores/Store.swift | 5 ++++ Sources/CacheStore/Stores/TestStore.swift | 27 ++++++++++++++----- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/Sources/CacheStore/Actions/ActionHandling.swift b/Sources/CacheStore/Actions/ActionHandling.swift index bea3715..06976e1 100644 --- a/Sources/CacheStore/Actions/ActionHandling.swift +++ b/Sources/CacheStore/Actions/ActionHandling.swift @@ -1,20 +1,27 @@ 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? @@ -23,25 +30,30 @@ public struct ActionEffect { 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) -> ActionEffect? + /// init for `StoreActionHandler` public init( _ handler: @escaping (inout CacheStore, Action, Dependency) -> ActionEffect? ) { self.handler = handler } + /// `StoreActionHandler` that doesn't handle any `Action` public static var none: StoreActionHandler { StoreActionHandler { _, _, _ in nil } } + /// Mutate `CacheStore` for `Action` with `Dependency` public func handle( store: inout CacheStore, action: Action, diff --git a/Sources/CacheStore/Stores/CacheStore.swift b/Sources/CacheStore/Stores/CacheStore.swift index 07c6b10..17a3bd2 100644 --- a/Sources/CacheStore/Stores/CacheStore.swift +++ b/Sources/CacheStore/Stores/CacheStore.swift @@ -4,14 +4,19 @@ 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: ", "))" } @@ -23,11 +28,13 @@ public class CacheStore: ObservableObject, Cacheable { /// 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() @@ -54,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 { @@ -69,6 +78,7 @@ 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 @@ -81,11 +91,13 @@ public class CacheStore: ObservableObject, Cacheable { 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 { lock.lock() defer { lock.unlock() } @@ -93,6 +105,7 @@ public class CacheStore: ObservableObject, Cacheable { 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] { @@ -116,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 { @@ -131,6 +145,7 @@ public class CacheStore: ObservableObject, Cacheable { // MARK: - Copying + /// Create a copy of the current `CacheStore` cache public func copy() -> CacheStore { lock.lock() defer { lock.unlock() } diff --git a/Sources/CacheStore/Stores/Store.swift b/Sources/CacheStore/Stores/Store.swift index 15a15c8..7e0f57c 100644 --- a/Sources/CacheStore/Stores/Store.swift +++ b/Sources/CacheStore/Stores/Store.swift @@ -5,6 +5,7 @@ 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 @@ -48,6 +49,7 @@ public class Store: ObservableObject, ActionH return "(Store: \(storeDescription), CacheStore: \(cacheStoreAddress))" } + /// init for `Store` public required init( initialValues: [Key: Any], actionHandler: StoreActionHandler, @@ -99,6 +101,7 @@ public class Store: ObservableObject, ActionH return self } + /// Sends the action to be handled by the `Store` public func handle(action: Action) { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in @@ -110,6 +113,7 @@ public class Store: ObservableObject, ActionH _ = send(action) } + /// Cancel an effect with the ID public func cancel(id: AnyHashable) { effects[id]?.cancel() effects[id] = nil @@ -251,6 +255,7 @@ public extension Store { // MARK: - Debugging extension Store { + /// Modifies and returns the `Store` with debugging mode on public var debug: Self { lock.lock() isDebugging = true diff --git a/Sources/CacheStore/Stores/TestStore.swift b/Sources/CacheStore/Stores/TestStore.swift index 32fc1a5..625f626 100644 --- a/Sources/CacheStore/Stores/TestStore.swift +++ b/Sources/CacheStore/Stores/TestStore.swift @@ -2,19 +2,21 @@ 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? - - public private(set) var store: Store - public private(set) var effects: [ActionEffect] + private var store: Store + private var effects: [ActionEffect] deinit { guard effects.isEmpty else { @@ -24,6 +26,15 @@ public class TestStore { } } + /// init for `TestStore` + /// + /// **Make sure to set `TestStoreFailure.handler`** + /// + /// ``` + /// override func setUp() { + /// TestStoreFailure.handler = XCTFail + /// } + /// ``` public required init( initialValues: [Key: Any], actionHandler: StoreActionHandler, @@ -49,6 +60,7 @@ public class TestStore { initLine = line } + /// Send an action and provide an expectation for the changes from handling the action public func send( _ action: Action, file: StaticString = #filePath, @@ -95,10 +107,12 @@ public class TestStore { } } + /// 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, @@ -132,10 +146,8 @@ public class TestStore { send(nextAction, file: file, line: line, expecting: expecting) } -} - - -public extension TestStore { + + /// Checks to make sure the cache has the required keys, otherwise it will fail func require( keys: Set, file: StaticString = #filePath, @@ -149,6 +161,7 @@ public extension TestStore { } } + /// Checks to make sure the cache has the required key, otherwise it will fail func require( _ key: Key, file: StaticString = #filePath, From 5d312c8fb49a2d7d40e43ae95b1ff299facd8026 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 2 Jun 2022 20:32:27 -0600 Subject: [PATCH 21/25] Update README --- README.md | 229 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 158 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index a562029..13f8b65 100644 --- a/README.md +++ b/README.md @@ -6,113 +6,200 @@ `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 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) From a3584355426bd2ca045e6b2ef8e09e7f551d2813 Mon Sep 17 00:00:00 2001 From: Leif Date: Thu, 2 Jun 2022 20:34:24 -0600 Subject: [PATCH 22/25] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 13f8b65..17dd2bd 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # 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 State Management 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 Basic Idea From 1b55a424dc27b78c199fbfa5aad62f9b851c4853 Mon Sep 17 00:00:00 2001 From: Leif Date: Thu, 2 Jun 2022 20:34:45 -0600 Subject: [PATCH 23/25] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 17dd2bd..60275cb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## What is `CacheStore`? -`CacheStore` is a SwiftUI State Management 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 State Management framework. 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 Basic Idea From 57c9e1fe29830432c21cd8b632cda51508cd8ea5 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 2 Jun 2022 20:39:34 -0600 Subject: [PATCH 24/25] Remove t --- Package.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Package.swift b/Package.swift index 2d54278..95abf84 100644 --- a/Package.swift +++ b/Package.swift @@ -22,10 +22,6 @@ let package = Package( .package( url: "https://github.com/0xOpenBytes/c", from: "1.1.1" - ), - .package( - url: "https://github.com/0xOpenBytes/t", - from: "0.2.0" ), .package( url: "https://github.com/0xLeif/swift-custom-dump", @@ -39,7 +35,6 @@ let package = Package( name: "CacheStore", dependencies: [ "c", - "t", .product(name: "CustomDump", package: "swift-custom-dump") ] ), From f8e30e12ff3037796bcfea589fd1c39c8110d280 Mon Sep 17 00:00:00 2001 From: Leif Date: Thu, 2 Jun 2022 20:46:30 -0600 Subject: [PATCH 25/25] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 60275cb..2e7cb1a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## What is `CacheStore`? -`CacheStore` is a SwiftUI State Management framework. 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