diff --git a/README.md b/README.md
index c660a58..448ffae 100644
--- a/README.md
+++ b/README.md
@@ -49,10 +49,6 @@ When using a `Store`, the data flows in a single direction.
-When working with UI, it is better to use a `ViewStore` to ensure all operations are performed on the main thread.
-
-
-
## Usage
### Implementing a Reducer
diff --git a/Sources/OneWay/Store.swift b/Sources/OneWay/Store.swift
index fa5046f..cc8e7c8 100644
--- a/Sources/OneWay/Store.swift
+++ b/Sources/OneWay/Store.swift
@@ -104,6 +104,7 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
}
deinit {
+ continuation.finish()
tasks.forEach { $0.value.cancel() }
bindingTask?.cancel()
}
@@ -116,20 +117,23 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
guard !isProcessing else { return }
isProcessing = true
await Task.yield()
- for action in actionQueue {
- #if canImport(OSLog) && DEBUG
- if loggingOptions.contains(.action) {
- let timestamp = Date.now.formatted(iso8601FormatStyle)
- logger.debug("[\(timestamp)] Action: \(String(describing: action))")
- }
- #endif
- let effect = reducer.reduce(state: &state, action: action)
- let isThrottled = await throttleIfNeeded(for: effect)
- if !isThrottled {
- await execute(effect: effect)
+ while !actionQueue.isEmpty {
+ let actions = actionQueue
+ actionQueue.removeAll()
+ for action in actions {
+ #if canImport(OSLog) && DEBUG
+ if loggingOptions.contains(.action) {
+ let timestamp = Date.now.formatted(iso8601FormatStyle)
+ logger.debug("[\(timestamp)] Action: \(String(describing: action))")
+ }
+ #endif
+ let effect = reducer.reduce(state: &state, action: action)
+ let isThrottled = await throttleIfNeeded(for: effect)
+ if !isThrottled {
+ await execute(effect: effect)
+ }
}
}
- actionQueue = []
isProcessing = false
}
diff --git a/Sources/OneWay/ViewStore.swift b/Sources/OneWay/ViewStore.swift
index edcc62f..56dff68 100644
--- a/Sources/OneWay/ViewStore.swift
+++ b/Sources/OneWay/ViewStore.swift
@@ -9,6 +9,12 @@
#if canImport(Combine)
import Combine
#endif
+#if canImport(Foundation)
+import Foundation
+#endif
+#if canImport(OSLog)
+import OSLog
+#endif
/// `ViewStore` is an object that manages state values within the context of the `MainActor`.
///
@@ -23,17 +29,31 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
/// A convenience type alias for referring to a given reducer's state.
public typealias State = R.State
+ private typealias TaskID = UUID
+
/// The initial state of the store.
public let initialState: State
/// The current state of the store.
public private(set) var state: State {
didSet {
- continuation.yield(state)
- states.send(state)
+ if oldValue != state {
+ continuation.yield(state)
+ states.send(state)
#if canImport(Combine)
- objectWillChange.send()
+ objectWillChange.send()
+#endif
+#if canImport(OSLog) && DEBUG
+ if loggingOptions.contains(.state) {
+ let timestamp = Date.now.formatted(iso8601FormatStyle)
+ logger.debug("""
+ [\(timestamp)] State changed:
+ - \(String(describing: oldValue))
+ + \(String(describing: self.state))
+ """)
+ }
#endif
+ }
}
}
@@ -41,10 +61,29 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
///
/// Use this stream to observe state changes.
public let states: AsyncViewStateSequence
+
+ /// A boolean value indicating whether the store is currently idle.
+ ///
+ /// A store is considered idle when it is not processing any actions and there are no pending
+ /// tasks for side effects.
+ public var isIdle: Bool {
+ !isProcessing && tasks.isEmpty
+ }
- private let store: Store
+ private let reducer: R
+ private let clock: C
+#if canImport(OSLog) && DEBUG
+ private let logger = Logger(subsystem: "com.devyeom.oneway", category: "ViewStore")
+#endif
+ private var loggingOptions = LoggingOptions.none
private let continuation: AsyncStream.Continuation
- private var task: Task?
+ private var isProcessing: Bool = false
+ private var actionQueue: [Action] = []
+ private var bindingTask: Task?
+ private var tasks: [TaskID: Task] = [:]
+ private var cancellables: [EffectIDWrapper: Set] = [:]
+ private var throttleTimestamps: [EffectIDWrapper: C.Instant] = [:]
+ private var trailingThrottledEffects: [EffectIDWrapper: AnyEffect] = [:]
/// Initializes a new view store with a reducer, an initial state, and a clock.
///
@@ -61,36 +100,51 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
) {
self.initialState = state
self.state = state
- self.store = Store(
- reducer: reducer(),
- state: state,
- clock: clock
- )
+ self.reducer = reducer()
+ self.clock = clock
let (stream, continuation) = AsyncStream.makeStream()
self.states = AsyncViewStateSequence(stream)
self.continuation = continuation
- self.task = Task { @MainActor [weak self] in
- guard let states = await self?.store.states else { return }
- for await state in states {
- guard let self else { break }
- guard !Task.isCancelled else { break }
- self.state = state
- }
+ Task { @MainActor [weak self] in
+ self?.bindExternalEffect()
+ }
+ defer {
+ continuation.yield(state)
+ states.send(state)
}
}
deinit {
- task?.cancel()
continuation.finish()
+ tasks.forEach { $0.value.cancel() }
+ bindingTask?.cancel()
}
/// Sends an action to the view store.
///
/// - Parameter action: An action defined in the reducer.
public func send(_ action: Action) {
- Task { @MainActor in
- await store.send(action)
+ actionQueue.append(action)
+ guard !isProcessing else { return }
+ isProcessing = true
+ while !actionQueue.isEmpty {
+ let actions = actionQueue
+ actionQueue.removeAll()
+ for action in actions {
+#if canImport(OSLog) && DEBUG
+ if loggingOptions.contains(.action) {
+ let timestamp = Date.now.formatted(iso8601FormatStyle)
+ logger.debug("[\(timestamp)] Action: \(String(describing: action))")
+ }
+#endif
+ let effect = reducer.reduce(state: &state, action: action)
+ let isThrottled = throttleIfNeeded(for: effect)
+ if !isThrottled {
+ execute(effect: effect)
+ }
+ }
}
+ isProcessing = false
}
/// Resets the store by removing all queued actions and effects and re-binding global states.
@@ -98,9 +152,13 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
/// - Note: This is useful when you need to call `bind()` again, as you cannot call `bind()`
/// directly.
public func reset() {
- Task { @MainActor in
- await store.reset()
- }
+ bindExternalEffect()
+ tasks.forEach { $0.value.cancel() }
+ tasks.removeAll()
+ actionQueue.removeAll()
+ cancellables.removeAll()
+ trailingThrottledEffects.removeAll()
+ throttleTimestamps.removeAll()
}
/// Sets the logging options for the store to control what information is logged.
@@ -124,13 +182,119 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
/// - Parameter loggingOptions: A set of `LoggingOptions` that determines what information
/// is logged.
public func debug(_ loggingOptions: LoggingOptions) -> Self {
- Task { @MainActor in
- await store.debug(loggingOptions)
- }
+ self.loggingOptions = loggingOptions
return self
}
+
+ private func throttleIfNeeded(for effect: AnyEffect) -> Bool {
+ guard case let .throttle(id, interval, latest) = effect.method else {
+ return false
+ }
+ let effectID = EffectIDWrapper(id)
+ let now = clock.now
+ if let last = throttleTimestamps[effectID],
+ last.duration(to: now) < interval {
+ if latest {
+ trailingThrottledEffects[effectID] = effect
+ }
+ return true
+ } else {
+ throttleTimestamps[effectID] = now
+ if latest {
+ Task { @MainActor [weak self] in
+ do {
+ try await self?.clock.sleep(for: interval)
+ self?.executeTrailingThrottledEffects(effectID)
+ }
+ catch {
+ self?.removeTrailingThrottledEffects(effectID)
+ }
+ }
+ }
+ return false
+ }
+ }
+
+ private func execute(effect: AnyEffect) {
+ let taskID = TaskID()
+ let task = Task { @MainActor [weak self, taskID] in
+ guard !Task.isCancelled else { return }
+ for await value in effect.values {
+ guard let self else { break }
+ guard !Task.isCancelled else { break }
+ self.send(value)
+ }
+ self?.removeTask(taskID)
+ }
+ tasks[taskID] = task
+
+ switch effect.method {
+ case let .register(id, cancelInFlight):
+ let effectID = EffectIDWrapper(id)
+ if cancelInFlight {
+ let taskIDs = cancellables[effectID, default: []]
+ taskIDs.forEach { removeTask($0) }
+ cancellables.removeValue(forKey: effectID)
+ }
+ cancellables[effectID, default: []].insert(taskID)
+ case let .cancel(id):
+ let effectID = EffectIDWrapper(id)
+ let taskIDs = cancellables[effectID, default: []]
+ taskIDs.forEach { removeTask($0) }
+ cancellables.removeValue(forKey: effectID)
+ case .throttle,
+ .none:
+ break
+ }
+ }
+
+ private func executeTrailingThrottledEffects(_ effectID: EffectIDWrapper) {
+ if let effect = trailingThrottledEffects.removeValue(forKey: effectID) {
+ execute(effect: effect)
+ }
+ }
+
+ private func removeTrailingThrottledEffects(_ effectID: EffectIDWrapper) {
+ trailingThrottledEffects.removeValue(forKey: effectID)
+ }
+
+ private func bindExternalEffect() {
+ let values = reducer.bind().values
+ bindingTask?.cancel()
+ bindingTask = Task { @MainActor [weak self] in
+ for await value in values {
+ guard let self else { break }
+ guard !Task.isCancelled else { break }
+ self.send(value)
+ }
+ }
+ }
+
+ private func removeTask(_ key: UUID) {
+ if let task = tasks.removeValue(forKey: key) {
+ task.cancel()
+ }
+ }
}
+private struct EffectIDWrapper: Hashable, @unchecked Sendable {
+ private let id: AnyHashable
+
+ fileprivate init(_ id: some Hashable & Sendable) {
+ self.id = id
+ }
+}
+
+#if canImport(OSLog) && DEBUG
+private let iso8601FormatStyle = Date.ISO8601FormatStyle()
+ .year()
+ .month()
+ .day()
+ .timeZone(separator: .omitted)
+ .time(includingFractionalSeconds: true)
+ .timeSeparator(.colon)
+#endif
+
#if canImport(Combine)
extension ViewStore: ObservableObject { }
#endif
diff --git a/Sources/OneWayTesting/ViewStore+Testing.swift b/Sources/OneWayTesting/ViewStore+Testing.swift
new file mode 100644
index 0000000..974eab9
--- /dev/null
+++ b/Sources/OneWayTesting/ViewStore+Testing.swift
@@ -0,0 +1,316 @@
+//
+// OneWay
+// The MIT License (MIT)
+//
+// Copyright (c) 2022-2026 Seungyeop Yeom ( https://github.com/DevYeom ).
+//
+#if !os(Linux)
+import OneWay
+
+#if canImport(CoreFoundation)
+import CoreFoundation
+#endif
+
+#if canImport(Testing)
+import Testing
+#endif
+
+#if canImport(XCTest)
+import XCTest
+#endif
+
+#if canImport(Testing)
+extension ViewStore {
+ #if swift(>=6.0)
+ /// Allows the expectation of a certain property value in the store's state. It compares the
+ /// current value of the given `keyPath` in the state with an expected `input` value
+ ///
+ /// The function works asynchronously, waiting for the store to become idle, i.e., when the
+ /// store is not actively processing or updating its state, before performing the comparison.
+ ///
+ /// - Parameters:
+ /// - keyPath: A key path that specifies the property in the `State` to be compared.
+ /// - input: The expected value of the property at the given key path.
+ /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish
+ /// processing before timing out. Defaults to 2 seconds.
+ /// - fileID: The file ID from which the function is called.
+ /// - filePath: The file path from which the function is called.
+ /// - line: The line number from which the function is called.
+ /// - column: The column number from which the function is called.
+ public func expect(
+ _ keyPath: KeyPath & Sendable,
+ _ input: Property,
+ timeout: Double = 2,
+ fileID: StaticString = #fileID,
+ filePath: StaticString = #filePath,
+ line: UInt = #line,
+ column: UInt = #column
+ ) async where Property: Sendable & Equatable {
+ var isTimeout = false
+ let start = CFAbsoluteTimeGetCurrent()
+ await Task.detached(priority: .background) {
+ await Task.yield()
+ }.value
+ await Task.yield()
+ while !isIdle {
+ await Task.detached(priority: .background) {
+ await Task.yield()
+ }.value
+ await Task.yield()
+ let elapsedTime = CFAbsoluteTimeGetCurrent() - start
+ if elapsedTime > timeout {
+ isTimeout = true
+ break
+ }
+ }
+ let result = state[keyPath: keyPath]
+ switch TestingFramework.current {
+ case .xcTest:
+ #if canImport(XCTest)
+ if isTimeout && result != input {
+ XCTFail(
+ "Timeout exceeded \(timeout) seconds: received \(input), expected \(result)",
+ file: filePath,
+ line: line
+ )
+ } else {
+ XCTAssertEqual(
+ result,
+ input,
+ file: filePath,
+ line: line
+ )
+ }
+ #else
+ break
+ #endif
+ case .testing:
+ if isTimeout && result != input {
+ Issue.record(
+ "Timeout exceeded \(timeout) seconds: received \(input), expected \(result)",
+ sourceLocation: Testing.SourceLocation(
+ fileID: String(describing: fileID),
+ filePath: String(describing: filePath),
+ line: Int(line),
+ column: Int(column)
+ )
+ )
+ } else {
+ #expect(
+ result == input,
+ sourceLocation: Testing.SourceLocation(
+ fileID: String(describing: fileID),
+ filePath: String(describing: filePath),
+ line: Int(line),
+ column: Int(column)
+ )
+ )
+ }
+ }
+ }
+ #else
+ /// Allows the expectation of a certain property value in the store's state. It compares the
+ /// current value of the given `keyPath` in the state with an expected `input` value
+ ///
+ /// The function works asynchronously, waiting for the store to become idle, i.e., when the
+ /// store is not actively processing or updating its state, before performing the comparison.
+ ///
+ /// - Parameters:
+ /// - keyPath: A key path that specifies the property in the `State` to be compared.
+ /// - input: The expected value of the property at the given key path.
+ /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish
+ /// processing before timing out. Defaults to 2 seconds.
+ /// - fileID: The file ID from which the function is called.
+ /// - filePath: The file path from which the function is called.
+ /// - line: The line number from which the function is called.
+ /// - column: The column number from which the function is called.
+ public func expect(
+ _ keyPath: KeyPath,
+ _ input: Property,
+ timeout: Double = 2,
+ fileID: StaticString = #fileID,
+ filePath: StaticString = #filePath,
+ line: UInt = #line,
+ column: UInt = #column
+ ) async where Property: Sendable & Equatable {
+ var isTimeout = false
+ let start = CFAbsoluteTimeGetCurrent()
+ await Task.detached(priority: .background) {
+ await Task.yield()
+ }.value
+ await Task.yield()
+ while !isIdle {
+ await Task.detached(priority: .background) {
+ await Task.yield()
+ }.value
+ await Task.yield()
+ let elapsedTime = CFAbsoluteTimeGetCurrent() - start
+ if elapsedTime > timeout {
+ isTimeout = true
+ break
+ }
+ }
+ let result = state[keyPath: keyPath]
+ switch TestingFramework.current {
+ case .xcTest:
+ #if canImport(XCTest)
+ if isTimeout && result != input {
+ XCTFail(
+ "Timeout exceeded \(timeout) seconds: received \(input), expected \(result)",
+ file: filePath,
+ line: line
+ )
+ } else {
+ XCTAssertEqual(
+ result,
+ input,
+ file: filePath,
+ line: line
+ )
+ }
+ #else
+ break
+ #endif
+ case .testing:
+ if isTimeout && result != input {
+ Issue.record(
+ "Timeout exceeded \(timeout) seconds: received \(input), expected \(result)",
+ sourceLocation: Testing.SourceLocation(
+ fileID: String(describing: fileID),
+ filePath: String(describing: filePath),
+ line: Int(line),
+ column: Int(column)
+ )
+ )
+ } else {
+ #expect(
+ result == input,
+ sourceLocation: Testing.SourceLocation(
+ fileID: String(describing: fileID),
+ filePath: String(describing: filePath),
+ line: Int(line),
+ column: Int(column)
+ )
+ )
+ }
+ }
+ }
+ #endif
+}
+#endif
+
+#if !canImport(Testing) && canImport(XCTest)
+extension ViewStore {
+ #if swift(>=6.0)
+ /// Allows the expectation of a certain property value in the store's state. It compares the
+ /// current value of the given `keyPath` in the state with an expected `input` value
+ ///
+ /// The function works asynchronously, waiting for the store to become idle, i.e., when the
+ /// store is not actively processing or updating its state, before performing the comparison.
+ ///
+ /// - Parameters:
+ /// - keyPath: A key path that specifies the property in the `State` to be compared.
+ /// - input: The expected value of the property at the given key path.
+ /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish
+ /// processing before timing out. Defaults to 2 seconds.
+ /// - file: The file path from which the function is called.
+ /// - line: The line number from which the function is called.
+ public func expect(
+ _ keyPath: KeyPath & Sendable,
+ _ input: Property,
+ timeout: Double = 2,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) async where Property: Sendable & Equatable {
+ var isTimeout = false
+ let start = CFAbsoluteTimeGetCurrent()
+ await Task.detached(priority: .background) {
+ await Task.yield()
+ }.value
+ await Task.yield()
+ while !isIdle {
+ await Task.detached(priority: .background) {
+ await Task.yield()
+ }.value
+ await Task.yield()
+ let elapsedTime = CFAbsoluteTimeGetCurrent() - start
+ if elapsedTime > timeout {
+ isTimeout = true
+ break
+ }
+ }
+ let result = state[keyPath: keyPath]
+ if isTimeout && result != input {
+ XCTFail(
+ "Exceeded timeout of \(timeout) seconds",
+ file: file,
+ line: line
+ )
+ } else {
+ XCTAssertEqual(
+ result,
+ input,
+ file: file,
+ line: line
+ )
+ }
+ }
+ #else
+ /// Allows the expectation of a certain property value in the store's state. It compares the
+ /// current value of the given `keyPath` in the state with an expected `input` value
+ ///
+ /// The function works asynchronously, waiting for the store to become idle, i.e., when the
+ /// store is not actively processing or updating its state, before performing the comparison.
+ ///
+ /// - Parameters:
+ /// - keyPath: A key path that specifies the property in the `State` to be compared.
+ /// - input: The expected value of the property at the given key path.
+ /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish
+ /// processing before timing out. Defaults to 2 seconds.
+ /// - file: The file path from which the function is called.
+ /// - line: The line number from which the function is called.
+ public func expect(
+ _ keyPath: KeyPath,
+ _ input: Property,
+ timeout: Double = 2,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) async where Property: Sendable & Equatable {
+ var isTimeout = false
+ let start = CFAbsoluteTimeGetCurrent()
+ await Task.detached(priority: .background) {
+ await Task.yield()
+ }.value
+ await Task.yield()
+ while !isIdle {
+ await Task.detached(priority: .background) {
+ await Task.yield()
+ }.value
+ await Task.yield()
+ let elapsedTime = CFAbsoluteTimeGetCurrent() - start
+ if elapsedTime > timeout {
+ isTimeout = true
+ break
+ }
+ }
+ let result = state[keyPath: keyPath]
+ if isTimeout && result != input {
+ XCTFail(
+ "Exceeded timeout of \(timeout) seconds",
+ file: file,
+ line: line
+ )
+ } else {
+ XCTAssertEqual(
+ result,
+ input,
+ file: file,
+ line: line
+ )
+ }
+ }
+ #endif
+}
+#endif
+
+#endif
diff --git a/Tests/OneWayTests/ViewStoreTests.swift b/Tests/OneWayTests/ViewStoreTests.swift
index 84d414d..07eaeae 100644
--- a/Tests/OneWayTests/ViewStoreTests.swift
+++ b/Tests/OneWayTests/ViewStoreTests.swift
@@ -6,27 +6,40 @@
//
import Testing
+#if canImport(Combine)
+import Combine
+#endif
+import Clocks
import OneWay
+#if canImport(CoreFoundation)
+import CoreFoundation
+#endif
#if !os(Linux)
@MainActor
struct ViewStoreTests {
- private var sut: ViewStore!
+ private var sut: ViewStore>!
+ private var clock: TestClock!
init() {
+ let clock = TestClock()
+ self.clock = clock
sut = ViewStore(
- reducer: TestReducer(),
- state: TestReducer.State(count: 0)
+ reducer: TestReducer(clock: clock),
+ state: TestReducer.State(count: 0, text: ""),
+ clock: clock
)
}
@Test
func initialState() async {
- #expect(self.sut.initialState == TestReducer.State(count: 0))
+ #expect(self.sut.initialState == TestReducer.State(count: 0, text: ""))
#expect(self.sut.state.count == 0)
+ #expect(self.sut.state.text == "")
for await state in sut.states {
#expect(state.count == 0)
+ #expect(state.text == "")
break
}
}
@@ -73,6 +86,8 @@ struct ViewStoreTests {
}
}
+ await Task.yield() // Allow observer tasks to start
+
sut.send(.setTriggeredCount(10))
sut.send(.setTriggeredCount(10))
sut.send(.setTriggeredCount(10))
@@ -89,8 +104,7 @@ struct ViewStoreTests {
expectedTriggeredCounts: [Int],
timeout: Duration = .seconds(1)
) async {
- let clock = ContinuousClock()
- let deadline = clock.now + timeout
+ let deadline = clock.now.advanced(by: timeout)
while clock.now < deadline {
let counts = await result.counts
let triggeredCounts = await result.triggeredCounts
@@ -129,6 +143,8 @@ struct ViewStoreTests {
await result.appendIgnoredCount(ignoredCount)
}
}
+
+ await Task.yield()
sut.send(.setIgnoredCount(10))
sut.send(.setIgnoredCount(20))
@@ -147,8 +163,7 @@ struct ViewStoreTests {
expectedIgnoredCounts: [Int],
timeout: Duration = .seconds(1)
) async {
- let clock = ContinuousClock()
- let deadline = clock.now + timeout
+ let deadline = clock.now.advanced(by: timeout)
while clock.now < deadline {
let counts = await result.counts
let ignoredCounts = await result.ignoredCounts
@@ -200,7 +215,7 @@ struct ViewStoreTests {
}
}
- try! await Task.sleep(for: .milliseconds(10))
+ try! await Task.sleep(for: .milliseconds(100))
sut.send(.concat)
await result.waitForCompletion(timeout: 1)
@@ -219,7 +234,7 @@ struct ViewStoreTests {
@Test
func logging_options() {
let _ = ViewStore(
- reducer: TestReducer(),
+ reducer: TestReducer(clock: TestClock()),
state: TestReducer.State(count: 0)
)
.debug(.all)
@@ -227,12 +242,254 @@ struct ViewStoreTests {
.debug(.action)
.debug(.state)
}
+
+ @Test
+ func lotsOfActions() async {
+ let iterations: Int = 100_000
+ sut.send(.incrementMany)
+ await sut.expect(\.count, iterations, timeout: 10)
+ }
+
+ @Test
+ func threadSafeSendingActions() async {
+ let iterations: Int = 100_000
+ let sut = sut!
+ for _ in 0 ..< iterations {
+ Task.detached {
+ await sut.send(.increment)
+ }
+ }
+
+ await sut.expect(\.count, iterations)
+ }
+
+ @Test
+ func asyncAction() async {
+ sut.send(.request)
+ await sut.expect(\.text, "Success")
+ }
+
+ #if canImport(Combine)
+ @Test
+ func bind() async {
+ let sut = ViewStore(
+ reducer: BindTestReducer(),
+ state: BindTestReducer.State(text: "")
+ )
+ var result: Set = []
+
+ Task {
+ try! await Task.sleep(for: .milliseconds(1))
+ testPublisher.text.send("first")
+ testPublisher.number.send(1)
+ testPublisher.text.send("second")
+ testPublisher.number.send(2)
+ }
+
+ let states = sut.states
+ for await state in states {
+ result.insert(state.text)
+ if result.count > 4 { break }
+ }
+
+ #expect(result == ["", "first", "1", "second", "2"])
+ }
+ #endif
+
+ @Test
+ func removeDuplicates() async {
+ sut.send(.response("First"))
+ sut.send(.response("First"))
+ sut.send(.response("First"))
+ sut.send(.response("Second"))
+ sut.send(.response("Second"))
+ sut.send(.response("Third"))
+
+ var result: [String] = []
+ let states = sut.states
+ for await state in states {
+ result.append(state.text)
+ if result.count > 3 {
+ break
+ }
+ }
+
+ #expect(result == ["", "First", "Second", "Third"])
+ }
+
+ @Test
+ func cancel() async {
+ do {
+ let before = sut.state.text
+ #expect(before == "")
+
+ sut.send(.longTimeTask)
+ await Task.yield()
+ await clock.advance(by: .seconds(200 + 1))
+
+ await sut.expect(\.text, "Success")
+ }
+
+ sut.send(.response(""))
+ await sut.expect(\.text, "")
+
+ do {
+ sut.send(.longTimeTask)
+ await Task.yield()
+ await clock.advance(by: .seconds(100))
+ await Task.yield()
+
+ sut.send(.cancelLongTimeTask)
+ await Task.yield()
+ await clock.advance(by: .seconds(100))
+ await Task.yield()
+
+ let text = sut.state.text
+ #expect(text == "")
+ }
+ }
+
+ @Test
+ func debounce() async {
+ for _ in 0..<5 {
+ await clock.advance(by: .seconds(10))
+ sut.send(.debouncedIncrement)
+ }
+ await clock.advance(by: .seconds(100))
+ for _ in 0..<5 {
+ await clock.advance(by: .seconds(10))
+ sut.send(.debouncedIncrement)
+ }
+ await clock.advance(by: .seconds(100))
+
+ await sut.expect(\.count, 2)
+
+ for _ in 0..<5 {
+ await clock.advance(by: .seconds(10))
+ sut.send(.debouncedIncrement)
+ }
+ await clock.advance(by: .seconds(10)) // 10s < 100s
+
+ await sut.expect(\.count, 2)
+ }
+
+ @Test
+ func deboouncedSequence() async {
+ for _ in 0..<5 {
+ await clock.advance(by: .seconds(10))
+ sut.send(.debouncedSequence)
+ }
+ await clock.advance(by: .seconds(100))
+ for _ in 0..<5 {
+ await clock.advance(by: .seconds(10))
+ sut.send(.debouncedSequence)
+ }
+ await clock.advance(by: .seconds(100))
+
+ await sut.expect(\.count, 10)
+
+ for _ in 0..<5 {
+ await clock.advance(by: .seconds(10))
+ sut.send(.debouncedSequence)
+ }
+ await clock.advance(by: .seconds(10)) // 10s < 100s
+
+ await sut.expect(\.count, 10)
+ }
+
+ @Test
+ func throttle() async {
+ sut.send(.throttledIncrement)
+ sut.send(.throttledIncrement)
+ await clock.advance(by: .seconds(10))
+ sut.send(.throttledIncrement)
+ await sut.expect(\.count, 1)
+
+ await clock.advance(by: .seconds(100))
+ await sut.expect(\.count, 1)
+
+ sut.send(.throttledIncrement)
+ await sut.expect(\.count, 2)
+ }
+
+ @Test
+ func throttle_latest() async {
+ sut.send(.throttledIncrementLatest)
+ await sut.expect(\.count, 1)
+
+ sut.send(.throttledIncrementLatest)
+ await sut.expect(\.count, 1)
+
+ await clock.advance(by: .seconds(100))
+ await sut.expect(\.count, 2)
+
+ sut.send(.throttledIncrementLatest)
+ await clock.advance(by: .seconds(10))
+ sut.send(.throttledIncrementLatest)
+ await sut.expect(\.count, 3)
+
+ await clock.advance(by: .seconds(100))
+ await sut.expect(\.count, 4)
+ }
+}
+
+
+
+#if canImport(Combine)
+/// Just for testing
+private struct TestPublisher: @unchecked Sendable {
+ let text = PassthroughSubject()
+ let number = PassthroughSubject()
}
+private let testPublisher = TestPublisher()
+
+private struct BindTestReducer: Reducer {
+ enum Action: Sendable {
+ case response(String)
+ }
+
+ struct State: Equatable {
+ var text: String
+ }
+
+ func reduce(state: inout State, action: Action) -> AnyEffect {
+ switch action {
+ case .response(let response):
+ state.text = response
+ return .none
+ }
+ }
+
+ func bind() -> AnyEffect {
+ return .merge(
+ .sequence { send in
+ for await text in testPublisher.text.stream {
+ send(Action.response(text))
+ }
+ },
+ .sequence { send in
+ for await number in testPublisher.number.stream {
+ send(Action.response(String(number)))
+ }
+ }
+ )
+ }
+}
+#endif
private struct TestReducer: Reducer {
enum Action: Sendable {
case increment
+ case incrementMany
case twice
+ case request
+ case response(String)
+ case longTimeTask
+ case cancelLongTimeTask
+ case debouncedIncrement
+ case debouncedSequence
+ case throttledIncrement
+ case throttledIncrementLatest
case concat
case setCount(Int)
case setTriggeredCount(Int)
@@ -241,22 +498,90 @@ private struct TestReducer: Reducer {
struct State: Equatable {
var count: Int
+ var text: String = ""
@Triggered var triggeredCount: Int = 0
@Ignored var ignoredCount: Int = 0
}
+ private enum EffectID: Hashable {
+ case longTimeTask
+ }
+
+ private let clock: TestClock?
+
+ init(clock: TestClock? = nil) {
+ self.clock = clock
+ }
+
+ enum Debounce {
+ case increment
+ case incrementSequence
+ }
+
+ enum Throttle {
+ case increment
+ case incrementLatest
+ }
+
func reduce(state: inout State, action: Action) -> AnyEffect {
switch action {
case .increment:
state.count += 1
return .none
+ case .incrementMany:
+ state.count += 1
+ return state.count >= 100_000 ? .none : .just(.incrementMany)
+
case .twice:
return .merge(
.just(.increment),
.just(.increment)
)
+ case .request:
+ return .single {
+ return Action.response("Success")
+ }
+
+ case .response(let response):
+ state.text = response
+ return .none
+
+ case .longTimeTask:
+ return .single {
+ try? await clock?.sleep(for: .seconds(200))
+ return Action.response("Success")
+ }
+ .cancellable(EffectID.longTimeTask)
+
+ case .cancelLongTimeTask:
+ return .cancel(EffectID.longTimeTask)
+
+ case .debouncedIncrement:
+ guard let clock = clock else { return .none }
+ return .just(.increment)
+ .debounce(id: Debounce.increment, for: .seconds(100), clock: clock)
+
+ case .debouncedSequence:
+ guard let clock = clock else { return .none }
+ return .sequence { send in
+ send(.increment)
+ send(.increment)
+ send(.increment)
+ send(.increment)
+ send(.increment)
+ }
+ .debounce(id: Debounce.incrementSequence, for: .seconds(100), clock: clock)
+
+ case .throttledIncrement:
+ return .just(.increment)
+ .throttle(id: Throttle.increment, for: .seconds(100))
+
+ case .throttledIncrementLatest:
+ return .just(.increment)
+ .throttle(id: Throttle.incrementLatest, for: .seconds(100), latest: true)
+
case .concat:
return .concat(
.just(.increment),