diff --git a/Sources/ScreenStatetKit/Actions/ActionLocker.swift b/Sources/ScreenStatetKit/Actions/ActionLocker.swift index 2385662..7cebbc7 100644 --- a/Sources/ScreenStatetKit/Actions/ActionLocker.swift +++ b/Sources/ScreenStatetKit/Actions/ActionLocker.swift @@ -65,7 +65,7 @@ public final class NonIsolatedActionLocker { public func unlock(_ action: ActionLockable) { guard actions[action.lockKey] != .none else { return } - actions.updateValue(false, forKey: action.lockKey) + actions.removeValue(forKey: action.lockKey) } public func canExecute(_ action: ActionLockable) -> Bool { @@ -84,7 +84,7 @@ public final class NonIsolatedActionLocker { extension ActionLocker { - public enum Errors: Error { + public enum Errors: Error, NonPresentableError { case actionIsRunning } } diff --git a/Sources/ScreenStatetKit/Helpers/CancelBag.swift b/Sources/ScreenStatetKit/Helpers/CancelBag.swift index 5c8ab49..d77c005 100644 --- a/Sources/ScreenStatetKit/Helpers/CancelBag.swift +++ b/Sources/ScreenStatetKit/Helpers/CancelBag.swift @@ -75,13 +75,13 @@ public actor CancelBag: ObservableObject { if #available(iOS 26.0, macOS 26.0, *) { Task.immediate {[weak self] in await self?.insert(task) - await task.waitComplete() + try? await task.waitComplete() await self?.removeCanceller(by: task.watchId) } } else { Task {[weak self] in await self?.insert(task) - await task.waitComplete() + try? await task.waitComplete() await self?.removeCanceller(by: task.watchId) } } @@ -167,7 +167,7 @@ public struct AnyTask: Sendable { public typealias Identifier = Hashable & Sendable public let cancel: @Sendable () -> Void - public let waitComplete: @Sendable () async -> Void + public let waitComplete: @Sendable () async throws -> Void public var isCancelled: Bool { isCancelledBock() } public let id: any Identifier @@ -180,7 +180,7 @@ public struct AnyTask: Sendable { init(_ task: Task, identifier: any Identifier) { cancel = { task.cancel() } - waitComplete = { _ = await task.result } + waitComplete = { _ = try await task.value } isCancelledBock = { task.isCancelled } id = identifier watchId = .init() diff --git a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift index 97621ec..73ada07 100644 --- a/Sources/ScreenStatetKit/Store/ScreenActionStore.swift +++ b/Sources/ScreenStatetKit/Store/ScreenActionStore.swift @@ -56,28 +56,31 @@ extension ScreenActionStore { where Action: Hashable, Action: LoadingTrackable { if #available(iOS 26.0, macOS 26.0, *) { Task.immediate { - await dispatch(action: action) + try await dispatch(action: action) } .store(in: canceller, withIdentifier: action) } else { Task { - await dispatch(action: action) + try await dispatch(action: action) } .store(in: canceller, withIdentifier: action) } } - private func dispatch(action: Action) async + private func dispatch(action: Action) async throws where Action: Hashable, Action: LoadingTrackable { await viewState?.loadingStarted(action: action) do { try await receive(action: action) + await viewState?.loadingFinished(action: action) } catch let displayable as DisplayableError where !displayable.isSilent { await viewState?.showError(displayable) + await viewState?.loadingFinished(action: action) + throw displayable } catch { - printDebug(error.localizedDescription) + await viewState?.loadingFinished(action: action) + throw error } - await viewState?.loadingFinished(action: action) } func printDebug(_ message: @autoclosure () -> String) { diff --git a/Tests/ScreenStatetKitTests/AsyncStream/StreamProducerTests.swift b/Tests/ScreenStatetKitTests/AsyncStream/StreamProducerTests.swift index 7a8a199..33257d8 100644 --- a/Tests/ScreenStatetKitTests/AsyncStream/StreamProducerTests.swift +++ b/Tests/ScreenStatetKitTests/AsyncStream/StreamProducerTests.swift @@ -14,20 +14,26 @@ struct StreamProducerTests { @Test("emit delivers element to subscriber") func test_emit_deliversToSubscriber() async { - let sut = StreamProducer(withLatest: false) - - Task { - try await Task.sleep(for: .milliseconds(50)) + await withMainSerialExecutor { + let sut = StreamProducer(withLatest: false) + + let received = LockIsolated(nil) + + let task = Task { + for await element in await sut.stream { + received.setValue(element) + } + } + + await Task.yield() + await sut.emit(element: 42) await sut.finish() - } - - var received: Int? - for await element in await sut.stream { - received = element - } - #expect(received == 42) + await task.value + + #expect(received.value == 42) + } } @Test("emit delivers element to multiple subscribers") diff --git a/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift b/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift index a6e8cae..4cb2a8d 100644 --- a/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift +++ b/Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift @@ -4,6 +4,7 @@ // import Testing +import ConcurrencyExtras @testable import ScreenStateKit @Suite("CancelBag Tests") @@ -13,121 +14,125 @@ struct CancelBagTests { @Test("cancelAll cancels all stored tasks") func test_cancelAll_cancelsAllStoredTasks() async throws { - let sut = CancelBag(onDuplicate: .cancelExisting) + await withMainSerialExecutor { + let sut = CancelBag(onDuplicate: .cancelExisting) - let task1 = Task { - try await Task.sleep(for: .seconds(10)) - } - let task2 = Task { - try await Task.sleep(for: .seconds(10)) - } - - task1.store(in: sut) - task2.store(in: sut) + let task1 = Task { + try await Task.sleep(for: .seconds(10)) + } + let task2 = Task { + try await Task.sleep(for: .seconds(10)) + } - try await Task.sleep(for: .milliseconds(50)) + task1.store(in: sut) + task2.store(in: sut) - await sut.cancelAll() + await Task.megaYield() - try await Task.sleep(for: .milliseconds(50)) + await sut.cancelAll() - #expect(task1.isCancelled == true) - #expect(task2.isCancelled == true) + #expect(task1.isCancelled == true) + #expect(task2.isCancelled == true) + } } // MARK: - cancel(forIdentifier:) Tests @Test("cancel for identifier cancels specific task") func test_cancelForIdentifier_cancelsSpecificTask() async throws { - let sut = CancelBag(onDuplicate: .cancelExisting) + await withMainSerialExecutor { + let sut = CancelBag(onDuplicate: .cancelExisting) - let task1 = Task { - try await Task.sleep(for: .seconds(10)) - } - let task2 = Task { - try await Task.sleep(for: .seconds(10)) - } + 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: "task1") - task2.store(in: sut, withIdentifier: "task2") + task1.store(in: sut, withIdentifier: "task1") + task2.store(in: sut, withIdentifier: "task2") - try await Task.sleep(for: .milliseconds(50)) + await Task.megaYield() - await sut.cancel(forIdentifier: "task1") + await sut.cancel(forIdentifier: "task1") - try await Task.sleep(for: .milliseconds(50)) - - #expect(task1.isCancelled == true) - #expect(task2.isCancelled == false) + #expect(task1.isCancelled == true) + #expect(task2.isCancelled == false) + } } // MARK: - store() Tests @Test("store with same identifier cancels previous task") func test_store_withSameIdentifierCancelsPreviousTask() async throws { - let sut = CancelBag(onDuplicate: .cancelExisting) + await withMainSerialExecutor { + let sut = CancelBag(onDuplicate: .cancelExisting) - let task1 = Task { - try await Task.sleep(for: .seconds(10)) - } - let task2 = Task { - try await Task.sleep(for: .seconds(10)) - } + 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") + task1.store(in: sut, withIdentifier: "sameId") - try await Task.sleep(for: .milliseconds(50)) + await Task.megaYield() - task2.store(in: sut, withIdentifier: "sameId") + task2.store(in: sut, withIdentifier: "sameId") - try await Task.sleep(for: .milliseconds(50)) + await Task.megaYield() - #expect(task1.isCancelled == true) - #expect(task2.isCancelled == false) + #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) + await withMainSerialExecutor { + 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)) - } + 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") + task1.store(in: sut, withIdentifier: "sameId") - try await Task.sleep(for: .milliseconds(50)) + await Task.megaYield() - task2.store(in: sut, withIdentifier: "sameId") + task2.store(in: sut, withIdentifier: "sameId") - try await Task.sleep(for: .milliseconds(50)) + await Task.megaYield() - #expect(task1.isCancelled == false) - #expect(task2.isCancelled == true) + #expect(task1.isCancelled == false) + #expect(task2.isCancelled == true) + } } - - @Test("watch task is copmpleted should remove it from cancelbag storage") + + @Test("watch task completed 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) + await withMainSerialExecutor { + let sut = CancelBag(onDuplicate: .cancelExisting) + + Task { }.store(in: sut) + + Task { + try await Task.sleep(for: .seconds(10)) + }.store(in: sut) + + await Task.megaYield() + + let count = await sut.count + let isEmpty = await sut.isEmpty + + #expect(count == 1) + #expect(isEmpty == false) + } } } diff --git a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift index 5ba840b..177f9a8 100644 --- a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift +++ b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/StoreStateIntegrationTests.swift @@ -41,7 +41,7 @@ class StoreStateIntegrationTests { #expect(state.isLoading == true) - await task.waitComplete() + try? await task.waitComplete() #expect(state.isLoading == false) } @@ -84,7 +84,7 @@ class StoreStateIntegrationTests { func test_errorAction_setsDisplayError() async throws { let (state, sut) = await makeSUT() - await sut.nonisolatedReceive(action: .failingAction).waitComplete() + try? await sut.nonisolatedReceive(action: .failingAction).waitComplete() #expect(state.displayError?.errorDescription == "Something went wrong") #expect(state.isLoading == false) @@ -95,7 +95,7 @@ class StoreStateIntegrationTests { func test_errorAction_setsNonDisplayError() async throws { let (state, sut) = await makeSUT() - await sut.nonisolatedReceive(action: .faillingWithSilentError).waitComplete() + try? await sut.nonisolatedReceive(action: .faillingWithSilentError).waitComplete() #expect(state.displayError == nil) #expect(state.isLoading == false)