diff --git a/Src/FluentAssertions.Reactive/ReactiveAssertions.cs b/Src/FluentAssertions.Reactive/ReactiveAssertions.cs index 9bd72f3..4330392 100644 --- a/Src/FluentAssertions.Reactive/ReactiveAssertions.cs +++ b/Src/FluentAssertions.Reactive/ReactiveAssertions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Reactive; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; @@ -9,6 +10,7 @@ using FluentAssertions.Execution; using FluentAssertions.Primitives; using FluentAssertions.Specialized; +using JetBrains.Annotations; using Microsoft.Reactive.Testing; namespace FluentAssertions.Reactive @@ -106,18 +108,18 @@ public async Task, IEnumerable - /// Asserts that at least notifications are pushed to the within the next 1 second.
+ /// Asserts that at least notifications are pushed to the within the next 1 seconds.
/// This includes any previously recorded notifications since it has been created or cleared. /// /// the number of notifications the observer should have recorded by now /// /// public AndWhichConstraint, IEnumerable> Push(int numberOfNotifications, string because = "", params object[] becauseArgs) - => Push(numberOfNotifications, TimeSpan.FromSeconds(10), because, becauseArgs); + => Push(numberOfNotifications, TimeSpan.FromSeconds(1), because, becauseArgs); /// public Task, IEnumerable>> PushAsync(int numberOfNotifications, string because = "", params object[] becauseArgs) - => PushAsync(numberOfNotifications, TimeSpan.FromSeconds(10), because, becauseArgs); + => PushAsync(numberOfNotifications, TimeSpan.FromSeconds(1), because, becauseArgs); /// /// Asserts that at least 1 notification is pushed to the within the next 1 second.
@@ -248,6 +250,133 @@ public AndConstraint> NotComplete(TimeSpan timeout, public AndConstraint> NotComplete(string because = "", params object[] becauseArgs) => NotComplete(TimeSpan.FromMilliseconds(100), because, becauseArgs); + + /// + /// Asserts that at least one notification matching was pushed to the + /// within the specified .
+ /// This includes any previously recorded notifications since it has been created or cleared. + ///
+ /// A predicate to match the items in the collection against. + /// the maximum time to wait for the notification to arrive + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + /// is null. + public AndConstraint> PushMatch( + [NotNull] Expression> predicate, + TimeSpan timeout, + string because = "", + params object[] becauseArgs) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + + IList notifications = new List(); + + try + { + Func func = predicate.Compile(); + notifications = Observer.RecordedNotificationStream + .Select(r => r.Value) + .Dematerialize() + .Where(func) + .Take(1) + .Timeout(timeout) + .Catch(exception => Observable.Empty()) + .ToList() + .ToTask() + .ExecuteInDefaultSynchronizationContext(); + } + catch (Exception e) + { + if (e is AggregateException aggregateException) + e = aggregateException.InnerException; + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith("Expected {context:observable} to push an item matching {0}{reason}, but it failed with a {1}.", predicate.Body, e); + } + + Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(notifications.Any()) + .FailWith("Expected {context:observable} to push an item matching {0}{reason} within {1}.", predicate.Body, timeout); + + return new AndConstraint>(this); + } + + /// + /// Asserts that at least one notification matching was pushed to the + /// within the next 1 second.
+ /// This includes any previously recorded notifications since it has been created or cleared. + ///
+ /// A predicate to match the items in the collection against. + /// the maximum time to wait for the notification to arrive + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + /// is null. + public AndConstraint> PushMatch( + [NotNull] Expression> predicate, + string because = "", + params object[] becauseArgs) + => PushMatch(predicate, TimeSpan.FromSeconds(1), because, becauseArgs); + + /// + public async Task>> PushMatchAsync( + [NotNull] Expression> predicate, + TimeSpan timeout, + string because = "", + params object[] becauseArgs) + { + if (predicate == null) + throw new ArgumentNullException(nameof(predicate)); + + IList notifications = new List(); + + try + { + Func func = predicate.Compile(); + notifications = await Observer.RecordedNotificationStream + .Select(r => r.Value) + .Dematerialize() + .Where(func) + .Take(1) + .Timeout(timeout) + .Catch(exception => Observable.Empty()) + .ToList() + .ToTask().ConfigureAwait(false); + } + catch (Exception e) + { + if (e is AggregateException aggregateException) + e = aggregateException.InnerException; + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith("Expected {context:observable} to push an item matching {0}{reason}, but it failed with a {1}.", predicate.Body, e); + } + + Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(notifications.Any()) + .FailWith("Expected {context:observable} to push an item matching {0}{reason} within {1}.", predicate.Body, timeout); + + return new AndWhichConstraint, IEnumerable>(this, notifications); + } + + /// + public Task>> PushMatchAsync( + [NotNull] Expression> predicate, + string because = "", + params object[] becauseArgs) + => PushMatchAsync(predicate, TimeSpan.FromSeconds(1), because, becauseArgs); + protected Task>>> GetRecordedNotifications(TimeSpan timeout) => Observer.RecordedNotificationStream .TakeUntil(recorded => recorded.Value.Kind == NotificationKind.OnError) diff --git a/Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs b/Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs index d67659b..786624f 100644 --- a/Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs +++ b/Tests/FluentAssertions.Reactive.Specs/ReactiveAssertionSpecs.cs @@ -126,8 +126,7 @@ public void When_the_observable_is_expected_to_fail_but_does_not_it_should_throw observer.Error.Should().BeNull(); } - - + [Fact] public void When_the_observable_completes_as_expected_it_should_not_throw() { @@ -159,5 +158,68 @@ public void When_the_observable_is_expected_to_complete_but_does_not_it_should_t observer.Error.Should().BeNull(); } + [Fact] + public void When_the_observable_pushes_an_expected_match_it_should_not_throw() + { + var scheduler = new TestScheduler(); + var observable = scheduler.CreateColdObservable( + OnNext(100, 1), + OnNext(200, 2)); + + // observe the sequence + using var observer = observable.Observe(scheduler); + // push subscriptions + scheduler.AdvanceTo(250); + + // Act + Action act = () => observer.Should().PushMatch(i => i > 1); + + // Assert + act.Should().NotThrow(); + + observer.RecordedNotifications.Should().BeEquivalentTo(observable.Messages); + } + + [Fact] + public void When_the_observable_does_not_push_a_match_it_should_throw() + { + var scheduler = new TestScheduler(); + var observable = scheduler.CreateColdObservable( + OnNext(100, 1), + OnNext(200, 2)); + + // observe the sequence + using var observer = observable.Observe(scheduler); + // push subscriptions + scheduler.AdvanceTo(250); + + // Act + Action act = () => observer.Should().PushMatch(i => i > 3, TimeSpan.FromMilliseconds(1)); + + // Assert + act.Should().Throw().WithMessage( + $"Expected observable to push an item matching (i > 3) within {Formatter.ToString(TimeSpan.FromMilliseconds(1))}."); + + observer.RecordedNotifications.Should().BeEquivalentTo(observable.Messages); + } + + [Fact] + public void When_the_observable_fails_instead_of_pushing_a_match_it_should_throw() + { + var exception = new ArgumentException("That was wrong."); + var scheduler = new TestScheduler(); + var observable = scheduler.CreateColdObservable( + OnError(1, exception)); + + // observe the sequence + using var observer = observable.Observe(scheduler); + scheduler.AdvanceTo(10); + // Act + Action act = () => observer.Should().PushMatch(i => i > 1); + // Assert + act.Should().Throw().WithMessage( + $"Expected observable to push an item matching (i > 1), but it failed with a {Formatter.ToString(exception)}."); + observer.Error.Should().BeEquivalentTo(exception); + } } }