diff --git a/Sources/ScreenStatetKit/Actions/AsyncAction.swift b/Sources/ScreenStatetKit/Actions/AsyncAction.swift index bdf0795..dcb534c 100644 --- a/Sources/ScreenStatetKit/Actions/AsyncAction.swift +++ b/Sources/ScreenStatetKit/Actions/AsyncAction.swift @@ -15,17 +15,21 @@ public typealias AsyncActionPut = AsyncAction public struct AsyncAction: Sendable where Input: Sendable, Output: Sendable { - public typealias WorkAction = @Sendable (Input) async throws -> Output + public typealias WorkAction = @Sendable @isolated(any) (Input) async throws -> Output + public let name: String? - private let identifier = UUID().uuidString + private let identifier = UUID() private let action: WorkAction - public init (_ action: @escaping WorkAction) { + public init (name: String? = .none, + _ action: @escaping WorkAction) { + self.name = name self.action = action } @discardableResult - public func asyncExecute(_ input: Input) async throws -> Output { + public func asyncExecute(isolation: isolated (any Actor)? = #isolation, + _ input: Input) async throws -> Output { try await action(input) } } @@ -33,7 +37,7 @@ where Input: Sendable, Output: Sendable { extension AsyncAction where Input == Void { @discardableResult - public func asyncExecute() async throws -> Output { + public func asyncExecute(isolation: isolated (any Actor)? = #isolation) async throws -> Output { try await action(Void()) } } @@ -42,8 +46,14 @@ extension AsyncAction where Input == Void { extension AsyncAction where Output == Void { public func execute(_ input: Input) { - Task { - try await action(input) + if #available(iOS 26.0, macOS 26.0, *) { + Task.immediate { + try await action(input) + } + } else { + Task { + try await action(input) + } } } } @@ -52,8 +62,14 @@ extension AsyncAction where Output == Void { extension AsyncAction where Output == Void, Input == Void { public func execute() { - Task { - try await action(Void()) + if #available(iOS 26.0, macOS 26.0, *) { + Task.immediate { + try await action(Void()) + } + } else { + Task { + try await action(Void()) + } } } } diff --git a/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift b/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift index 551f347..49d7c0c 100644 --- a/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift +++ b/Sources/ScreenStatetKit/AsyncStream/StreamProducerType.swift @@ -23,10 +23,11 @@ public actor StreamProducer: StreamProducerType where Element: Sendable typealias Continuation = AsyncStream.Continuation - public let withLatest: Bool - private var continuations: [String:Continuation] = [:] + private let storage = StreamStorage() private var latestElement: Element? + public let withLatest: Bool + /// Events stream public var stream: AsyncStream { AsyncStream { continuation in @@ -46,12 +47,11 @@ public actor StreamProducer: StreamProducerType where Element: Sendable if withLatest { latestElement = element } - continuations.values.forEach({ $0.yield(element) }) + storage.emit(element: element) } public func finish() { - continuations.values.forEach({ $0.finish() }) - continuations.removeAll() + storage.finish() } private func append(_ continuation: Continuation) { @@ -59,28 +59,77 @@ public actor StreamProducer: StreamProducerType where Element: Sendable continuation.onTermination = {[weak self] _ in self?.onTermination(forKey: key) } - continuations.updateValue(continuation, forKey: key) + storage.update(continuation, forKey: key) } private func removeContinuation(forKey key: String) { - continuations.removeValue(forKey: key) + storage.removeContinuation(forKey: key) } nonisolated private func onTermination(forKey key: String) { - Task(priority: .high) { - await removeContinuation(forKey: key) + if #available(iOS 26.0, macOS 26.0, *) { + Task.immediate { + await removeContinuation(forKey: key) + } + } else { + Task(priority: .high) { + await removeContinuation(forKey: key) + } } } + @available(*, deprecated, renamed: "finish", message: "The Stream will be automatically finished when deallocated. No need to call it manually.") public nonisolated func nonIsolatedFinish() { - Task(priority: .high) { - await finish() + if #available(iOS 26.0, macOS 26.0, *) { + Task.immediate { + await finish() + } + } else { + Task(priority: .high) { + await finish() + } } } public nonisolated func nonIsolatedEmit(_ element: Element) { - Task(priority: .high) { - await emit(element: element) + if #available(iOS 26.0, macOS 26.0, *) { + Task.immediate { + await emit(element: element) + } + } else { + Task(priority: .high) { + await emit(element: element) + } } } } + +//MARK: - Storage +extension StreamProducer { + private final class StreamStorage { + + private var continuations: [String:Continuation] = [:] + + func emit(element: Element) { + continuations.values.forEach({ $0.yield(element) }) + } + + func update(_ continuation: Continuation, forKey key: String) { + continuations.updateValue(continuation, forKey: key) + } + + func removeContinuation(forKey key: String) { + continuations.removeValue(forKey: key) + } + + func finish() { + continuations.values.forEach({ $0.finish() }) + continuations.removeAll() + } + + deinit { + finish() + } + } +} + diff --git a/Sources/ScreenStatetKit/Helpers/CancelBag.swift b/Sources/ScreenStatetKit/Helpers/CancelBag.swift index b12baba..5c8ab49 100644 --- a/Sources/ScreenStatetKit/Helpers/CancelBag.swift +++ b/Sources/ScreenStatetKit/Helpers/CancelBag.swift @@ -5,57 +5,155 @@ // Created by Anthony on 4/12/25. // - - import Foundation +import SwiftUI -public actor CancelBag { +/// A container that manages the lifetime of `Task`s. +/// +/// Tasks stored in a ``CancelBag`` can be cancelled individually using an +/// identifier or all at once using ``cancelAll()``. +/// +/// When a task finishes (successfully or with cancellation), it is automatically +/// removed from the bag. +/// +/// If the ``CancelBag`` is tied to the lifetime of a view or object, all stored +/// tasks will be cancelled when the bag is deallocated. +public actor CancelBag: ObservableObject { private let storage: CancelBagStorage - public init() { - storage = .init() + public var isEmpty: Bool { + storage.isEmpty } + public var count: Int { + storage.count + } + + public var policy: DuplicatePolicy { + storage.duplicatePolicy + } + + public init(onDuplicate policy: DuplicatePolicy) { + self.storage = .init(onDuplicate: policy) + } + + /// Cancels all stored tasks and clears the bag. public func cancelAll() { storage.cancelAll() } - public func cancel(forIdentifier identifier: String) { + @available(*, deprecated, renamed: "cancelAll", message: "CancelBag will automatically cancel all tasks when deallocated. No need call this method directly.") + nonisolated public func cancelAllInTask() { + Task(priority: .high) { + await cancelAll() + } + } + + /// Cancels the task associated with the given identifier. + /// + /// - Parameter identifier: The identifier used when storing the task. + public func cancel(forIdentifier identifier: AnyHashable) { storage.cancel(forIdentifier: identifier) } - private func insert(_ canceller: Canceller) { - storage.insert(canceller: canceller) + /// Appends a task to the bag. + /// + /// This method is nonisolated so tasks can store themselves without + /// requiring the caller to `await`. + private func insert(_ task: AnyTask) { + storage.insert(task: task) } - nonisolated fileprivate func append(canceller: Canceller) { - Task(priority: .high) { - await insert(canceller) + /// This ensures completed tasks do not remain in the bag. + /// - Parameter watchId: ``Canceller``'s `watchId` + private func removeCanceller(by watchId: UUID) async { + storage.remove(by: watchId) + } + + nonisolated fileprivate func append(task: AnyTask) { + if #available(iOS 26.0, macOS 26.0, *) { + Task.immediate {[weak self] in + await self?.insert(task) + await task.waitComplete() + await self?.removeCanceller(by: task.watchId) + } + } else { + Task {[weak self] in + await self?.insert(task) + await task.waitComplete() + await self?.removeCanceller(by: task.watchId) + } } } } +extension CancelBag { + + /// Defines how `CancelBag` handles tasks with the same identifier. + public enum DuplicatePolicy: Int8, Sendable { + + //// Cancel the currently executing task if a new task with the same identifier is added. + case cancelExisting + + /// Cancel the newly added task if a task with the same identifier already exists. + case cancelNew + } +} + +//MARK: - Storage private final class CancelBagStorage { - private var cancellers: [String: Canceller] = [:] + private var runningTasks: [AnyHashable: AnyTask] + let duplicatePolicy: CancelBag.DuplicatePolicy + + var isEmpty: Bool { + runningTasks.isEmpty + } + + var count: Int { + runningTasks.count + } + + init(onDuplicate policy: CancelBag.DuplicatePolicy) { + self.runningTasks = .init() + self.duplicatePolicy = policy + } func cancelAll() { - let runningTasks = cancellers.values.filter({ !$0.isCancelled }) - runningTasks.forEach{ $0.cancel() } - cancellers.removeAll() + runningTasks.values.forEach{ $0.cancel() } + runningTasks.removeAll() } - func cancel(forIdentifier identifier: String) { - guard let task = cancellers[identifier] else { return } + func cancel(forIdentifier identifier: AnyHashable) { + guard let task = runningTasks[identifier] else { return } task.cancel() - cancellers.removeValue(forKey: identifier) + runningTasks.removeValue(forKey: identifier) } - func insert(canceller: Canceller) { - cancel(forIdentifier: canceller.id) - guard !canceller.isCancelled else { return } - cancellers.updateValue(canceller, forKey: canceller.id) + func remove(by watchId: UUID) { + guard let key = runningTasks.first(where: { $0.value.watchId == watchId })?.key else { return } + runningTasks.removeValue(forKey: key) + } + + func insert(task: AnyTask) { + guard let existing = runningTasks[task.storageKey] else { + _insert(task: task) + return + } + switch duplicatePolicy { + case .cancelExisting: + existing.cancel() + runningTasks.removeValue(forKey: existing.storageKey) + _insert(task: task) + case .cancelNew: + task.cancel() + } + } + + private func _insert(task: AnyTask) { + guard !task.isCancelled else { return } + runningTasks.updateValue(task, forKey: task.storageKey) } deinit { @@ -63,30 +161,49 @@ private final class CancelBagStorage { } } -private struct Canceller: Identifiable, Sendable { + +//MARK: - AnyTask +public struct AnyTask: Sendable { - let cancel: @Sendable () -> Void - let id: String - var isCancelled: Bool { isCancelledBock() } + public typealias Identifier = Hashable & Sendable + public let cancel: @Sendable () -> Void + public let waitComplete: @Sendable () async -> Void + public var isCancelled: Bool { isCancelledBock() } + public let id: any Identifier + let watchId: UUID private let isCancelledBock: @Sendable () -> Bool - init(_ task: Task, identifier: String = UUID().uuidString) { + var storageKey: AnyHashable { + .init(self.id) + } + + init(_ task: Task, identifier: any Identifier) { cancel = { task.cancel() } + waitComplete = { _ = await task.result } isCancelledBock = { task.isCancelled } id = identifier + watchId = .init() } } +//MARK: - Short Path extension Task { - public func store(in bag: CancelBag) { - let canceller = Canceller(self) - bag.append(canceller: canceller) + @discardableResult + public func store(in bag: CancelBag?) -> AnyTask { + let anyTask = AnyTask(self, identifier: UUID()) + bag?.append(task: anyTask) + return anyTask } - public func store(in bag: CancelBag, withIdentifier identifier: String) { - let canceller = Canceller(self, identifier: identifier) - bag.append(canceller: canceller) + @discardableResult + public func store(in bag: CancelBag?, + withIdentifier identifier: Identifier) + -> AnyTask where Identifier: Hashable, Identifier: Sendable { + let anyTask = AnyTask(self, identifier: identifier) + bag?.append(task: anyTask) + return anyTask } } + diff --git a/Sources/ScreenStatetKit/Helpers/DisplayableError.swift b/Sources/ScreenStatetKit/Helpers/DisplayableError.swift index 77a5729..f5cc731 100644 --- a/Sources/ScreenStatetKit/Helpers/DisplayableError.swift +++ b/Sources/ScreenStatetKit/Helpers/DisplayableError.swift @@ -8,17 +8,53 @@ import SwiftUI +public protocol NonPresentableError: Error { + var isSilent: Bool { get } +} + +extension NonPresentableError { + public var isSilent: Bool { true } +} + public struct DisplayableError: LocalizedError, Identifiable, Hashable { - public let id: String + public let id: UUID public var errorDescription: String? { message } - let message: String - - public init(message: String) { + public let message: String + public let originalError: Error? + let isSilent: Bool + + public init(message: String, error: Error? = .none) { self.message = message - self.id = UUID().uuidString + self.id = UUID() + self.isSilent = (error as? NonPresentableError)?.isSilent == true + if let displayable = error as? DisplayableError { + self.originalError = displayable.originalError + } else { + self.originalError = error + } + } + + public init(error: Error) { + self.message = error.localizedDescription + self.id = UUID() + self.isSilent = (error as? NonPresentableError)?.isSilent == true + if let displayable = error as? DisplayableError { + self.originalError = displayable.originalError + } else { + self.originalError = error + } + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func == (lhs: DisplayableError, rhs: DisplayableError) -> Bool { + lhs.id == rhs.id } } + diff --git a/Sources/ScreenStatetKit/Helpers/LoadingTrackable.swift b/Sources/ScreenStatetKit/Helpers/LoadingTrackable.swift index e2145e4..31fc1e0 100644 --- a/Sources/ScreenStatetKit/Helpers/LoadingTrackable.swift +++ b/Sources/ScreenStatetKit/Helpers/LoadingTrackable.swift @@ -5,7 +5,6 @@ // Created by Anthony on 4/12/25. // - import Foundation public protocol LoadingTrackable { diff --git a/Sources/ScreenStatetKit/States/LoadmoreScreenState.swift b/Sources/ScreenStatetKit/States/LoadmoreScreenState.swift index 2f3eae4..010f9a9 100644 --- a/Sources/ScreenStatetKit/States/LoadmoreScreenState.swift +++ b/Sources/ScreenStatetKit/States/LoadmoreScreenState.swift @@ -15,7 +15,7 @@ open class LoadmoreScreenState: ScreenState { public private(set) var canShowLoadmore: Bool = false public private(set) var didLoadAllData: Bool = false - public func ternimateLoadmoreView() { + public func terminateLoadMoreView() { withAnimation { self.canShowLoadmore = false } diff --git a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift index 896d992..97621ec 100644 --- a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift +++ b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift @@ -10,10 +10,79 @@ import Foundation public protocol ScreenActionStore: TypeNamed, Actor { - associatedtype AScreenState: ScreenState - associatedtype Action: Sendable & ActionLockable + associatedtype ViewState: ScreenState + associatedtype Action: Sendable & Hashable - func binding(state: AScreenState) + /// Reference to the view state. Conforming types should store this as `weak`. + var viewState: ViewState? { get } - nonisolated func receive(action: Action) + /// Handles an incoming action and performs the corresponding logic. + /// - Parameter action: The action to process. + /// - Throws: An error if the action handling fails. + func receive(action: Action) async throws +} + +extension ScreenActionStore { + + public var viewState: ScreenState? { .none } + + /// Dispatches an action from a non-isolated context. + /// + /// This method allows sending an `Action` to the actor without requiring + /// the caller to `await`. Internally it creates a `Task` that forwards the + /// action to `receive(action:)`. + /// + /// If a `CancelBag` is provided, the created task will be stored in the bag + /// using the `action` as its identifier. This allows the task to be cancelled + /// later or automatically replaced if another task with the same identifier + /// is stored. + /// + /// - Parameters: + /// - action: The action to send to the receiver. + /// - bag: Optional `CancelBag` used to manage the lifetime of the created task. + /// + /// - Returns: The stored `AnyTask`. + /// + /// - Tip: If the ``CancelBag`` is tied to the lifetime of a view, its tasks + /// will be cancelled automatically when the view is destroyed. + /// + /// - Note: `Action` must conform to `Hashable` so it can be used as an + /// identifier for task cancellation. + @discardableResult nonisolated + public func nonisolatedReceive( + action: Action, + canceller: CancelBag? = .none + ) -> AnyTask + where Action: Hashable, Action: LoadingTrackable { + if #available(iOS 26.0, macOS 26.0, *) { + Task.immediate { + await dispatch(action: action) + } + .store(in: canceller, withIdentifier: action) + } else { + Task { + await dispatch(action: action) + } + .store(in: canceller, withIdentifier: action) + } + } + + private func dispatch(action: Action) async + where Action: Hashable, Action: LoadingTrackable { + await viewState?.loadingStarted(action: action) + do { + try await receive(action: action) + } catch let displayable as DisplayableError where !displayable.isSilent { + await viewState?.showError(displayable) + } catch { + printDebug(error.localizedDescription) + } + await viewState?.loadingFinished(action: action) + } + + func printDebug(_ message: @autoclosure () -> String) { + #if DEBUG + print(message()) + #endif + } } diff --git a/Tests/ScreenStatetKitTests/Actions/AsyncActionTests.swift b/Tests/ScreenStatetKitTests/Actions/AsyncActionTests.swift index 64f2f1f..efb5a1f 100644 --- a/Tests/ScreenStatetKitTests/Actions/AsyncActionTests.swift +++ b/Tests/ScreenStatetKitTests/Actions/AsyncActionTests.swift @@ -10,7 +10,6 @@ import Testing struct AsyncActionTests { // MARK: - asyncExecute() Tests - @Test("asyncExecute executes wrapped action and returns output") func test_asyncExecute_executesWrappedActionAndReturnsOutput() async throws { let sut = AsyncActionGet { diff --git a/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift b/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift index 1b346b2..a6e8cae 100644 --- a/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift +++ b/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift @@ -13,7 +13,7 @@ struct CancelBagTests { @Test("cancelAll cancels all stored tasks") func test_cancelAll_cancelsAllStoredTasks() async throws { - let sut = CancelBag() + let sut = CancelBag(onDuplicate: .cancelExisting) let task1 = Task { try await Task.sleep(for: .seconds(10)) @@ -39,7 +39,7 @@ struct CancelBagTests { @Test("cancel for identifier cancels specific task") func test_cancelForIdentifier_cancelsSpecificTask() async throws { - let sut = CancelBag() + let sut = CancelBag(onDuplicate: .cancelExisting) let task1 = Task { try await Task.sleep(for: .seconds(10)) @@ -65,7 +65,7 @@ struct CancelBagTests { @Test("store with same identifier cancels previous task") func test_store_withSameIdentifierCancelsPreviousTask() async throws { - let sut = CancelBag() + let sut = CancelBag(onDuplicate: .cancelExisting) let task1 = Task { try await Task.sleep(for: .seconds(10)) @@ -85,12 +85,56 @@ struct CancelBagTests { #expect(task1.isCancelled == true) #expect(task2.isCancelled == false) } + + @Test("store with same identifier cancels new task") + func test_store_withSameIdentifierCancelsNewTask() async throws { + let sut = CancelBag(onDuplicate: .cancelNew) + + let task1 = Task { + try await Task.sleep(for: .seconds(10)) + } + let task2 = Task { + try await Task.sleep(for: .seconds(10)) + } + + task1.store(in: sut, withIdentifier: "sameId") + + try await Task.sleep(for: .milliseconds(50)) + + task2.store(in: sut, withIdentifier: "sameId") + + try await Task.sleep(for: .milliseconds(50)) + + #expect(task1.isCancelled == false) + #expect(task2.isCancelled == true) + } + + @Test("watch task is copmpleted should remove it from cancelbag storage") + func testWatchTaskCompletedRemoveCancellerFromStorage() async throws { + let sut = CancelBag(onDuplicate: .cancelExisting) + + Task { + try await Task.sleep(for: .milliseconds(10)) + }.store(in: sut) + + Task { + try await Task.sleep(for: .seconds(10)) + }.store(in: sut) + + try await Task.sleep(for: .milliseconds(100)) + + let count = await sut.count + let isEmpty = await sut.isEmpty + + #expect(count == 1) + #expect(isEmpty == false) + } } // MARK: - Helpers extension CancelBagTests { private func makeSUT() -> CancelBag { - CancelBag() + CancelBag(onDuplicate: .cancelExisting) } } diff --git a/Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift b/Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift deleted file mode 100644 index f4af68a..0000000 --- a/Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Testing -@testable import ScreenStateKit - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. -} diff --git a/Tests/ScreenStatetKitTests/States/LoadmoreScreenStatesTests.swift b/Tests/ScreenStatetKitTests/States/LoadmoreScreenStatesTests.swift index 0661280..60beb89 100644 --- a/Tests/ScreenStatetKitTests/States/LoadmoreScreenStatesTests.swift +++ b/Tests/ScreenStatetKitTests/States/LoadmoreScreenStatesTests.swift @@ -55,7 +55,7 @@ struct LoadmoreScreenStatesTests { sut.canExecuteLoadmore() // First set it to true #expect(sut.canShowLoadmore == true) - sut.ternimateLoadmoreView() + sut.terminateLoadMoreView() #expect(sut.canShowLoadmore == false) } diff --git a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestLoadMoreStore.swift b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestLoadMoreStore.swift index c647958..105feb0 100644 --- a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestLoadMoreStore.swift +++ b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestLoadMoreStore.swift @@ -30,7 +30,7 @@ extension StoreStateIntegrationTests { await state?.updateState { state in state.items = Array(1...10) } - await state?.ternimateLoadmoreView() + await state?.terminateLoadMoreView() case .loadMoreWithPagination(let page): let items = makeItemsForPage(page) @@ -40,7 +40,7 @@ extension StoreStateIntegrationTests { state.currentPage = page state.hasMorePages = hasMore } - await state?.ternimateLoadmoreView() + await state?.terminateLoadMoreView() } actionLocker.unlock(action) diff --git a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift index 385c25f..0fe675c 100644 --- a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift +++ b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift @@ -10,34 +10,27 @@ import ScreenStateKit extension StoreStateIntegrationTests { actor TestStore: ScreenActionStore { - private var state: TestScreenState? + private(set) var viewState: TestScreenState? private let actionLocker = ActionLocker.nonIsolated private(set) var fetchCount = 0 func binding(state: TestScreenState) { - self.state = state + self.viewState = state } - nonisolated func receive(action: Action) { - Task { - await isolatedReceive(action: action) - } - } - - func isolatedReceive(action: Action) async { + func receive(action: Action) async throws { guard actionLocker.canExecute(action) else { return } - await state?.loadingStarted(action: action) - + defer { actionLocker.unlock(action) } do { switch action { case .fetchUser(let id): fetchCount += 1 - await state?.updateState { state in + await viewState?.updateState { state in state.userName = "User \(id)" } case .fetchUserProfile: fetchCount += 1 - await state?.updateState { state in + await viewState?.updateState { state in state.userName = "John Doe" state.userAge = 25 state.userEmail = "john@example.com" @@ -47,41 +40,44 @@ extension StoreStateIntegrationTests { case .failingAction: throw TestError.somethingWentWrong + case .faillingWithSilentError: + throw TestError.silentError } } catch { - await state?.showError(DisplayableError(message: error.localizedDescription)) + throw DisplayableError(error: error) } - - actionLocker.unlock(action) - await state?.loadingFinished(action: action) } - enum Action: ActionLockable, LoadingTrackable { + enum Action: ActionLockable, LoadingTrackable, Hashable { case fetchUser(id: Int) case fetchUserProfile(id: Int) case slowFetch case failingAction + case faillingWithSilentError var canTrackLoading: Bool { true } - - var lockKey: AnyHashable { - switch self { - case .fetchUser: - return "fetchUser" - case .fetchUserProfile: - return "fetchUserProfile" - case .slowFetch: - return "slowFetch" - case .failingAction: - return "failingAction" - } - } } - enum TestError: LocalizedError { + enum TestError: LocalizedError, NonPresentableError { case somethingWentWrong + case silentError + var errorDescription: String? { + switch self { + case .somethingWentWrong: + "Something went wrong" + case .silentError: + "The silent error" + } + } - var errorDescription: String? { "Something went wrong" } + var isSilent: Bool { + switch self { + case .somethingWentWrong: + false + case .silentError: + true + } + } } } diff --git a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift index f8d447d..5ba840b 100644 --- a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift +++ b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift @@ -7,6 +7,7 @@ import Testing import ConcurrencyExtras import ScreenStateKit +import Observation class StoreStateIntegrationTests { private var leakTrackers: [MemoryLeakTracker] = [] @@ -21,8 +22,7 @@ class StoreStateIntegrationTests { @MainActor func test_receiveAction_updatesStateViaKeyPath() async throws { let (state, sut) = await makeSUT() - - await sut.isolatedReceive(action: .fetchUser(id: 123)) + try await sut.receive(action: .fetchUser(id: 123)) #expect(state.userName == "User 123") #expect(state.isLoading == false) @@ -36,12 +36,12 @@ class StoreStateIntegrationTests { await withMainSerialExecutor { let (state, sut) = await makeSUT() - let task = Task { await sut.isolatedReceive(action: .slowFetch) } - await Task.yield() + let task = sut.nonisolatedReceive(action: .slowFetch) + await Task.megaYield() #expect(state.isLoading == true) - await task.value + await task.waitComplete() #expect(state.isLoading == false) } @@ -53,8 +53,8 @@ class StoreStateIntegrationTests { await withMainSerialExecutor { let (state, sut) = await makeSUT() - sut.receive(action: .fetchUser(id: 1)) - sut.receive(action: .fetchUser(id: 2)) + sut.nonisolatedReceive(action: .fetchUser(id: 1)) + sut.nonisolatedReceive(action: .fetchUser(id: 1)) await Task.megaYield() @@ -62,6 +62,20 @@ class StoreStateIntegrationTests { #expect(await sut.fetchCount == 1) } } + + @Test("action locker dont prevents duplicate action execution when params is differents") + @MainActor + func test_actionLocker_dontPreventsDuplicateExecution() async throws { + await withMainSerialExecutor { + let (_, sut) = await makeSUT() + + sut.nonisolatedReceive(action: .fetchUser(id: 1)) + sut.nonisolatedReceive(action: .fetchUser(id: 2)) + + await Task.megaYield() + #expect(await sut.fetchCount == 2) + } + } // MARK: - Error Handling Tests @@ -70,11 +84,22 @@ class StoreStateIntegrationTests { func test_errorAction_setsDisplayError() async throws { let (state, sut) = await makeSUT() - await sut.isolatedReceive(action: .failingAction) + await sut.nonisolatedReceive(action: .failingAction).waitComplete() #expect(state.displayError?.errorDescription == "Something went wrong") #expect(state.isLoading == false) } + + @Test("error action sets nonDisplayError on state") + @MainActor + func test_errorAction_setsNonDisplayError() async throws { + let (state, sut) = await makeSUT() + + await sut.nonisolatedReceive(action: .faillingWithSilentError).waitComplete() + + #expect(state.displayError == nil) + #expect(state.isLoading == false) + } // MARK: - LoadmoreScreenStates Integration Tests @@ -100,7 +125,7 @@ class StoreStateIntegrationTests { func test_fetchUserProfile_updatesMultipleProperties() async throws { let (state, sut) = await makeSUT() - await sut.isolatedReceive(action: .fetchUserProfile(id: 42)) + try await sut.receive(action: .fetchUserProfile(id: 42)) #expect(state.userName == "John Doe") #expect(state.userAge == 25)