From 34a59f7cbd28218cc959dff6b47ef89636672379 Mon Sep 17 00:00:00 2001 From: Seungyeop Yeom Date: Wed, 3 Jun 2026 14:11:43 +0900 Subject: [PATCH] Refactor ViewStore to process actions directly on MainActor --- README.md | 4 - Sources/OneWay/Store.swift | 28 +- Sources/OneWay/ViewStore.swift | 216 +++++++++-- Sources/OneWayTesting/ViewStore+Testing.swift | 316 ++++++++++++++++ Tests/OneWayTests/ViewStoreTests.swift | 345 +++++++++++++++++- 5 files changed, 857 insertions(+), 52 deletions(-) create mode 100644 Sources/OneWayTesting/ViewStore+Testing.swift 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. flow_description_1 -When working with UI, it is better to use a `ViewStore` to ensure all operations are performed on the main thread. - -flow_description_1 - ## 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),