From ccfc5457e5b2586bc7472b27707e091179d4f536 Mon Sep 17 00:00:00 2001 From: Yasuhiro Inami Date: Tue, 18 Jun 2019 00:57:25 +0900 Subject: [PATCH] Add `Effect.until` for cancellation --- Sources/Automaton.swift | 14 +- Sources/Effect.swift | 30 +++- Sources/Mapping+Helper.swift | 2 +- .../EffectCancellationSpec.swift | 141 +++++++++++++++--- 4 files changed, 160 insertions(+), 27 deletions(-) diff --git a/Sources/Automaton.swift b/Sources/Automaton.swift index e12e1fc..b714974 100644 --- a/Sources/Automaton.swift +++ b/Sources/Automaton.swift @@ -9,7 +9,7 @@ public final class Automaton /// Transducer (input & output) mapping with `Effect` (additional effect) as output, /// which may emit next input values for continuous state-transitions. - public typealias EffectMapping = (State, Input) -> (State, Effect?)? + public typealias EffectMapping = (State, Input) -> (State, Effect?)? where Queue: EffectQueueProtocol /// `Reply` signal that notifies either `.success` or `.failure` of state-transition on every input. @@ -37,7 +37,7 @@ public final class Automaton self.init( state: initialState, inputs: inputSignal, - mapping: { mapping($0, $1).map { ($0, Effect?.none) } } + mapping: { mapping($0, $1).map { ($0, Effect?.none) } } ) } @@ -50,7 +50,7 @@ public final class Automaton /// - mapping: `EffectMapping` that designates next state and also generates additional effect. public convenience init( state initialState: State, - effect initialEffect: Effect? = nil, + effect initialEffect: Effect? = nil, inputs inputSignal: Signal, mapping: @escaping EffectMapping ) where Queue: EffectQueueProtocol @@ -75,7 +75,7 @@ public final class Automaton } var effects = mapped - .filterMap { _, _, mapped -> Effect? in + .filterMap { _, _, mapped -> Effect? in guard case let .some(_, effect) = mapped else { return nil } return effect } @@ -89,7 +89,11 @@ public final class Automaton EffectQueue.allCases.map { queue in effects .filter { $0.queue == queue } - .flatMap(queue.flattenStrategy) { $0.producer } + .flatMap(queue.flattenStrategy) { effect -> SignalProducer in + /// - Note: Cancellation will be triggered regardless of state-transition success or failure. + let until = from.filterMap { effect.until($0, $1) ? () : nil } + return effect.producer.take(until: until) + } } ) diff --git a/Sources/Effect.swift b/Sources/Effect.swift index 99ba578..f301247 100644 --- a/Sources/Effect.swift +++ b/Sources/Effect.swift @@ -2,7 +2,7 @@ import ReactiveSwift /// Managed side-effect that enqueues `producer` on `EffectQueue` /// to perform arbitrary `Queue.flattenStrategy`. -public struct Effect where Queue: EffectQueueProtocol +public struct Effect where Queue: EffectQueueProtocol { /// "Cold" stream that runs side-effect and sends next `Input`. public let producer: SignalProducer @@ -10,13 +10,37 @@ public struct Effect where Queue: EffectQueueProtocol /// Effect queue that associates with `producer` to perform various `flattenStrategy`s. internal let queue: EffectQueue - /// - Parameter queue: Uses custom queue, or set `nil` as default queue to use `merge` strategy. + /// `(input, fromState)` predicate for running `producer` cancellation. + /// - Note: Cancellation will be triggered regardless of state-transition success or failure. + internal let until: (Input, State) -> Bool + + /// - Parameters: + /// - queue: Uses custom queue, or set `nil` as default queue to use `merge` strategy. + /// - until: `(input, fromState)` predicate for running `producer` cancellation. public init( _ producer: SignalProducer, - queue: Queue? = nil + queue: Queue? = nil, + until: @escaping (Input, State) -> Bool = { _, _ in false } ) { self.producer = producer self.queue = queue.map(EffectQueue.custom) ?? .default + self.until = until + } +} + +extension Effect where Input: Equatable +{ + public init( + _ producer: SignalProducer, + queue: Queue? = nil, + until input: Input + ) + { + self.init( + producer, + queue: queue, + until: { i, _ in i == input } + ) } } diff --git a/Sources/Mapping+Helper.swift b/Sources/Mapping+Helper.swift index ba196ff..0864c36 100644 --- a/Sources/Mapping+Helper.swift +++ b/Sources/Mapping+Helper.swift @@ -89,7 +89,7 @@ public func | ( public func | ( mapping: @escaping Automaton.Mapping, - effect: Effect? + effect: Effect? ) -> Automaton.EffectMapping { return { fromState, input in diff --git a/Tests/ReactiveAutomatonTests/EffectCancellationSpec.swift b/Tests/ReactiveAutomatonTests/EffectCancellationSpec.swift index d63c3a5..ec7dcb8 100644 --- a/Tests/ReactiveAutomatonTests/EffectCancellationSpec.swift +++ b/Tests/ReactiveAutomatonTests/EffectCancellationSpec.swift @@ -7,20 +7,18 @@ class EffectCancellationSpec: QuickSpec { override func spec() { - typealias Automaton = ReactiveAutomaton.Automaton - typealias EffectMapping = Automaton.EffectMapping - - let (signal, observer) = Signal.pipe() - var automaton: Automaton? - var lastReply: Reply? - var testScheduler: TestScheduler! - var isEffectDetected: Bool = false - - // WARNING: - // Handling `Lifetime` is actually a side-effect that should not run inside `EffectMapping`. - // So, this test is actually not a good example of handling cancellation. describe("Cancellation using Lifetime") { + typealias State = _State + typealias Automaton = ReactiveAutomaton.Automaton + typealias EffectMapping = Automaton.EffectMapping + + let (signal, observer) = Signal.pipe() + var automaton: Automaton? + var lastReply: Reply? + var testScheduler: TestScheduler! + var isEffectDetected: Bool = false + beforeEach { testScheduler = TestScheduler() isEffectDetected = false @@ -33,9 +31,6 @@ class EffectCancellationSpec: QuickSpec let mapping: EffectMapping = { fromState, input in switch (fromState.status, input) { case (.idle, .userAction(.request)): - // WARNING: - // Handling `Lifetime` is actually a side-effect that should not run inside `EffectMapping`. - // So, this test is actually not a good example of handling cancellation. let (lifetime, token) = Lifetime.make() let toState = fromState.with { $0.status = .requesting(token) @@ -121,6 +116,113 @@ class EffectCancellationSpec: QuickSpec } + describe("Cancellation using Effect.until") { + + typealias State = _State<_Void> + typealias Automaton = ReactiveAutomaton.Automaton + typealias EffectMapping = Automaton.EffectMapping + + let (signal, observer) = Signal.pipe() + var automaton: Automaton? + var lastReply: Reply? + var testScheduler: TestScheduler! + var isEffectDetected: Bool = false + + beforeEach { + testScheduler = TestScheduler() + isEffectDetected = false + + /// Sends `.loginOK` after delay, simulating async work during `.loggingIn`. + let requestOKProducer = + SignalProducer(value: .requestOK) + .delay(1, on: testScheduler) + + let mapping: EffectMapping = { fromState, input in + switch (fromState.status, input) { + case (.idle, .userAction(.request)): + let toState = fromState.with { + $0.status = .requesting(_Void()) + } + let effect = requestOKProducer + .on(value: { _ in + isEffectDetected = true + }) + return (toState, Effect(effect, until: .userAction(.cancel))) + + case (.requesting, .userAction(.cancel)): + let toState = fromState.with { + $0.status = .idle + } + return (toState, nil) + + case (.requesting, .requestOK): + let toState = fromState.with { + $0.status = .idle + } + return (toState, nil) + + default: + return nil + } + } + + automaton = Automaton(state: State(), inputs: signal, mapping: mapping) + + _ = automaton?.replies.observeValues { reply in + lastReply = reply + } + + lastReply = nil + } + + it("request success") { + expect(automaton?.state.value.status) == .idle + expect(lastReply).to(beNil()) + + observer.send(value: .userAction(.request)) + + expect(lastReply?.input) == .userAction(.request) + expect(lastReply?.fromState.status) == .idle + expect(lastReply?.toState?.status.requesting).toNot(beNil()) + expect(automaton?.state.value.status.requesting).toNot(beNil()) + + // `loginOKProducer` will automatically send `.loginOK` + testScheduler.advance(by: .seconds(2)) + + expect(lastReply?.input) == .requestOK + expect(lastReply?.fromState.status.requesting).toNot(beNil()) + expect(lastReply?.toState?.status) == .idle + expect(automaton?.state.value.status) == .idle + expect(isEffectDetected) == true + } + + it("request cancelled") { + expect(automaton?.state.value.status) == .idle + expect(lastReply).to(beNil()) + + observer.send(value: .userAction(.request)) + + expect(lastReply?.input) == .userAction(.request) + expect(lastReply?.fromState.status) == .idle + expect(lastReply?.toState?.status.requesting).toNot(beNil()) + expect(automaton?.state.value.status.requesting).toNot(beNil()) + + // `loginOKProducer` will automatically send `.loginOK` + observer.send(value: .userAction(.cancel)) + + expect(lastReply?.input) == .userAction(.cancel) + expect(lastReply?.fromState.status.requesting).toNot(beNil()) + expect(lastReply?.toState?.status) == .idle + expect(automaton?.state.value.status) == .idle + + lastReply = nil // clear `lastReply` to not retain `Lifetime.Token` + testScheduler.advance(by: .seconds(2)) + + expect(isEffectDetected) == false + } + + } + } } @@ -138,18 +240,21 @@ private enum Input: Equatable } } -private struct State: With, Equatable +private struct _State: With, Equatable + where Requesting: Equatable { var status: Status = .idle enum Status: Equatable { case idle - case requesting(Lifetime.Token) + case requesting(Requesting) - var requesting: Lifetime.Token? + var requesting: Requesting? { guard case let .requesting(value) = self else { return nil } return value } } } + +private struct _Void: Equatable {}