From be576671d1a24c79e33a4cc9bd9c5b1c94131ba3 Mon Sep 17 00:00:00 2001 From: Anthony Tran Date: Thu, 19 Mar 2026 15:29:43 +0700 Subject: [PATCH 1/4] Replace Task.sleep with withMainSerialExecutor for deterministic tests --- .../AsyncStream/StreamProducerTests.swift | 28 ++-- .../Helpers/CancelBagTests.swift | 157 +++++++++--------- 2 files changed, 98 insertions(+), 87 deletions(-) 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) + } } } From 100bccfa58f1dffe04cf025ee74ba776ff2afded Mon Sep 17 00:00:00 2001 From: Thang Kieu Date: Mon, 8 Jun 2026 10:27:12 +0700 Subject: [PATCH 2/4] refactor(cancellation): propagate errors through waitComplete and fix loadingFinished paths - Make AnyTask.waitComplete throwing; use task.value instead of task.result so cancellation propagates - Call loadingFinished in each error branch of dispatch rather than after catch - Rethrow errors from dispatch so callers receive cancellation/error signals - Conform ActionLocker.Errors to NonPresentableError - Fix unlock to removeValue instead of setting false --- Sources/ScreenStatetKit/Actions/ActionLocker.swift | 4 ++-- Sources/ScreenStatetKit/Helpers/CancelBag.swift | 8 ++++---- .../ScreenStatetKit/Store/ScreenActionStore.swift | 13 ++++++++----- .../StoreStateIntegrationTests.swift | 6 +++--- 4 files changed, 17 insertions(+), 14 deletions(-) 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/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) From 3b1526fdd122cbee99ab35619b02397f3973d4ec Mon Sep 17 00:00:00 2001 From: Thang Kieu Date: Mon, 8 Jun 2026 10:27:34 +0700 Subject: [PATCH 3/4] test(cancellation): add placeholder test file --- Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift diff --git a/Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift b/Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift new file mode 100644 index 0000000..f4af68a --- /dev/null +++ b/Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift @@ -0,0 +1,6 @@ +import Testing +@testable import ScreenStateKit + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} From 83d0200e1f631a45209759cb45d1a0c6f65751c4 Mon Sep 17 00:00:00 2001 From: Thang Kieu Date: Mon, 8 Jun 2026 10:58:40 +0700 Subject: [PATCH 4/4] chore(test): remove placeholder test file --- Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 Tests/ScreenStatetKitTests/ScreenStatetKitTests.swift 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. -}