From ec7bc7aba64b289f1cb4f03dcecd78af581de4dd Mon Sep 17 00:00:00 2001 From: Christer van der Meeren Date: Mon, 3 Apr 2017 10:17:47 +0200 Subject: [PATCH 01/10] Make store members protected and virtual --- src/Redux/Store.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Redux/Store.cs b/src/Redux/Store.cs index ebc1a46..748e746 100644 --- a/src/Redux/Store.cs +++ b/src/Redux/Store.cs @@ -32,7 +32,7 @@ public event Action StateChanged } } - public object Dispatch(object action) + public virtual object Dispatch(object action) { return _dispatcher(action); } @@ -42,7 +42,7 @@ public TState GetState() return _lastState; } - private Dispatcher ApplyMiddlewares(params Middleware[] middlewares) + protected virtual Dispatcher ApplyMiddlewares(params Middleware[] middlewares) { Dispatcher dispatcher = InnerDispatch; foreach (var middleware in middlewares) @@ -52,7 +52,7 @@ private Dispatcher ApplyMiddlewares(params Middleware[] middlewares) return dispatcher; } - private object InnerDispatch(object action) + protected virtual object InnerDispatch(object action) { lock (_syncRoot) { From 4f8f53c7f09dd633940373ee70295e12637d35ea Mon Sep 17 00:00:00 2001 From: Christer van der Meeren Date: Mon, 3 Apr 2017 11:58:49 +0200 Subject: [PATCH 02/10] Add action-observable store and awaitable store with awaitable async saga support --- src/Redux.Tests/AwaitableStoreTests.cs | 150 +++++++++++++++++++++++++ src/Redux.Tests/Redux.Tests.csproj | 1 + src/Redux/AwaitableStore.cs | 105 +++++++++++++++++ src/Redux/Redux.csproj | 1 + 4 files changed, 257 insertions(+) create mode 100644 src/Redux.Tests/AwaitableStoreTests.cs create mode 100644 src/Redux/AwaitableStore.cs diff --git a/src/Redux.Tests/AwaitableStoreTests.cs b/src/Redux.Tests/AwaitableStoreTests.cs new file mode 100644 index 0000000..a103222 --- /dev/null +++ b/src/Redux.Tests/AwaitableStoreTests.cs @@ -0,0 +1,150 @@ +namespace Redux.Tests +{ + using System.Reactive.Linq; + using System.Threading.Tasks; + + using NUnit.Framework; + + public class AwaitableStoreTests + { + private class IncrementAction + { + } + + private class IncrementAsyncAction + { + } + + private static int Reducer(int state, object action) + { + switch (action) + { + case IncrementAction _: return state + 1; + default: return state; + } + } + + private async Task DelayedIncrementSaga(IncrementAsyncAction action, IStore store) + { + await Task.Delay(1000); + store.Dispatch(new IncrementAction()); + } + + private void ImmediateIncrementSaga(IncrementAsyncAction action, IStore store) + { + store.Dispatch(new IncrementAction()); + } + + [Test] + public async Task When_AwaitingDispatch_Should_GetUpdatedState() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + + // Act + await awaitableStore.DispatchAsync(new IncrementAsyncAction()); + + // Assert + Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + } + + [Test] + public void When_NotAwaitingDispatchAsync_Should_GetOldState() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + + // Act + awaitableStore.DispatchAsync(new IncrementAsyncAction()); + + // Assert + Assert.That(awaitableStore.GetState(), Is.EqualTo(0)); + } + + [Test] + public void When_NormalDispatch_Should_GetOldState() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + + // Act + awaitableStore.Dispatch(new IncrementAsyncAction()); + + // Assert + Assert.That(awaitableStore.GetState(), Is.EqualTo(0)); + } + + [Test] + public void When_SagaNotInvoked_Should_GetNewStateWithNonAwaitedDispatchAsync() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + + // Act + awaitableStore.Dispatch(new IncrementAction()); + + // Assert + Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + } + + [Test] + public void When_SagaNotInvoked_Should_GetNewStateWithNormalDispatch() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + + // Act + awaitableStore.Dispatch(new IncrementAction()); + + // Assert + Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + } + + [Test] + public void When_UsingNonAsyncSaga_Should_GetNewStateWithNormalDispatch() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ImmediateIncrementSaga); + + // Act + awaitableStore.Dispatch(new IncrementAction()); + + // Assert + Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + } + + [Test] + public void When_UsingNonAsyncSaga_Should_GetNewStateNonAwaitedAsyncDispatch() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ImmediateIncrementSaga); + + // Act + awaitableStore.DispatchAsync(new IncrementAction()); + + // Assert + Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + } + + [Test] + public async Task When_UsingNonAsyncSaga_Should_GetNewStateAwaitedAsyncDispatch() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ImmediateIncrementSaga); + + // Act + await awaitableStore.DispatchAsync(new IncrementAction()); + + // Assert + Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + } + } +} \ No newline at end of file diff --git a/src/Redux.Tests/Redux.Tests.csproj b/src/Redux.Tests/Redux.Tests.csproj index 7cd1ad1..b0f933f 100644 --- a/src/Redux.Tests/Redux.Tests.csproj +++ b/src/Redux.Tests/Redux.Tests.csproj @@ -82,6 +82,7 @@ + diff --git a/src/Redux/AwaitableStore.cs b/src/Redux/AwaitableStore.cs new file mode 100644 index 0000000..a4beb54 --- /dev/null +++ b/src/Redux/AwaitableStore.cs @@ -0,0 +1,105 @@ +namespace Redux +{ + using System; + using System.Reactive.Linq; + using System.Reactive.Subjects; + using System.Threading.Tasks; + + public delegate void Saga(TAction action, IStore store); + + public delegate Task AsyncSaga(TAction action, IStore store); + + public static class ObservableExtensions + { + public static void RunsSaga( + this IObservable source, + IStore store, + Saga saga) + { + source.Subscribe(action => saga(action, store)); + } + + public static void RunsAsyncSaga( + this IObservable source, + AwaitableStore store, + AsyncSaga saga) + { + source.Subscribe( + async action => + { + // TODO: Find a way to call AddOperation and RemoveOperation below + // without specifying concrete class AwaitableStore above, but also + // without giving devs access to AddOperation and RemoveOperation + store.AddOperation(); + await saga(action, (IStore)store); + store.RemoveOperation(); + }); + } + } + + public interface IObservableActionStore + { + IObservable Actions { get; } + } + + public class ObservableActionStore : Store, IObservableActionStore + { + private readonly ISubject actionsSubject = new Subject(); + + /// + public ObservableActionStore( + Reducer reducer, + TState initialState = default(TState), + params Middleware[] middlewares) : base(reducer, initialState, middlewares) + { + } + + public IObservable Actions => this.actionsSubject; + + protected override object InnerDispatch(object action) + { + object ret = base.InnerDispatch(action); + this.actionsSubject.OnNext(action); + return ret; + } + } + + public interface IAwaitableStore + { + + Task DispatchAsync(object action); + } + + public class AwaitableStore : ObservableActionStore, IAwaitableStore + { + private int numOperations; + private readonly ISubject numOperationsSubject = new BehaviorSubject(0); // TODO: start with 0 + + /// + public AwaitableStore( + Reducer reducer, + TState initialState = default(TState), + params Middleware[] middlewares) : base(reducer, initialState, middlewares) + { + } + + private IObservable OngoingOperations => this.numOperationsSubject; + + internal void AddOperation() + { + this.numOperationsSubject.OnNext(++this.numOperations); + } + + internal void RemoveOperation() + { + this.numOperationsSubject.OnNext(--this.numOperations); + } + + public async Task DispatchAsync(object action) + { + object ret = this.Dispatch(action); + await this.OngoingOperations.FirstAsync(i => i == 0); + return Task.FromResult(ret); + } + } +} \ No newline at end of file diff --git a/src/Redux/Redux.csproj b/src/Redux/Redux.csproj index 8f854a4..b903979 100644 --- a/src/Redux/Redux.csproj +++ b/src/Redux/Redux.csproj @@ -35,6 +35,7 @@ 4 + From 8ee92008cb2a010d804b64922b97e76918d8e178 Mon Sep 17 00:00:00 2001 From: Christer van der Meeren Date: Mon, 3 Apr 2017 12:07:50 +0200 Subject: [PATCH 03/10] Fix tests --- src/Redux.Tests/AwaitableStoreTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Redux.Tests/AwaitableStoreTests.cs b/src/Redux.Tests/AwaitableStoreTests.cs index a103222..910acb8 100644 --- a/src/Redux.Tests/AwaitableStoreTests.cs +++ b/src/Redux.Tests/AwaitableStoreTests.cs @@ -113,7 +113,7 @@ public void When_UsingNonAsyncSaga_Should_GetNewStateWithNormalDispatch() awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ImmediateIncrementSaga); // Act - awaitableStore.Dispatch(new IncrementAction()); + awaitableStore.Dispatch(new IncrementAsyncAction()); // Assert Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); @@ -127,7 +127,7 @@ public void When_UsingNonAsyncSaga_Should_GetNewStateNonAwaitedAsyncDispatch() awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ImmediateIncrementSaga); // Act - awaitableStore.DispatchAsync(new IncrementAction()); + awaitableStore.DispatchAsync(new IncrementAsyncAction()); // Assert Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); @@ -141,7 +141,7 @@ public async Task When_UsingNonAsyncSaga_Should_GetNewStateAwaitedAsyncDispatch( awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ImmediateIncrementSaga); // Act - await awaitableStore.DispatchAsync(new IncrementAction()); + await awaitableStore.DispatchAsync(new IncrementAsyncAction()); // Assert Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); From 9ed385354c1ac2d80751e17cac712299db52dbed Mon Sep 17 00:00:00 2001 From: Christer van der Meeren Date: Mon, 3 Apr 2017 12:09:06 +0200 Subject: [PATCH 04/10] Typos --- src/Redux.Tests/AwaitableStoreTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Redux.Tests/AwaitableStoreTests.cs b/src/Redux.Tests/AwaitableStoreTests.cs index 910acb8..25535fb 100644 --- a/src/Redux.Tests/AwaitableStoreTests.cs +++ b/src/Redux.Tests/AwaitableStoreTests.cs @@ -120,7 +120,7 @@ public void When_UsingNonAsyncSaga_Should_GetNewStateWithNormalDispatch() } [Test] - public void When_UsingNonAsyncSaga_Should_GetNewStateNonAwaitedAsyncDispatch() + public void When_UsingNonAsyncSaga_Should_GetNewStateWithNonAwaitedAsyncDispatch() { // Arrange var awaitableStore = new AwaitableStore(Reducer, 0); @@ -134,7 +134,7 @@ public void When_UsingNonAsyncSaga_Should_GetNewStateNonAwaitedAsyncDispatch() } [Test] - public async Task When_UsingNonAsyncSaga_Should_GetNewStateAwaitedAsyncDispatch() + public async Task When_UsingNonAsyncSaga_Should_GetNewStateWithAwaitedAsyncDispatch() { // Arrange var awaitableStore = new AwaitableStore(Reducer, 0); From 6d8589f3f00df843b975bb563eb35f34fc11b8ca Mon Sep 17 00:00:00 2001 From: Christer van der Meeren Date: Mon, 3 Apr 2017 12:12:04 +0200 Subject: [PATCH 05/10] Add test for multiple dispatches --- src/Redux.Tests/AwaitableStoreTests.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Redux.Tests/AwaitableStoreTests.cs b/src/Redux.Tests/AwaitableStoreTests.cs index 25535fb..17931eb 100644 --- a/src/Redux.Tests/AwaitableStoreTests.cs +++ b/src/Redux.Tests/AwaitableStoreTests.cs @@ -63,6 +63,24 @@ public void When_NotAwaitingDispatchAsync_Should_GetOldState() Assert.That(awaitableStore.GetState(), Is.EqualTo(0)); } + [Test] + public async Task When_AwaitingMultipleDispatches_Should_GetFinalState() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + + // Act + await awaitableStore.DispatchAsync(new IncrementAsyncAction()); + await Task.Delay(100); + await awaitableStore.DispatchAsync(new IncrementAsyncAction()); + await Task.Delay(100); + await awaitableStore.DispatchAsync(new IncrementAsyncAction()); + + // Assert + Assert.That(awaitableStore.GetState(), Is.EqualTo(3)); + } + [Test] public void When_NormalDispatch_Should_GetOldState() { From c4c3b55ccc7af96c905e024f4c6e637c1a3c3b8f Mon Sep 17 00:00:00 2001 From: Christer van der Meeren Date: Tue, 4 Apr 2017 10:11:20 +0200 Subject: [PATCH 06/10] Add saga unsubscription, test exception handling --- src/Redux.Tests/AwaitableStoreTests.cs | 92 +++++++++++++++++++++++++- src/Redux/AwaitableStore.cs | 45 ++++++++----- 2 files changed, 121 insertions(+), 16 deletions(-) diff --git a/src/Redux.Tests/AwaitableStoreTests.cs b/src/Redux.Tests/AwaitableStoreTests.cs index 17931eb..ac17137 100644 --- a/src/Redux.Tests/AwaitableStoreTests.cs +++ b/src/Redux.Tests/AwaitableStoreTests.cs @@ -1,10 +1,13 @@ namespace Redux.Tests { + using System; using System.Reactive.Linq; using System.Threading.Tasks; using NUnit.Framework; + [TestFixture] + [Timeout(5000)] public class AwaitableStoreTests { private class IncrementAction @@ -26,10 +29,20 @@ private static int Reducer(int state, object action) private async Task DelayedIncrementSaga(IncrementAsyncAction action, IStore store) { - await Task.Delay(1000); + await Task.Delay(500); store.Dispatch(new IncrementAction()); } + private Task AsyncThrowingSaga(IncrementAsyncAction action, IStore store) + { + throw new Exception(); + } + + private void ThrowingSaga(IncrementAsyncAction action, IStore store) + { + throw new Exception(); + } + private void ImmediateIncrementSaga(IncrementAsyncAction action, IStore store) { store.Dispatch(new IncrementAction()); @@ -164,5 +177,82 @@ public async Task When_UsingNonAsyncSaga_Should_GetNewStateWithAwaitedAsyncDispa // Assert Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); } + + [Test] + public void When_SagaThrowsException_Should_BubbleUp() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ThrowingSaga); + + // Act/Assert + Assert.That(() => awaitableStore.Dispatch(new IncrementAsyncAction()), Throws.Exception); + } + + [Test] + public void When_AsyncSagaThrowsException_Should_BubbleUp() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.AsyncThrowingSaga); + + // Act/Assert + Assert.That(async () => await awaitableStore.DispatchAsync(new IncrementAsyncAction()), Throws.Exception); + } + + [Test] + public async Task When_AsyncSagaThrowsException_Expect_DispatchIsStillAwaitable() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + + // Subscribe a saga that throws an exception, run it and remove it + IDisposable sub = awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.AsyncThrowingSaga); + Assert.That(async () => await awaitableStore.DispatchAsync(new IncrementAsyncAction()), Throws.Exception); + sub.Dispose(); + + // Act + await awaitableStore.DispatchAsync(new IncrementAction()); + + // Assert: The test will time out if the async counter was not decremented after the exception + } + + [Test] + public void When_SagaUnsubscribed_Should_NotRun() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + IDisposable sub = awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ImmediateIncrementSaga); + + // Sanity check + awaitableStore.Dispatch(new IncrementAsyncAction()); + Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + + // Act + sub.Dispose(); + awaitableStore.Dispatch(new IncrementAsyncAction()); + + // Assert + Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + } + + [Test] + public async Task When_AsyncSagaUnsubscribed_Should_NotRun() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + IDisposable sub = awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + + // Sanity check + await awaitableStore.DispatchAsync(new IncrementAsyncAction()); + Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + + // Act + sub.Dispose(); + await awaitableStore.DispatchAsync(new IncrementAsyncAction()); + + // Assert + Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + } } } \ No newline at end of file diff --git a/src/Redux/AwaitableStore.cs b/src/Redux/AwaitableStore.cs index a4beb54..94d6c80 100644 --- a/src/Redux/AwaitableStore.cs +++ b/src/Redux/AwaitableStore.cs @@ -1,6 +1,7 @@ namespace Redux { using System; + using System.Reactive; using System.Reactive.Linq; using System.Reactive.Subjects; using System.Threading.Tasks; @@ -11,29 +12,44 @@ public static class ObservableExtensions { - public static void RunsSaga( + public static IDisposable RunsSaga( this IObservable source, IStore store, Saga saga) { - source.Subscribe(action => saga(action, store)); + return source.Subscribe(action => saga(action, store)); } - public static void RunsAsyncSaga( + public static IDisposable RunsAsyncSaga( this IObservable source, AwaitableStore store, AsyncSaga saga) { - source.Subscribe( - async action => - { - // TODO: Find a way to call AddOperation and RemoveOperation below - // without specifying concrete class AwaitableStore above, but also - // without giving devs access to AddOperation and RemoveOperation - store.AddOperation(); - await saga(action, (IStore)store); - store.RemoveOperation(); - }); + // Using SelectMany is the standard way of running async subscribers, otherwise they become + // async void and exceptions are swallowed. E.g. http://stackoverflow.com/a/24844934/2978652, + // http://stackoverflow.com/a/37412422/2978652, http://stackoverflow.com/a/23011084/2978652. + // + // Note that this will not block the queue while the saga runs; a new action can trigger + // the saga again while the previous runs. To avoid this, see http://stackoverflow.com/a/30030640/2978652. + // TODO: we should implement some kind of cancellation support, i.e. for TakeLatest semantics. + return source.SelectMany( + async action => + { + // TODO: Find a way to call AddOperation and RemoveOperation below + // without specifying concrete class AwaitableStore above, but also + // without giving devs access to AddOperation and RemoveOperation + store.AddOperation(); + try + { + await saga(action, store); + return Unit.Default; + } + finally + { + store.RemoveOperation(); + } + }) + .Subscribe(); } } @@ -66,14 +82,13 @@ protected override object InnerDispatch(object action) public interface IAwaitableStore { - Task DispatchAsync(object action); } public class AwaitableStore : ObservableActionStore, IAwaitableStore { private int numOperations; - private readonly ISubject numOperationsSubject = new BehaviorSubject(0); // TODO: start with 0 + private readonly ISubject numOperationsSubject = new BehaviorSubject(0); /// public AwaitableStore( From f8ce0c672ea7403bee315626147122bb2e22953b Mon Sep 17 00:00:00 2001 From: Christer van der Meeren Date: Tue, 4 Apr 2017 10:23:51 +0200 Subject: [PATCH 07/10] Implement async ref count as a Disposable --- src/Redux/AwaitableStore.cs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/Redux/AwaitableStore.cs b/src/Redux/AwaitableStore.cs index 94d6c80..22f76e6 100644 --- a/src/Redux/AwaitableStore.cs +++ b/src/Redux/AwaitableStore.cs @@ -2,6 +2,7 @@ { using System; using System.Reactive; + using System.Reactive.Disposables; using System.Reactive.Linq; using System.Reactive.Subjects; using System.Threading.Tasks; @@ -38,16 +39,11 @@ public static IDisposable RunsAsyncSaga( // TODO: Find a way to call AddOperation and RemoveOperation below // without specifying concrete class AwaitableStore above, but also // without giving devs access to AddOperation and RemoveOperation - store.AddOperation(); - try + using (store.AsyncOperation()) { await saga(action, store); return Unit.Default; } - finally - { - store.RemoveOperation(); - } }) .Subscribe(); } @@ -100,14 +96,10 @@ public AwaitableStore( private IObservable OngoingOperations => this.numOperationsSubject; - internal void AddOperation() + internal IDisposable AsyncOperation() { this.numOperationsSubject.OnNext(++this.numOperations); - } - - internal void RemoveOperation() - { - this.numOperationsSubject.OnNext(--this.numOperations); + return Disposable.Create(() => this.numOperationsSubject.OnNext(--this.numOperations)); } public async Task DispatchAsync(object action) From 6427ca55986b431a4c7c2638ca09b74ae9546757 Mon Sep 17 00:00:00 2001 From: Christer van der Meeren Date: Tue, 4 Apr 2017 11:47:57 +0200 Subject: [PATCH 08/10] Make RunsAsyncSaga compatible with any IStore --- src/Redux.Tests/AwaitableStoreTests.cs | 16 ++++++++++++++++ src/Redux/AwaitableStore.cs | 21 +++++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/Redux.Tests/AwaitableStoreTests.cs b/src/Redux.Tests/AwaitableStoreTests.cs index ac17137..8366fba 100644 --- a/src/Redux.Tests/AwaitableStoreTests.cs +++ b/src/Redux.Tests/AwaitableStoreTests.cs @@ -254,5 +254,21 @@ public async Task When_AsyncSagaUnsubscribed_Should_NotRun() // Assert Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); } + + [Test] + public async Task When_StoreIsNotAwaitable_Should_WorkAsNormal() + { + // Arrange + var store = new ObservableActionStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, this.DelayedIncrementSaga); + + // Act + store.Dispatch(new IncrementAsyncAction()); + + // Assert + Assert.That(store.GetState(), Is.EqualTo(0)); + await Task.Delay(600); + Assert.That(store.GetState(), Is.EqualTo(1)); + } } } \ No newline at end of file diff --git a/src/Redux/AwaitableStore.cs b/src/Redux/AwaitableStore.cs index 22f76e6..5c0b42f 100644 --- a/src/Redux/AwaitableStore.cs +++ b/src/Redux/AwaitableStore.cs @@ -23,7 +23,7 @@ public static IDisposable RunsSaga( public static IDisposable RunsAsyncSaga( this IObservable source, - AwaitableStore store, + IStore store, AsyncSaga saga) { // Using SelectMany is the standard way of running async subscribers, otherwise they become @@ -31,19 +31,24 @@ public static IDisposable RunsAsyncSaga( // http://stackoverflow.com/a/37412422/2978652, http://stackoverflow.com/a/23011084/2978652. // // Note that this will not block the queue while the saga runs; a new action can trigger - // the saga again while the previous runs. To avoid this, see http://stackoverflow.com/a/30030640/2978652. - // TODO: we should implement some kind of cancellation support, i.e. for TakeLatest semantics. + // the saga while the previous saga invocation still runs. This should be the expected + // behavior since otherwise, the sagas would have no control over cancellation of existing + // tasks when they are invoked again. return source.SelectMany( async action => { - // TODO: Find a way to call AddOperation and RemoveOperation below - // without specifying concrete class AwaitableStore above, but also - // without giving devs access to AddOperation and RemoveOperation - using (store.AsyncOperation()) + if (store is AwaitableStore s) + { + using (s.AsyncOperation()) + { + await saga(action, store); + } + } + else { await saga(action, store); - return Unit.Default; } + return Unit.Default; }) .Subscribe(); } From 01d75483a60a0b3ec7b99bc5e0414fdb6d864afe Mon Sep 17 00:00:00 2001 From: Christer van der Meeren Date: Tue, 4 Apr 2017 12:41:45 +0200 Subject: [PATCH 09/10] Add test for saga concurrency --- src/Redux.Tests/AwaitableStoreTests.cs | 18 ++++++++++++++++++ src/Redux/AwaitableStore.cs | 9 +++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/Redux.Tests/AwaitableStoreTests.cs b/src/Redux.Tests/AwaitableStoreTests.cs index 8366fba..f53ecb2 100644 --- a/src/Redux.Tests/AwaitableStoreTests.cs +++ b/src/Redux.Tests/AwaitableStoreTests.cs @@ -270,5 +270,23 @@ public async Task When_StoreIsNotAwaitable_Should_WorkAsNormal() await Task.Delay(600); Assert.That(store.GetState(), Is.EqualTo(1)); } + + [Test] + public async Task Should_AllowSagaToRunConcurrently() + { + // Arrange + var awaitableStore = new AwaitableStore(Reducer, 0); + awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + + // Act + awaitableStore.Dispatch(new IncrementAsyncAction()); + awaitableStore.Dispatch(new IncrementAsyncAction()); + + + // Assert + Assert.That(awaitableStore.GetState(), Is.EqualTo(0)); + await Task.Delay(600); + Assert.That(awaitableStore.GetState(), Is.EqualTo(2)); + } } } \ No newline at end of file diff --git a/src/Redux/AwaitableStore.cs b/src/Redux/AwaitableStore.cs index 5c0b42f..bcc5a97 100644 --- a/src/Redux/AwaitableStore.cs +++ b/src/Redux/AwaitableStore.cs @@ -30,10 +30,11 @@ public static IDisposable RunsAsyncSaga( // async void and exceptions are swallowed. E.g. http://stackoverflow.com/a/24844934/2978652, // http://stackoverflow.com/a/37412422/2978652, http://stackoverflow.com/a/23011084/2978652. // - // Note that this will not block the queue while the saga runs; a new action can trigger - // the saga while the previous saga invocation still runs. This should be the expected - // behavior since otherwise, the sagas would have no control over cancellation of existing - // tasks when they are invoked again. + // Note that this will NOT block the queue while the saga runs; a new action can trigger + // the saga while the previous saga invocation still runs (which can be accomplished with + // http://stackoverflow.com/a/30030640/2978652). Note that this should be the expected + // behavior since it allows the sagas themselves to control cancellation of existing tasks + // in response to new invocations. return source.SelectMany( async action => { From c7c7dd26f4f828f6a21e49c57f8c5b925c6c2fc0 Mon Sep 17 00:00:00 2001 From: Christer van der Meeren Date: Tue, 4 Apr 2017 14:36:30 +0200 Subject: [PATCH 10/10] Add and refactor tests --- .../Redux.DevTools.Universal.nuget.props | 4 +- src/Redux.Tests/AwaitableStoreTests.cs | 349 ++++++++++++------ src/Redux.sln.DotSettings | 2 + src/Redux/AwaitableStore.cs | 2 +- 4 files changed, 248 insertions(+), 109 deletions(-) create mode 100644 src/Redux.sln.DotSettings diff --git a/src/Redux.DevTools.Universal/Redux.DevTools.Universal.nuget.props b/src/Redux.DevTools.Universal/Redux.DevTools.Universal.nuget.props index f2f2db6..f9cdb94 100644 --- a/src/Redux.DevTools.Universal/Redux.DevTools.Universal.nuget.props +++ b/src/Redux.DevTools.Universal/Redux.DevTools.Universal.nuget.props @@ -3,9 +3,9 @@ True NuGet - C:\Dev\redux.net\src\Redux.DevTools.Universal\project.lock.json + C:\GH\redux.NET\src\Redux.DevTools.Universal\project.lock.json $(UserProfile)\.nuget\packages\ - C:\Users\Guillaume\.nuget\packages\ + C:\Users\Christer\.nuget\packages\ ProjectJson 4.0.0 diff --git a/src/Redux.Tests/AwaitableStoreTests.cs b/src/Redux.Tests/AwaitableStoreTests.cs index f53ecb2..174e4e8 100644 --- a/src/Redux.Tests/AwaitableStoreTests.cs +++ b/src/Redux.Tests/AwaitableStoreTests.cs @@ -1,6 +1,7 @@ namespace Redux.Tests { using System; + using System.Diagnostics; using System.Reactive.Linq; using System.Threading.Tasks; @@ -10,11 +11,11 @@ [Timeout(5000)] public class AwaitableStoreTests { - private class IncrementAction + private class StoreIncrementAction { } - private class IncrementAsyncAction + private class SagaIncrementAction { } @@ -22,271 +23,407 @@ private static int Reducer(int state, object action) { switch (action) { - case IncrementAction _: return state + 1; + case StoreIncrementAction _: return state + 1; default: return state; } } - private async Task DelayedIncrementSaga(IncrementAsyncAction action, IStore store) + private static void BlockingIncrementSaga(SagaIncrementAction action, IStore store) { - await Task.Delay(500); - store.Dispatch(new IncrementAction()); + Task.Delay(100).Wait(); + store.Dispatch(new StoreIncrementAction()); } - private Task AsyncThrowingSaga(IncrementAsyncAction action, IStore store) + private static async Task AsyncIncrementSaga(SagaIncrementAction action, IStore store) + { + await Task.Delay(100); + store.Dispatch(new StoreIncrementAction()); + } + + private static void BlockingThrowingSaga(SagaIncrementAction action, IStore store) { throw new Exception(); } - private void ThrowingSaga(IncrementAsyncAction action, IStore store) + private static Task AsyncThrowingSaga(SagaIncrementAction action, IStore store) { throw new Exception(); } - private void ImmediateIncrementSaga(IncrementAsyncAction action, IStore store) + #region Updating state using delayed saga + + [Test] + public async Task AsyncSaga_When_AwaitingDispatchAsync_Should_GetNewState() { - store.Dispatch(new IncrementAction()); + // Arrange + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncIncrementSaga); + + // Act + await store.DispatchAsync(new SagaIncrementAction()); + + // Assert + Assert.That(store.GetState(), Is.EqualTo(1)); } [Test] - public async Task When_AwaitingDispatch_Should_GetUpdatedState() + public async Task AsyncSaga_When_NotAwaitingDispatchAsync_Should_GetNewStateAfterSagaCompletes() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncIncrementSaga); // Act - await awaitableStore.DispatchAsync(new IncrementAsyncAction()); + store.DispatchAsync(new SagaIncrementAction()); // Assert - Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + Assert.That(store.GetState(), Is.EqualTo(0)); + await Task.Delay(150); + Assert.That(store.GetState(), Is.EqualTo(1)); } [Test] - public void When_NotAwaitingDispatchAsync_Should_GetOldState() + public async Task AsyncSaga_When_UsingNormalDispatch_Should_GetNewStateAfterSagaCompletes() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncIncrementSaga); // Act - awaitableStore.DispatchAsync(new IncrementAsyncAction()); + store.Dispatch(new SagaIncrementAction()); // Assert - Assert.That(awaitableStore.GetState(), Is.EqualTo(0)); + Assert.That(store.GetState(), Is.EqualTo(0)); + await Task.Delay(150); + Assert.That(store.GetState(), Is.EqualTo(1)); } [Test] - public async Task When_AwaitingMultipleDispatches_Should_GetFinalState() + public async Task AsyncSaga_When_StoreIsNonAwaitable_Should_GetNewStateAfterSagaCompletes() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + var store = new ObservableActionStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncIncrementSaga); // Act - await awaitableStore.DispatchAsync(new IncrementAsyncAction()); - await Task.Delay(100); - await awaitableStore.DispatchAsync(new IncrementAsyncAction()); - await Task.Delay(100); - await awaitableStore.DispatchAsync(new IncrementAsyncAction()); + store.Dispatch(new SagaIncrementAction()); + + // Assert + Assert.That(store.GetState(), Is.EqualTo(0)); + await Task.Delay(150); + Assert.That(store.GetState(), Is.EqualTo(1)); + } + + [Test] + public async Task AsyncSaga_When_AwaitingFirstOfSeveralDispatches_Should_AwaitAllDispatchesAndGetFinalState() + { + // Arrange + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncIncrementSaga); + + // Act + Task firstDispatch = store.DispatchAsync(new SagaIncrementAction()); + await Task.Delay(30); + store.Dispatch(new SagaIncrementAction()); + await Task.Delay(30); + store.Dispatch(new SagaIncrementAction()); + + await firstDispatch; // Assert - Assert.That(awaitableStore.GetState(), Is.EqualTo(3)); + Assert.That(store.GetState(), Is.EqualTo(3)); } + #endregion + + #region Updating state using blocking saga + [Test] - public void When_NormalDispatch_Should_GetOldState() + public async Task BlockingSaga_When_AwaitingDispatchAsync_Should_GetNewState() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsSaga(store, BlockingIncrementSaga); // Act - awaitableStore.Dispatch(new IncrementAsyncAction()); + await store.DispatchAsync(new SagaIncrementAction()); // Assert - Assert.That(awaitableStore.GetState(), Is.EqualTo(0)); + Assert.That(store.GetState(), Is.EqualTo(1)); } [Test] - public void When_SagaNotInvoked_Should_GetNewStateWithNonAwaitedDispatchAsync() + public void BlockingSaga_When_NotAwaitingDispatchAsync_Should_GetNewState() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsSaga(store, BlockingIncrementSaga); // Act - awaitableStore.Dispatch(new IncrementAction()); + store.DispatchAsync(new SagaIncrementAction()); // Assert - Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + Assert.That(store.GetState(), Is.EqualTo(1)); } [Test] - public void When_SagaNotInvoked_Should_GetNewStateWithNormalDispatch() + public void BlockingSaga_When_UsingNormalDispatch_Should_GetNewState() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsSaga(store, BlockingIncrementSaga); // Act - awaitableStore.Dispatch(new IncrementAction()); + store.Dispatch(new SagaIncrementAction()); // Assert - Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + Assert.That(store.GetState(), Is.EqualTo(1)); } [Test] - public void When_UsingNonAsyncSaga_Should_GetNewStateWithNormalDispatch() + public void BlockingSaga_When_StoreIsNonAwaitable_Should_GetNewState() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ImmediateIncrementSaga); + var store = new ObservableActionStore(Reducer, 0); + store.Actions.OfType().RunsSaga(store, BlockingIncrementSaga); // Act - awaitableStore.Dispatch(new IncrementAsyncAction()); + store.Dispatch(new SagaIncrementAction()); // Assert - Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + Assert.That(store.GetState(), Is.EqualTo(1)); } + #endregion + + #region Updating state directly (bypassing saga) + [Test] - public void When_UsingNonAsyncSaga_Should_GetNewStateWithNonAwaitedAsyncDispatch() + public async Task DirectAction_When_AwaitingDispatchAsync_Should_GetNewState() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ImmediateIncrementSaga); + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncIncrementSaga); // Act - awaitableStore.DispatchAsync(new IncrementAsyncAction()); + await store.DispatchAsync(new StoreIncrementAction()); // Assert - Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + Assert.That(store.GetState(), Is.EqualTo(1)); } [Test] - public async Task When_UsingNonAsyncSaga_Should_GetNewStateWithAwaitedAsyncDispatch() + public void DirectAction_When_NotAwaitingDispatchAsync_Should_GetNewState() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ImmediateIncrementSaga); + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncIncrementSaga); // Act - await awaitableStore.DispatchAsync(new IncrementAsyncAction()); + store.DispatchAsync(new StoreIncrementAction()); // Assert - Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + Assert.That(store.GetState(), Is.EqualTo(1)); } [Test] - public void When_SagaThrowsException_Should_BubbleUp() + public void DirectAction_When_UsingNormalDispatch_Should_GetNewState() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ThrowingSaga); + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncIncrementSaga); + + // Act + store.Dispatch(new StoreIncrementAction()); + + // Assert + Assert.That(store.GetState(), Is.EqualTo(1)); + } + + [Test] + public void DirectAction_When_StoreIsNonAwaitable_Should_GetNewState() + { + // Arrange + var store = new ObservableActionStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncIncrementSaga); + + // Act + store.Dispatch(new StoreIncrementAction()); + + // Assert + Assert.That(store.GetState(), Is.EqualTo(1)); + } + + #endregion + + #region Exceptions - async saga + + [Test] + public void AsyncThrowingSaga_When_AwaitingDispatchAsync_Should_BubbleUp() + { + // Arrange + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncThrowingSaga); // Act/Assert - Assert.That(() => awaitableStore.Dispatch(new IncrementAsyncAction()), Throws.Exception); + Assert.That(async () => await store.DispatchAsync(new SagaIncrementAction()), Throws.Exception); } [Test] - public void When_AsyncSagaThrowsException_Should_BubbleUp() + public void AsyncThrowingSaga_When_UsingNormalDispatch_Should_BubbleUp() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.AsyncThrowingSaga); + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncThrowingSaga); + + // Act/Assert + Assert.That(() => store.Dispatch(new SagaIncrementAction()), Throws.Exception); + } + + [Test] + public void AsyncThrowingSaga_When_StoreIsNonAwaitable_Should_BubbleUp() + { + // Arrange + var store = new ObservableActionStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncThrowingSaga); // Act/Assert - Assert.That(async () => await awaitableStore.DispatchAsync(new IncrementAsyncAction()), Throws.Exception); + Assert.That(() => store.Dispatch(new SagaIncrementAction()), Throws.Exception); } [Test] - public async Task When_AsyncSagaThrowsException_Expect_DispatchIsStillAwaitable() + public async Task AsyncThrowingSaga_When_ExceptionThrownAndCaught_Expect_DispatchIsStillAwaitable() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); + var store = new AwaitableStore(Reducer, 0); // Subscribe a saga that throws an exception, run it and remove it - IDisposable sub = awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.AsyncThrowingSaga); - Assert.That(async () => await awaitableStore.DispatchAsync(new IncrementAsyncAction()), Throws.Exception); + IDisposable sub = store.Actions.OfType() + .RunsAsyncSaga(store, AsyncThrowingSaga); + Assert.That(async () => await store.DispatchAsync(new SagaIncrementAction()), Throws.Exception); sub.Dispose(); - // Act - await awaitableStore.DispatchAsync(new IncrementAction()); + // Act/Assert: The test will time out if the async counter was not decremented after the exception + await store.DispatchAsync(new StoreIncrementAction()); + } + + #endregion + + #region Exceptions - blocking saga + + [Test] + public void BlockingThrowingSaga_When_AwaitingDispatchAsync_Should_BubbleUp() + { + // Arrange + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsSaga(store, BlockingThrowingSaga); - // Assert: The test will time out if the async counter was not decremented after the exception + // Act/Assert + Assert.That(async () => await store.DispatchAsync(new SagaIncrementAction()), Throws.Exception); } [Test] - public void When_SagaUnsubscribed_Should_NotRun() + public void BlockingThrowingSaga_When_UsingNormalDispatch_Should_BubbleUp() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - IDisposable sub = awaitableStore.Actions.OfType().RunsSaga(awaitableStore, this.ImmediateIncrementSaga); + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsSaga(store, BlockingThrowingSaga); - // Sanity check - awaitableStore.Dispatch(new IncrementAsyncAction()); - Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + // Act/Assert + Assert.That(() => store.Dispatch(new SagaIncrementAction()), Throws.Exception); + } - // Act + [Test] + public void BlockingThrowingSaga_When_StoreIsNonAwaitable_Should_BubbleUp() + { + // Arrange + var store = new ObservableActionStore(Reducer, 0); + store.Actions.OfType().RunsSaga(store, BlockingThrowingSaga); + + // Act/Assert + Assert.That(() => store.Dispatch(new SagaIncrementAction()), Throws.Exception); + } + + [Test] + public async Task BlockingThrowingSaga_When_ExceptionThrownAndCaught_Expect_DispatchIsStillAwaitable() + { + // Arrange + var store = new AwaitableStore(Reducer, 0); + + // Subscribe a saga that throws an exception, run it and remove it + IDisposable sub = store.Actions.OfType().RunsSaga(store, BlockingThrowingSaga); + Assert.That(async () => await store.DispatchAsync(new SagaIncrementAction()), Throws.Exception); sub.Dispose(); - awaitableStore.Dispatch(new IncrementAsyncAction()); - // Assert - Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + // Act/Assert: The test will time out if the async counter was not decremented after the exception + await store.DispatchAsync(new StoreIncrementAction()); } + #endregion + + #region Unsubscription + [Test] - public async Task When_AsyncSagaUnsubscribed_Should_NotRun() + public async Task AsyncSaga_When_Unsubscribed_Should_NotBeInvoked() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - IDisposable sub = awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + var store = new AwaitableStore(Reducer, 0); + IDisposable sub = store.Actions.OfType() + .RunsAsyncSaga(store, AsyncIncrementSaga); // Sanity check - await awaitableStore.DispatchAsync(new IncrementAsyncAction()); - Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + await store.DispatchAsync(new SagaIncrementAction()); + Assert.That(store.GetState(), Is.EqualTo(1)); // Act sub.Dispose(); - await awaitableStore.DispatchAsync(new IncrementAsyncAction()); + await store.DispatchAsync(new SagaIncrementAction()); // Assert - Assert.That(awaitableStore.GetState(), Is.EqualTo(1)); + Assert.That(store.GetState(), Is.EqualTo(1)); } [Test] - public async Task When_StoreIsNotAwaitable_Should_WorkAsNormal() + public void BlockingSaga_When_Unsubscribed_Should_NotBeInvoked() { // Arrange var store = new ObservableActionStore(Reducer, 0); - store.Actions.OfType().RunsAsyncSaga(store, this.DelayedIncrementSaga); + IDisposable sub = store.Actions.OfType().RunsSaga(store, BlockingIncrementSaga); + + // Sanity check + store.Dispatch(new SagaIncrementAction()); + Assert.That(store.GetState(), Is.EqualTo(1)); // Act - store.Dispatch(new IncrementAsyncAction()); - + sub.Dispose(); + store.Dispatch(new SagaIncrementAction()); + // Assert - Assert.That(store.GetState(), Is.EqualTo(0)); - await Task.Delay(600); Assert.That(store.GetState(), Is.EqualTo(1)); } + #endregion + + #region Concurrency + [Test] - public async Task Should_AllowSagaToRunConcurrently() + public async Task When_MultipleActionsDispatched_Should_BeProcessedConcurrentlyBySaga() { // Arrange - var awaitableStore = new AwaitableStore(Reducer, 0); - awaitableStore.Actions.OfType().RunsAsyncSaga(awaitableStore, this.DelayedIncrementSaga); + var store = new AwaitableStore(Reducer, 0); + store.Actions.OfType().RunsAsyncSaga(store, AsyncIncrementSaga); // Act - awaitableStore.Dispatch(new IncrementAsyncAction()); - awaitableStore.Dispatch(new IncrementAsyncAction()); - + store.Dispatch(new SagaIncrementAction()); + await Task.Delay(50); + store.Dispatch(new SagaIncrementAction()); // Assert - Assert.That(awaitableStore.GetState(), Is.EqualTo(0)); - await Task.Delay(600); - Assert.That(awaitableStore.GetState(), Is.EqualTo(2)); + Assert.That(store.GetState(), Is.EqualTo(0)); + await Task.Delay(150); + Assert.That(store.GetState(), Is.EqualTo(2)); } } + + #endregion } \ No newline at end of file diff --git a/src/Redux.sln.DotSettings b/src/Redux.sln.DotSettings new file mode 100644 index 0000000..4f05f4a --- /dev/null +++ b/src/Redux.sln.DotSettings @@ -0,0 +1,2 @@ + + <data><IncludeFilters /><ExcludeFilters><Filter ModuleMask="Redux.Tests" ModuleVersionMask="*" ClassMask="*" FunctionMask="*" IsEnabled="True" /></ExcludeFilters></data> \ No newline at end of file diff --git a/src/Redux/AwaitableStore.cs b/src/Redux/AwaitableStore.cs index bcc5a97..b14e7c5 100644 --- a/src/Redux/AwaitableStore.cs +++ b/src/Redux/AwaitableStore.cs @@ -82,7 +82,7 @@ protected override object InnerDispatch(object action) } } - public interface IAwaitableStore + public interface IAwaitableStore : IStore { Task DispatchAsync(object action); }