Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/ScreenStatetKit/Actions/ActionLocker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -84,7 +84,7 @@ public final class NonIsolatedActionLocker {

extension ActionLocker {

public enum Errors: Error {
public enum Errors: Error, NonPresentableError {
case actionIsRunning
}
}
Expand Down
8 changes: 4 additions & 4 deletions Sources/ScreenStatetKit/Helpers/CancelBag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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

Expand All @@ -180,7 +180,7 @@ public struct AnyTask: Sendable {

init<S,E>(_ task: Task<S,E>, identifier: any Identifier) {
cancel = { task.cancel() }
waitComplete = { _ = await task.result }
waitComplete = { _ = try await task.value }
isCancelledBock = { task.isCancelled }
id = identifier
watchId = .init()
Expand Down
13 changes: 8 additions & 5 deletions Sources/ScreenStatetKit/Store/ScreenActionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
28 changes: 17 additions & 11 deletions Tests/ScreenStatetKitTests/AsyncStream/StreamProducerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,26 @@ struct StreamProducerTests {

@Test("emit delivers element to subscriber")
func test_emit_deliversToSubscriber() async {
let sut = StreamProducer<Int>(withLatest: false)

Task {
try await Task.sleep(for: .milliseconds(50))
await withMainSerialExecutor {
let sut = StreamProducer<Int>(withLatest: false)

let received = LockIsolated<Int?>(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")
Expand Down
157 changes: 81 additions & 76 deletions Tests/ScreenStatetKitTests/Helpers/CancelBagTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//

import Testing
import ConcurrencyExtras
@testable import ScreenStateKit

@Suite("CancelBag Tests")
Expand All @@ -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)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class StoreStateIntegrationTests {

#expect(state.isLoading == true)

await task.waitComplete()
try? await task.waitComplete()

#expect(state.isLoading == false)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading