diff --git a/README.md b/README.md index b6696a0..a2e2cc0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![iOS Version](https://img.shields.io/badge/iOS_Version->=_13.0-brightgreen?logo=apple&logoColor=green)]() [![Swift Version](https://img.shields.io/badge/Swift_Version-6.0-green?logo=swift)](https://docs.swift.org/swift-book/) -[![Contains Test](https://img.shields.io/badge/Tests-69%25_coverage-blue)]() +[![Contains Test](https://img.shields.io/badge/Tests-83%25_coverage-blue)]() [![Dependency Manager](https://img.shields.io/badge/Dependency_Manager-SPM-red)](#swiftpackagemanager) TBD: updated description diff --git a/Sources/GoodReactor/AnyReactor.swift b/Sources/GoodReactor/AnyReactor.swift deleted file mode 100644 index 9e73ca9..0000000 --- a/Sources/GoodReactor/AnyReactor.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// AnyReactor.swift -// GoodReactor -// -// Created by Filip Šašala on 24/09/2025. -// - -import Observation - -@available(iOS 17.0, macOS 14.0, *) -@MainActor @Observable @dynamicMemberLookup public final class AnyReactor: Reactor { - - // MARK: - Type aliases - - public typealias Action = WrappedAction - public typealias Mutation = WrappedMutation - public typealias Destination = WrappedDestination - public typealias State = WrappedState - - // MARK: - Forwarders - - private let _getState: () -> State - private let _setState: (State) -> () - private let _initialStateBuilder: () -> State - private let _sendAction: (Action) -> () - private let _sendActionAsync: (Action) async -> () - private let _getDestination: () -> Destination? - private let _sendDestination: (Destination?) -> () - private let _reduce: (inout State, Event) -> () - private let _transform: () -> () - - // MARK: - Initialization - - public init(_ base: R) where - R.Action == Action, - R.Mutation == Mutation, - R.Destination == Destination, - R.State == State - { - self._getState = { base.state } - self._setState = { base.state = $0 } - self._initialStateBuilder = { base.makeInitialState() } - self._sendAction = { base.send(action: $0) } - self._sendActionAsync = { await base.send(action: $0) } - self._getDestination = { base.destination } - self._sendDestination = { base.send(destination: $0) } - self._reduce = { base.reduce(state: &$0, event: $1) } - self._transform = { base.transform() } - } - -} - -// MARK: - Dynamic member lookup - -@available(iOS 17.0, macOS 14.0, *) -public extension AnyReactor { - - subscript(dynamicMember keyPath: KeyPath) -> T { - _getState()[keyPath: keyPath] - } - - subscript(dynamicMember keyPath: ReferenceWritableKeyPath) -> T { - _getState()[keyPath: keyPath] - } - -} - -// MARK: - Reactor - -@available(iOS 17.0, macOS 14.0, *) -public extension AnyReactor { - - func makeInitialState() -> State { - _initialStateBuilder() - } - - func transform() { - _transform() - } - - func reduce(state: inout State, event: Event) { - _reduce(&state, event) - } - - var destination: Destination? { - get { - _getDestination() - } - set { - _sendDestination(newValue) - } - } - - var initialState: State { - _initialStateBuilder() - } - -} - -// MARK: - Mirror - -@available(iOS 17.0, macOS 14.0, *) -public extension AnyReactor { - - func send(action: Action) { - _sendAction(action) - } - - func send(action: Action) async { - await _sendActionAsync(action) - } - - func send(destination: Destination?) { - _sendDestination(destination) - } - -} - -// MARK: - Eraser - -@available(iOS 17.0, macOS 14.0, *) -public extension Reactor { - - func eraseToAnyReactor() -> AnyReactor { - AnyReactor(self) - } - -} diff --git a/Sources/GoodReactor/Core/DataFetchingState.swift b/Sources/GoodReactor/Core/DataFetchingState.swift new file mode 100644 index 0000000..43c7f76 --- /dev/null +++ b/Sources/GoodReactor/Core/DataFetchingState.swift @@ -0,0 +1,108 @@ +// +// DataFetchingState.swift +// GoodReactor +// +// Created by Filip Šašala on 21/10/2025. +// + +@_spi(ReactorExperimental) public enum DataFetchingState { + + case idle + case loading + case success(T) + case failure(E) + +} + +@_spi(ReactorExperimental) extension DataFetchingState { + + public var isIdle: Bool { + switch self { + case .idle: true + default: false + } + } + + public var isLoading: Bool { + switch self { + case .loading: true + default: false + } + } + + public var isSuccess: Bool { + switch self { + case .success: true + default: false + } + } + + public var isFailure: Bool { + switch self { + case .failure: true + default: false + } + } + + public var successValue: T? { + if case .success(let value) = self { + return value + } + return nil + } + + public var errorValue: E? { + if case .failure(let error) = self { + return error + } + return nil + } + +} + +@_spi(ReactorExperimental) extension DataFetchingState: Sendable where T: Sendable {} + +@_spi(ReactorExperimental) extension DataFetchingState: Equatable where T: Equatable { + + nonisolated public static func == (lhs: Self, rhs: Self) -> Bool { + switch lhs { + case .idle: + switch rhs { + case .idle: + return true + + default: + return false + } + + case .loading: + switch rhs { + case .loading: + return true + + default: + return false + } + + case .success(let lhsValue): + switch rhs { + case .success(let rhsValue): + return lhsValue == rhsValue + + default: + return false + } + + case .failure(let lhsError): + switch rhs { + case .failure(let rhsError): + // TODO: improve error equality + return lhsError.localizedDescription == rhsError.localizedDescription + + default: + return false + } + } + } + +} diff --git a/Sources/GoodReactor/Core/Erased/AnyMutation.swift b/Sources/GoodReactor/Core/Erased/AnyMutation.swift new file mode 100644 index 0000000..5890d41 --- /dev/null +++ b/Sources/GoodReactor/Core/Erased/AnyMutation.swift @@ -0,0 +1,20 @@ +// +// Mutation.swift +// GoodReactor +// +// Created by Filip Šašala on 01/10/2025. +// + +public struct AnyMutation: Sendable { + + internal let `enum`: (Any & Sendable) + + internal init(_ `enum`: E) { + self.enum = `enum` + } + + internal func `as`(_: T.Type) -> T? { + `enum` as? T + } + +} diff --git a/Sources/GoodReactor/Core/Erased/AnyReactor.swift b/Sources/GoodReactor/Core/Erased/AnyReactor.swift new file mode 100644 index 0000000..5b7535c --- /dev/null +++ b/Sources/GoodReactor/Core/Erased/AnyReactor.swift @@ -0,0 +1,154 @@ +// +// AnyReactor.swift +// GoodReactor +// +// Created by Filip Šašala on 24/09/2025. +// + +import Observation + +#if canImport(SwiftUI) +import SwiftUI +#endif + +/// A type-erased, observable wrapper around a ``Reactor``. +/// +/// Hide a Reactor’s concrete type (including its `Mutation`) while keeping the +/// public interface needed to drive UI and navigation. This helps decouple views, +/// store heterogeneous reactors, and expose a stable API surface across modules. +/// +/// On type-erased `Mutation`: +/// When one view is reused with multiple view models that share the same UI, actions, and state +/// but require slightly different internal behavior, each view model can keep its own concrete +/// `Mutation` type while the view works with a single `AnyReactor`. The view remains agnostic to +/// those internal differences. +/// +/// Behavior: +/// - State is accessed dynamically and mutated by reducing events on a concrete underlying reactor. +/// - Lifecycle: external subscriptions start automatically when `AnyReactor` is initialized (by `start()`-ing the base reactor). +/// - Events from `send(action:)`, `send(action:) async`, and `send(destination:)` are forwarded to the base reactor. +/// +/// Example: +/// ```swift +/// struct ProfileView: View { +/// @ViewModel var reactor: AnyReactor = ProfileViewModel().eraseToAnyReactor() +/// +/// var body: some View { +/// VStack { +/// Text(reactor.name) // dynamic access into state +/// if reactor.isLoading { ProgressView() } +/// Button("Reload") { reactor.send(action: .reload) } +/// } +/// } +/// } +/// ``` +/// +/// - Note: Mutation type is intentionally erased. If you need access to mutations, prefer using concrete Reactor type instead. +@available(iOS 17.0, macOS 14.0, *) +@MainActor @Observable @dynamicMemberLookup public final class AnyReactor: Reactor { + + // MARK: - Type aliases + + public typealias Action = WrappedAction + public typealias Mutation = AnyMutation + public typealias Destination = WrappedDestination + public typealias State = WrappedState + + private let _box: any AnyReactorBoxProtocol + + // MARK: - Initialization + + public init(_ base: R) where R.Action == Action, R.Destination == Destination, R.State == State { + self._box = AnyReactorBox(base) + base.start() + } + +} + +// MARK: - Dynamic member lookup + +@available(iOS 17.0, macOS 14.0, *) +public extension AnyReactor { + + #if canImport(SwiftUI) + func bind(_ member: KeyPath, action: @escaping (T) -> Action) -> Binding { + Binding(get: { + self._box.state[keyPath: member] + }, set: { newValue in + self._box.send(action: action(newValue)) + }) + } + #endif + + subscript(dynamicMember keyPath: KeyPath) -> T { + _box.state[keyPath: keyPath] + } + + subscript(dynamicMember keyPath: ReferenceWritableKeyPath) -> T { + _box.state[keyPath: keyPath] + } + +} + +// MARK: - Reactor + +@available(iOS 17.0, macOS 14.0, *) +public extension AnyReactor { + + func makeInitialState() -> State { + _box.makeInitialState() + } + + func transform() { + _box.transform() + } + + var destination: Destination? { + get { + _box.destination + } + set { + _box.send(destination: newValue) + } + } + + var state: State { + _box.state + } + + func reduce(state: inout WrappedState, event: Event) { + _box.reduceAny(state: &state, event: event) + } + +} + +// MARK: - Mirror + +@available(iOS 17.0, macOS 14.0, *) +public extension AnyReactor { + + func send(action: Action) { + _box.send(action: action) + } + + func send(action: Action) async { + await _box.send(action: action) + } + + func send(destination: Destination?) { + _box.send(destination: destination) + } + +} + +// MARK: - Eraser + +@available(iOS 17.0, macOS 14.0, *) +public extension Reactor { + + func eraseToAnyReactor() -> AnyReactor { + AnyReactor(self) + } + +} + diff --git a/Sources/GoodReactor/Core/Erased/AnyReactorBox.swift b/Sources/GoodReactor/Core/Erased/AnyReactorBox.swift new file mode 100644 index 0000000..de18ca7 --- /dev/null +++ b/Sources/GoodReactor/Core/Erased/AnyReactorBox.swift @@ -0,0 +1,61 @@ +// +// AnyReactorBox.swift +// GoodReactor +// +// Created by Filip Šašala on 01/10/2025. +// + +/// `AnyReactor` box which forwards calls to concrete implementation of `base` Reactor. +@MainActor internal final class AnyReactorBox: AnyReactorBoxProtocol { + + typealias Action = Base.Action + typealias Destination = Base.Destination + typealias State = Base.State + + private let base: Base + + init(_ base: Base) { + self.base = base + } + + var state: Base.State { + get { base.state } + set { base.state = newValue } + } + + var destination: Base.Destination? { + get { base.destination } + } + + func makeInitialState() -> Base.State { + base.makeInitialState() + } + + func transform() { + base.transform() + } + + func send(action: Base.Action) { + base.send(action: action) + } + + func send(action: Base.Action) async { + await base.send(action: action) + } + + func send(destination: Base.Destination?) { + base.send(destination: destination) + } + + func reduceAny(state: inout Base.State, event: Event) { + let concreteEvent = event.castMutation { anyMutation in + guard let concreteMutation = anyMutation.as(Base.Mutation.self) else { + fatalError("Unexpected mutation type: \(type(of: anyMutation)), expected \(Base.Mutation.self)") + } + return concreteMutation + } + + base.reduce(state: &state, event: concreteEvent) + } + +} diff --git a/Sources/GoodReactor/Core/Erased/AnyReactorBoxProtocol.swift b/Sources/GoodReactor/Core/Erased/AnyReactorBoxProtocol.swift new file mode 100644 index 0000000..c8526ab --- /dev/null +++ b/Sources/GoodReactor/Core/Erased/AnyReactorBoxProtocol.swift @@ -0,0 +1,26 @@ +// +// AnyReactorBoxProtocol.swift +// GoodReactor +// +// Created by Filip Šašala on 01/10/2025. +// + +/// Box protocol marking the interface of type-erased reactor with no concrete `Mutation` type +@MainActor internal protocol AnyReactorBoxProtocol: AnyObject { + + associatedtype Action: Sendable + associatedtype Destination: Sendable + associatedtype State + + var state: State { get set } + var destination: Destination? { get } + + func makeInitialState() -> State + func transform() + func send(action: Action) + func send(action: Action) async + func send(destination: Destination?) + + func reduceAny(state: inout State, event: Event) + +} diff --git a/Sources/GoodReactor/Core/Event.swift b/Sources/GoodReactor/Core/Event.swift new file mode 100644 index 0000000..d90d92f --- /dev/null +++ b/Sources/GoodReactor/Core/Event.swift @@ -0,0 +1,69 @@ +// +// Event.swift +// GoodReactor +// +// Created by Filip Šašala on 27/08/2024. +// + +// MARK: - Event + +public final class Event: Sendable where A: Sendable, M: Sendable, D: Sendable { + + public enum Kind: Sendable { + case action(A) + case mutation(M) + case destination(D?) + } + + internal let id: EventIdentifier + public let kind: Event.Kind + + internal init(kind: Event.Kind) { + self.id = EventIdentifier() + self.kind = kind + } + + private init(id: EventIdentifier, action: A) { + self.id = id + self.kind = .action(action) + } + + private init(id: EventIdentifier, mutation: M) { + self.id = id + self.kind = .mutation(mutation) + } + + private init(id: EventIdentifier, destination: D?) { + self.id = id + self.kind = .destination(destination) + } + + // To be used from GoodCoordinator package + convenience public init(destination: D?) { + self.init(kind: .destination(destination)) + } + +} + +// MARK: - Un-erase mutation + +internal extension Event where M == AnyMutation { + + func castMutation(_ transform: (M) -> ConcreteMutation) -> Event { + switch kind { + case .action(let action): + return Event(id: id, action: action) + + case .mutation(let mutation): + return Event(id: id, mutation: transform(mutation)) + + case .destination(let destination): + return Event(id: id, destination: destination) + } + } + +} + +// MARK: - Event identifier + +internal final class EventIdentifier: Identifier, Sendable {} diff --git a/Sources/GoodReactor/Identifier.swift b/Sources/GoodReactor/Core/Identifier.swift similarity index 87% rename from Sources/GoodReactor/Identifier.swift rename to Sources/GoodReactor/Core/Identifier.swift index 0fa729e..516ab11 100644 --- a/Sources/GoodReactor/Identifier.swift +++ b/Sources/GoodReactor/Core/Identifier.swift @@ -32,7 +32,3 @@ public final class CodeLocationIdentifier: Identifier { } } - -// MARK: - Event identifier - -internal final class EventIdentifier: Identifier, Sendable {} diff --git a/Sources/GoodReactor/MapTables.swift b/Sources/GoodReactor/Core/MapTables.swift similarity index 100% rename from Sources/GoodReactor/MapTables.swift rename to Sources/GoodReactor/Core/MapTables.swift diff --git a/Sources/GoodReactor/Reactor.swift b/Sources/GoodReactor/Core/Reactor.swift similarity index 91% rename from Sources/GoodReactor/Reactor.swift rename to Sources/GoodReactor/Core/Reactor.swift index f63c840..04d3a20 100644 --- a/Sources/GoodReactor/Reactor.swift +++ b/Sources/GoodReactor/Core/Reactor.swift @@ -349,8 +349,10 @@ public extension Reactor { /// /// - important: Start async events only from ``reduce(state:event:)`` to ensure correct behaviour. /// - warning: This function is unavailable from asynchronous contexts. If you need to run multiple tasks - /// concurrently, create a `TaskGroup` with ``_Concurrency/Task``s, use `async let` or consider using + /// *concurrently*, create a `TaskGroup` with ``_Concurrency/Task``s, use `async let` or consider using /// an external helper struct. + /// - note: You can start multiple asynchronous blocks under a single `event`. Ordering of operations is + /// undefined and not guaranteed. /// - note: If you need to return multiple mutations from an asynchronous event, create a helper struct /// and supply mutations using a ``Publisher`` and ``subscribe(to:map:)`` @available(*, noasync) func run(_ event: Event, @_implicitSelfCapture eventHandler: @autoclosure @escaping () -> @Sendable () async -> Mutation?) { @@ -382,7 +384,51 @@ public extension Reactor { } } } - + + @_spi(ReactorExperimental) @available(*, noasync) func fetch( + _ event: Event, + _ data: ReferenceWritableKeyPath>, + @_implicitSelfCapture eventHandler: @autoclosure @escaping () -> @Sendable () async throws(E) -> T + ) { + let semaphore = MapTables.eventLocks[key: event.id, default: AsyncSemaphore(value: 0)] + MapTables.runningEvents[key: self, default: EventTaskCounter()].newTask(eventId: event.id) + state[keyPath: data] = DataFetchingState.loading + + Task { @MainActor [weak self] in + guard let self else { return } + + defer { + let remainingTasks = MapTables.runningEvents[key: self, default: EventTaskCounter()] + .stopTask(eventId: event.id) + + if remainingTasks < 1 { + semaphore.signal() + } + } + + let result = await Task.detached(operation: eventHandler()).result + + guard !Task.isCancelled else { + _debugLog(message: "Task cancelled") + state[keyPath: data] = DataFetchingState.idle + return + } + + switch result { + case .success(let value): + state[keyPath: data] = DataFetchingState.success(value) + + case .failure(let anyError): + if let error = anyError as? E { + state[keyPath: data] = DataFetchingState.failure(error) + } else { + // TODO: typed handling of thrown error + state[keyPath: data] = DataFetchingState.idle + } + } + } + } + /// Debounces calls to a function by ignoring repeated successive calls. If handler returns a mutation, /// the mutation will be executed once when debouncing is /// @@ -599,14 +645,3 @@ public extension Reactor { } } - -// MARK: - Migration - -public extension Reactor { - - @available(*, deprecated, message: "Call members directly from Reactor instead of using `currentState` property.") - var currentState: State { - state - } - -} diff --git a/Sources/GoodReactor/Stub.swift b/Sources/GoodReactor/Helpers/Stub.swift similarity index 100% rename from Sources/GoodReactor/Stub.swift rename to Sources/GoodReactor/Helpers/Stub.swift diff --git a/Sources/GoodReactor/ViewModel.swift b/Sources/GoodReactor/Helpers/ViewModel.swift similarity index 100% rename from Sources/GoodReactor/ViewModel.swift rename to Sources/GoodReactor/Helpers/ViewModel.swift diff --git a/Sources/GoodReactor/Utilities/AnyTask.swift b/Sources/GoodReactor/Utils/AnyTask.swift similarity index 100% rename from Sources/GoodReactor/Utilities/AnyTask.swift rename to Sources/GoodReactor/Utils/AnyTask.swift diff --git a/Sources/GoodReactor/Utilities/AsyncSemaphore.swift b/Sources/GoodReactor/Utils/AsyncSemaphore.swift similarity index 100% rename from Sources/GoodReactor/Utilities/AsyncSemaphore.swift rename to Sources/GoodReactor/Utils/AsyncSemaphore.swift diff --git a/Sources/GoodReactor/Utilities/Debouncer.swift b/Sources/GoodReactor/Utils/Debouncer.swift similarity index 100% rename from Sources/GoodReactor/Utilities/Debouncer.swift rename to Sources/GoodReactor/Utils/Debouncer.swift diff --git a/Sources/GoodReactor/Event.swift b/Sources/GoodReactor/Utils/EventTaskCounter.swift similarity index 62% rename from Sources/GoodReactor/Event.swift rename to Sources/GoodReactor/Utils/EventTaskCounter.swift index 0a4032b..ecd7f28 100644 --- a/Sources/GoodReactor/Event.swift +++ b/Sources/GoodReactor/Utils/EventTaskCounter.swift @@ -1,42 +1,14 @@ // -// Event.swift +// EventTaskCounter.swift // GoodReactor // -// Created by Filip Šašala on 27/08/2024. +// Created by Filip Šašala on 01/10/2025. // -import Foundation - -// MARK: - Event - -public final class Event: Sendable where A: Sendable, M: Sendable, D: Sendable { - - public enum Kind: Sendable { - case action(A) - case mutation(M) - case destination(D?) - } - - internal let id: EventIdentifier - public let kind: Event.Kind - - internal init(kind: Event.Kind) { - self.id = EventIdentifier() - self.kind = kind - } - - convenience public init(destination: D?) { - self.init(kind: .destination(destination)) - } - -} - -// MARK: - Event task counter - -struct EventTaskCounter: Sendable { +internal struct EventTaskCounter: Sendable { var events: [EventIdentifier: Int] = [:] - + /// Checks if any tasks are running for provided event /// - Parameter identifier: Event to check /// - Returns: `true` if any tasks are running, `false` otherwise diff --git a/Sources/GoodReactor/Utilities/NSPointerArrayExtension.swift b/Sources/GoodReactor/Utils/NSPointerArrayExtension.swift similarity index 100% rename from Sources/GoodReactor/Utilities/NSPointerArrayExtension.swift rename to Sources/GoodReactor/Utils/NSPointerArrayExtension.swift diff --git a/Sources/GoodReactor/Utilities/ReactorLogger.swift b/Sources/GoodReactor/Utils/ReactorLogger.swift similarity index 100% rename from Sources/GoodReactor/Utilities/ReactorLogger.swift rename to Sources/GoodReactor/Utils/ReactorLogger.swift diff --git a/Sources/GoodReactor/Utilities/WeakMapTable.swift b/Sources/GoodReactor/Utils/WeakMapTable.swift similarity index 100% rename from Sources/GoodReactor/Utilities/WeakMapTable.swift rename to Sources/GoodReactor/Utils/WeakMapTable.swift diff --git a/Tests/GoodReactorTests/AnyReactorTests.swift b/Tests/GoodReactorTests/AnyReactorTests.swift new file mode 100644 index 0000000..cea6318 --- /dev/null +++ b/Tests/GoodReactorTests/AnyReactorTests.swift @@ -0,0 +1,146 @@ +// +// AnyReactorTests.swift +// GoodReactor +// +// Created by Filip Šašala on 01/10/2025. +// + +import XCTest +@testable import GoodReactor + +import Combine +import SwiftUI + +@available(iOS 17.0, macOS 14.0, *) +final class AnyReactorTests: XCTestCase { + + @MainActor func testSendAction() { + let model = AnyReactor(ObservableModel()) + + model.send(action: .addOne) + + XCTAssertEqual(model.initialState.counter, 9, "Initial state mutated") + XCTAssertEqual(model.counter, 10, "Sending action failed") + } + + @MainActor func testInitialState() { + let model = AnyReactor(ObservableModel()) + + XCTAssertEqual(model.initialState.counter, 9, "Invalid initial state") + XCTAssertEqual(model.counter, 9, "Invalid initial state") + + XCTAssertNotIdentical(model.state, model.initialState, "Initial state is NOT A COPY but a reference!") + XCTAssertNotIdentical(model.object, model.initialState.object, "Current state has a reference to initial state with possible mutations!") + } + + @MainActor func testActionMutation() async throws { + let model = AnyReactor(ObservableModel()) + + XCTAssertEqual(model.counter, 9) + + let expectation = XCTestExpectation() + Task { + await model.send(action: .resetToZero) + XCTAssertEqual(model.counter, 0, "Reset to zero failed") + expectation.fulfill() + } + + XCTAssertEqual(model.counter, 9, "State mutated immediately") + + await fulfillment(of: [expectation], timeout: 3) + + XCTAssertEqual(model.counter, 0, "State did not mutate properly") + } + +// @MainActor func testLegacyModel() { +// let model = AnyReactor(LegacyModel()) +// +// let expectation = XCTestExpectation(description: "Change notification was sent") +// +// let cancellable = model.objectWillChange.sink { +// expectation.fulfill() +// } +// +// model.send(action: .addOne) +// +// XCTAssertEqual(model.counter, 10) +// wait(for: [expectation], timeout: 3) +// withExtendedLifetime(cancellable, {}) +// } + + @MainActor func testMultipleRuns() { + let model = AnyReactor(ObservableModel()) + let expectation = XCTestExpectation(description: "5 concurrent runs finished at the same time") + XCTAssertEqual(model.counter, 9) + + Task { + await model.send(action: .multipleRuns) // 5 concurrent tasks for 1s + expectation.fulfill() + } + + wait(for: [expectation], timeout: 2) + XCTAssertEqual(model.counter, 14) + } + + @MainActor func testHundredRunsInForLoop() { + let model = AnyReactor(ObservableModel()) + let expectation = XCTestExpectation(description: "100 concurrent runs finished at the same time") + XCTAssertEqual(model.counter, 9) + + Task { + await model.send(action: .hundredRuns) // 100 concurrent tasks for 1s + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.5) + XCTAssertEqual(model.counter, 109) + } + + /// Test 200 tasks running under one event, but added while the first ones were running, as mutations + @MainActor func testTwiceHundredRuns() { + let model = AnyReactor(ObservableModel()) + let expectation = XCTestExpectation(description: "100+100 concurrent runs finished at the same time") + XCTAssertEqual(model.counter, 9) + + Task { + await model.send(action: .twiceHundredRuns) // 100 + 100 more concurrent tasks for 1s + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.5) + XCTAssertEqual(model.counter, 209) + } + + @MainActor func testDebounce() async { + let model = AnyReactor(ObservableModel()) + + XCTAssertEqual(model.counter, 9) + + for _ in 0..<10 { + await model.send(action: .debounceTest) // send event 10x in a second + try? await Task.sleep(for: .milliseconds(100)) + } + + XCTAssertEqual(model.counter, 9) // event should be waiting in debouncer + + try? await Task.sleep(for: .seconds(1)) + + XCTAssertEqual(model.counter, 10) // event should be debounced by now + } + + @MainActor func testBinding() { + let model = AnyReactor(ObservableModel()) + + XCTAssertEqual(model.counter, 9) + + let binding = model.bind(\.counter, action: { .setCounter($0) }) + + XCTAssertEqual(model.counter, 9) + XCTAssertEqual(model.counter, binding.wrappedValue) + binding.wrappedValue += 12 + XCTAssertEqual(model.counter, 21) + XCTAssertEqual(binding.wrappedValue, 21) + XCTAssertEqual(model.counter, binding.wrappedValue) + } + +} diff --git a/Tests/GoodReactorTests/GoodReactorTests.swift b/Tests/GoodReactorTests/GoodReactorTests.swift index b014be9..6f47d85 100644 --- a/Tests/GoodReactorTests/GoodReactorTests.swift +++ b/Tests/GoodReactorTests/GoodReactorTests.swift @@ -27,29 +27,29 @@ final class GoodReactorTests: XCTestCase { let model = ObservableModel() XCTAssertEqual(model.initialState.counter, 9, "Invalid initial state") - XCTAssertEqual(model.state.counter, 9, "Invalid initial state") + XCTAssertEqual(model.counter, 9, "Invalid initial state") XCTAssertNotIdentical(model.state, model.initialState, "Initial state is NOT A COPY but a reference!") - XCTAssertNotIdentical(model.state.object, model.initialState.object, "Current state has a reference to initial state with possible mutations!") + XCTAssertNotIdentical(model.object, model.initialState.object, "Current state has a reference to initial state with possible mutations!") } @MainActor func testActionMutation() async throws { let model = ObservableModel() - XCTAssertEqual(model.state.counter, 9) + XCTAssertEqual(model.counter, 9) let expectation = XCTestExpectation() Task { await model.send(action: .resetToZero) - XCTAssertEqual(model.state.counter, 0, "Reset to zero failed") + XCTAssertEqual(model.counter, 0, "Reset to zero failed") expectation.fulfill() } - XCTAssertEqual(model.state.counter, 9, "State mutated immediately") + XCTAssertEqual(model.counter, 9, "State mutated immediately") await fulfillment(of: [expectation], timeout: 3) - XCTAssertEqual(model.state.counter, 0, "State did not mutate properly") + XCTAssertEqual(model.counter, 0, "State did not mutate properly") } @MainActor func testLegacyModel() { @@ -63,7 +63,7 @@ final class GoodReactorTests: XCTestCase { model.send(action: .addOne) - XCTAssertEqual(model.state.counter, 10) + XCTAssertEqual(model.counter, 10) wait(for: [expectation], timeout: 3) withExtendedLifetime(cancellable, {}) } @@ -71,7 +71,7 @@ final class GoodReactorTests: XCTestCase { @MainActor func testMultipleRuns() { let model = ObservableModel() let expectation = XCTestExpectation(description: "5 concurrent runs finished at the same time") - XCTAssertEqual(model.state.counter, 9) + XCTAssertEqual(model.counter, 9) Task { await model.send(action: .multipleRuns) // 5 concurrent tasks for 1s @@ -79,13 +79,13 @@ final class GoodReactorTests: XCTestCase { } wait(for: [expectation], timeout: 2) - XCTAssertEqual(model.state.counter, 14) + XCTAssertEqual(model.counter, 14) } @MainActor func testHundredRunsInForLoop() { let model = ObservableModel() let expectation = XCTestExpectation(description: "100 concurrent runs finished at the same time") - XCTAssertEqual(model.state.counter, 9) + XCTAssertEqual(model.counter, 9) Task { await model.send(action: .hundredRuns) // 100 concurrent tasks for 1s @@ -93,14 +93,14 @@ final class GoodReactorTests: XCTestCase { } wait(for: [expectation], timeout: 1.5) - XCTAssertEqual(model.state.counter, 109) + XCTAssertEqual(model.counter, 109) } /// Test 200 tasks running under one event, but added while the first ones were running, as mutations @MainActor func testTwiceHundredRuns() { let model = ObservableModel() let expectation = XCTestExpectation(description: "100+100 concurrent runs finished at the same time") - XCTAssertEqual(model.state.counter, 9) + XCTAssertEqual(model.counter, 9) Task { await model.send(action: .twiceHundredRuns) // 100 + 100 more concurrent tasks for 1s @@ -108,30 +108,30 @@ final class GoodReactorTests: XCTestCase { } wait(for: [expectation], timeout: 1.5) - XCTAssertEqual(model.state.counter, 209) + XCTAssertEqual(model.counter, 209) } @MainActor func testDebounce() async { let model = ObservableModel() - XCTAssertEqual(model.state.counter, 9) + XCTAssertEqual(model.counter, 9) for _ in 0..<10 { await model.send(action: .debounceTest) // send event 10x in a second try? await Task.sleep(for: .milliseconds(100)) } - XCTAssertEqual(model.state.counter, 9) // event should be waiting in debouncer + XCTAssertEqual(model.counter, 9) // event should be waiting in debouncer try? await Task.sleep(for: .seconds(1)) - XCTAssertEqual(model.state.counter, 10) // event should be debounced by now + XCTAssertEqual(model.counter, 10) // event should be debounced by now } @MainActor func testBinding() { let model = ObservableModel() - XCTAssertEqual(model.state.counter, 9) + XCTAssertEqual(model.counter, 9) let binding = model.bind(\.counter, action: { .setCounter($0) })