diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index eeda724..837a4f1 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -15,15 +15,15 @@ jobs: steps: - name: 🗂️ Checkout the repository uses: actions/checkout@main - with: - fetch-depth: 0 - - name: 🔖 Get version from latest tag + - name: 🔖 Get version from release tag id: version run: | - latest=$(git describe --tags $(git rev-list --tags --max-count=1)) - echo Current version: $latest - echo "version=$latest" >> $GITHUB_OUTPUT + # The release that triggered this run, not the newest tag in the repository. + tag="${{ github.event.release.tag_name }}" + version="${tag#v}" + echo Current version: $version + echo "version=$version" >> $GITHUB_OUTPUT - name: ⬇️ Install .NET 10 uses: actions/setup-dotnet@v5 diff --git a/README.md b/README.md index 48210d7..f87be1a 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ This library also has extension methods for scheduling. Every scheduling method ### Common options +Every timespan must be positive; zero or negative values throw an `ArgumentOutOfRangeException`. + All scheduling methods share three optional parameters: **resetTimerOnConsecutiveTrue / resetTimerOnConsecutiveFalse (default `false`)** diff --git a/src/Reactive.Boolean/BlinkWhileTrueOperator.cs b/src/Reactive.Boolean/BlinkWhileTrueOperator.cs index 62b45ee..291d944 100644 --- a/src/Reactive.Boolean/BlinkWhileTrueOperator.cs +++ b/src/Reactive.Boolean/BlinkWhileTrueOperator.cs @@ -17,7 +17,7 @@ internal sealed class BlinkWhileTrueOperator( // Under CompleteAfterTimer only the current "true" phase is finished, so an "off" phase has nothing pending. protected override bool HasPendingValue => TimerRunning && LastEmittedValue == true; - protected override void OnSourceValue(bool value) + protected override void OnSourceValue(bool value, bool? previous) { if (!value) { @@ -26,7 +26,7 @@ protected override void OnSourceValue(bool value) return; } - if (LastSourceValue != true || resetTimerOnConsecutiveTrue) + if (previous != true || resetTimerOnConsecutiveTrue) { Emit(true); StartTimer(); diff --git a/src/Reactive.Boolean/BooleanObservableExtensions.Operators.And.cs b/src/Reactive.Boolean/BooleanObservableExtensions.Operators.And.cs index 96ef7e2..bd9e669 100644 --- a/src/Reactive.Boolean/BooleanObservableExtensions.Operators.And.cs +++ b/src/Reactive.Boolean/BooleanObservableExtensions.Operators.And.cs @@ -59,17 +59,28 @@ public static IObservable And( { ArgumentNullException.ThrowIfNull(source); + var observables = source.ToArray(); + if (observables.Any(o => o is null)) + { + throw new ArgumentException("The collection contains a null observable.", nameof(source)); + } + if (observables.Length == 0) + { + // CombineLatest over no sources never emits; the conjunction of nothing is vacuously true. + return Observable.Return(true); + } + if (operatorDistinctness == OperatorDistinctness.InputDistinctUntilChanged) { - source = source.Select(o => o.DistinctUntilChanged()); + observables = observables.Select(o => o.DistinctUntilChanged()).ToArray(); } if (operatorDistinctness == OperatorDistinctness.OutputDistinctUntilChanged) { - return source + return observables .CombineLatest(values => values.All(v => v)) .DistinctUntilChanged(); } - return source.CombineLatest(values => values.All(v => v)); + return observables.CombineLatest(values => values.All(v => v)); } /// @@ -86,8 +97,13 @@ public static IObservable And( public static IObservable And( this IObservable observable, IEnumerable> observables, - OperatorDistinctness operatorDistinctness = OperatorDistinctness.OutputDistinctUntilChanged) => - new[] { observable }.Concat(observables).And(operatorDistinctness); + OperatorDistinctness operatorDistinctness = OperatorDistinctness.OutputDistinctUntilChanged) + { + ArgumentNullException.ThrowIfNull(observable); + ArgumentNullException.ThrowIfNull(observables); + + return new[] { observable }.Concat(observables).And(operatorDistinctness); + } /// /// Returns an observable that combines the latest results of all observables using an AND operator. @@ -97,7 +113,7 @@ public static IObservable And( public static IObservable And( this IObservable observable, params IObservable[] observables) => - new[] { observable }.Concat(observables).And(); + observable.And(observables, OperatorDistinctness.OutputDistinctUntilChanged); /// /// Returns an observable that combines the latest results of all observables using an AND operator. @@ -296,7 +312,7 @@ public static IObservable AndOp( public static IObservable AndOp( this IObservable observable, params IObservable[] observables) => - new[] { observable }.Concat(observables).And(); + observable.And(observables); /// /// Returns an observable that combines the latest results of all observables using an AND operator. diff --git a/src/Reactive.Boolean/BooleanObservableExtensions.Operators.Or.cs b/src/Reactive.Boolean/BooleanObservableExtensions.Operators.Or.cs index 9ffaf7b..f62c0fb 100644 --- a/src/Reactive.Boolean/BooleanObservableExtensions.Operators.Or.cs +++ b/src/Reactive.Boolean/BooleanObservableExtensions.Operators.Or.cs @@ -55,17 +55,28 @@ public static IObservable Or( { ArgumentNullException.ThrowIfNull(source); + var observables = source.ToArray(); + if (observables.Any(o => o is null)) + { + throw new ArgumentException("The collection contains a null observable.", nameof(source)); + } + if (observables.Length == 0) + { + // CombineLatest over no sources never emits; the disjunction of nothing is vacuously false. + return Observable.Return(false); + } + if (operatorDistinctness == OperatorDistinctness.InputDistinctUntilChanged) { - source = source.Select(o => o.DistinctUntilChanged()); + observables = observables.Select(o => o.DistinctUntilChanged()).ToArray(); } if (operatorDistinctness == OperatorDistinctness.OutputDistinctUntilChanged) { - return source + return observables .CombineLatest(values => values.Any(v => v)) .DistinctUntilChanged(); } - return source.CombineLatest(values => values.Any(v => v)); + return observables.CombineLatest(values => values.Any(v => v)); } /// @@ -82,8 +93,13 @@ public static IObservable Or( public static IObservable Or( this IObservable observable, IEnumerable> observables, - OperatorDistinctness operatorDistinctness = OperatorDistinctness.OutputDistinctUntilChanged) => - new[] { observable }.Concat(observables).Or(operatorDistinctness); + OperatorDistinctness operatorDistinctness = OperatorDistinctness.OutputDistinctUntilChanged) + { + ArgumentNullException.ThrowIfNull(observable); + ArgumentNullException.ThrowIfNull(observables); + + return new[] { observable }.Concat(observables).Or(operatorDistinctness); + } /// /// Returns an observable that combines the latest results of two observables using an OR operator. @@ -93,7 +109,7 @@ public static IObservable Or( public static IObservable Or( this IObservable observable, params IObservable[] observables) => - new[] { observable }.Concat(observables).Or(); + observable.Or(observables, OperatorDistinctness.OutputDistinctUntilChanged); /// /// Returns an observable that combines the latest results of two observables using an OR operator. @@ -210,7 +226,7 @@ public static IObservable Nor( IObservable observable2, IObservable observable3, OperatorDistinctness operatorDistinctness) => - new[] { observable1, observable2, observable3 }.Or(operatorDistinctness); + observable1.Or(observable2, observable3, operatorDistinctness).Not(); /// /// Returns an observable that combines the latest results of two observables using an NOR operator. @@ -231,6 +247,6 @@ public static IObservable Nor( IObservable observable3, IObservable observable4, OperatorDistinctness operatorDistinctness) => - new[] { observable1, observable2, observable3, observable4 }.Or(operatorDistinctness); + observable1.Or(observable2, observable3, observable4, operatorDistinctness).Not(); } } diff --git a/src/Reactive.Boolean/BooleanObservableExtensions.Scheduling.cs b/src/Reactive.Boolean/BooleanObservableExtensions.Scheduling.cs index 5a90337..26a8141 100644 --- a/src/Reactive.Boolean/BooleanObservableExtensions.Scheduling.cs +++ b/src/Reactive.Boolean/BooleanObservableExtensions.Scheduling.cs @@ -15,6 +15,7 @@ public static partial class BooleanObservableExtensions /// If set to "false", the resulting observable will not be distinct. Both consecutive "true" and "false" values will be emitted. Note that consecutive "false" values that occur during the timer, will only be emitted as a single "false" once the timer runs out. /// If "true", every "true" that is emitted by will reset the timer. A "true" that follows a "false" always (re)starts the timer. A repeated "true" received after the timer ran out only starts a new timer when this is set. /// Determines what happens when completes while a "false" is being withheld: drop it and complete immediately (default), or emit it once the timer runs out and complete afterwards. + /// is zero or negative. /// public static IObservable TrueForAtLeast( this IObservable source, @@ -26,10 +27,7 @@ public static IObservable TrueForAtLeast( { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(scheduler); - if (timeSpan <= TimeSpan.Zero) - { - return source; - } + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeSpan, TimeSpan.Zero); return Observable.Create(observer => new TimedWindowOperator(observer, timeSpan, scheduler, distinctUntilChanged, resetTimerOnConsecutiveTrue, completionBehavior, @@ -47,6 +45,7 @@ public static IObservable TrueForAtLeast( /// If set to "false", the resulting observable will not be distinct. Both consecutive "true" and "false" values will be emitted. Note that consecutive "true" values that occur during the timer, will only be emitted as a single "true" once the timer runs out. /// If "true", every "false" that is emitted by will reset the timer. A "false" that follows a "true" always (re)starts the timer. A repeated "false" received after the timer ran out only starts a new timer when this is set. /// Determines what happens when completes while a "true" is being withheld: drop it and complete immediately (default), or emit it once the timer runs out and complete afterwards. + /// is zero or negative. /// public static IObservable FalseForAtLeast( this IObservable source, @@ -70,6 +69,7 @@ public static IObservable FalseForAtLeast( /// If "true", every "false" that is emitted by while the timer runs will reset the timer. /// If set to "false", the resulting observable will not be distinct. Both consecutive "true" and "false" values will be emitted. Note that consecutive "false" values that occur during the timer, will only be emitted as a single "false" once the timer runs out. /// Determines what happens when completes while a "false" is being delayed: drop it and complete immediately (default), or emit it once the timer runs out and complete afterwards. + /// is zero or negative. /// public static IObservable PersistTrueFor( this IObservable source, @@ -81,10 +81,7 @@ public static IObservable PersistTrueFor( { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(scheduler); - if (timeSpan <= TimeSpan.Zero) - { - return source; - } + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeSpan, TimeSpan.Zero); return Observable.Create(observer => new DelayedTransitionOperator(observer, timeSpan, scheduler, distinctUntilChanged, resetTimerOnConsecutiveFalse, completionBehavior, @@ -102,6 +99,7 @@ public static IObservable PersistTrueFor( /// If "true", every "true" that is emitted by while the timer runs will reset the timer. /// If set to "false", the resulting observable will not be distinct. Both consecutive "true" and "false" values will be emitted. Note that consecutive "true" values that occur during the timer, will only be emitted as a single "true" once the timer runs out. /// Determines what happens when completes while a "true" is being delayed: drop it and complete immediately (default), or emit it once the timer runs out and complete afterwards. + /// is zero or negative. /// public static IObservable PersistFalseFor( this IObservable source, @@ -125,6 +123,7 @@ public static IObservable PersistFalseFor( /// If "true", every "true" that is emitted by while the timer runs will reset the timer. /// If set to "false", the resulting observable will not be distinct. Consecutive "false" values are emitted, as are consecutive "true" values received after the timer ran out. "true" values received while the timer runs are not emitted. /// Determines what happens when completes while the timer runs: complete immediately without emitting "true" (default), or emit "true" once the timer runs out and complete afterwards. + /// is zero or negative. public static IObservable WhenTrueFor( this IObservable source, TimeSpan timeSpan, @@ -135,10 +134,7 @@ public static IObservable WhenTrueFor( { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(scheduler); - if (timeSpan <= TimeSpan.Zero) - { - return source; - } + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeSpan, TimeSpan.Zero); return Observable.Create(observer => new DelayedTransitionOperator(observer, timeSpan, scheduler, distinctUntilChanged, resetTimerOnConsecutiveTrue, completionBehavior, @@ -156,6 +152,7 @@ public static IObservable WhenTrueFor( /// If "true", every "false" that is emitted by while the timer runs will reset the timer. /// If set to "false", the resulting observable will not be distinct. Consecutive "true" values are emitted, as are consecutive "false" values received after the timer ran out. "false" values received while the timer runs are not emitted. /// Determines what happens when completes while the timer runs: complete immediately without emitting "false" (default), or emit "false" once the timer runs out and complete afterwards. + /// is zero or negative. public static IObservable WhenFalseFor( this IObservable source, TimeSpan timeSpan, @@ -178,6 +175,7 @@ public static IObservable WhenFalseFor( /// If "true", a repeated pending value that is emitted by while the timer runs will reset the timer. /// If set to "false", the resulting observable will not be distinct. Values equal to the last emitted value are passed through, including one that cancels a pending change. Values received while the timer runs are not emitted. /// Determines what happens when completes while a change is pending: complete immediately without emitting it (default), or emit it once the timer runs out and complete afterwards. + /// is zero or negative. /// public static IObservable WhenStableFor( this IObservable source, @@ -189,10 +187,7 @@ public static IObservable WhenStableFor( { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(scheduler); - if (timeSpan <= TimeSpan.Zero) - { - return source; - } + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeSpan, TimeSpan.Zero); return Observable.Create(observer => new DelayedTransitionOperator(observer, timeSpan, scheduler, distinctUntilChanged, resetTimerOnConsecutiveValue, completionBehavior, @@ -210,6 +205,7 @@ public static IObservable WhenStableFor( /// If set to "false", the resulting observable will not be distinct. Both consecutive "true" and "false" values will be emitted. /// If "true", every "true" that is emitted by will reset the timer. A "true" received after the limit was reached then re-arms the limit and is emitted again. /// Determines what happens when completes while the timer runs: complete immediately without emitting the limiting "false" (default), or emit it once the timer runs out and complete afterwards. + /// is zero or negative. /// public static IObservable LimitTrueDuration( this IObservable source, @@ -221,6 +217,7 @@ public static IObservable LimitTrueDuration( { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(scheduler); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeSpan, TimeSpan.Zero); return Observable.Create(observer => new TimedWindowOperator(observer, timeSpan, scheduler, distinctUntilChanged, resetTimerOnConsecutiveTrue, completionBehavior, @@ -238,6 +235,7 @@ public static IObservable LimitTrueDuration( /// If set to "false", the resulting observable will not be distinct. Both consecutive "true" and "false" values will be emitted. /// If "true", every "false" that is emitted by will reset the timer. A "false" received after the limit was reached then re-arms the limit and is emitted again. /// Determines what happens when completes while the timer runs: complete immediately without emitting the limiting "true" (default), or emit it once the timer runs out and complete afterwards. + /// is zero or negative. /// public static IObservable LimitFalseDuration( this IObservable source, @@ -261,6 +259,7 @@ public static IObservable LimitFalseDuration( /// If set to "false", the resulting observable will not be distinct. Both consecutive "true" and "false" values will be emitted. Note that "false" values that occur during the pulse are not emitted; the pulse always ends with a single "false". /// If "true", every "true" that is emitted by will restart the pulse, also after the pulse has ended. A "true" that follows a "false" always (re)starts the pulse. /// Determines what happens when completes during a pulse: complete immediately without emitting the closing "false" (default), or emit it once the pulse ends and complete afterwards. + /// is zero or negative. /// public static IObservable PulseTrueFor( this IObservable source, @@ -272,10 +271,7 @@ public static IObservable PulseTrueFor( { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(scheduler); - if (timeSpan <= TimeSpan.Zero) - { - return source; - } + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeSpan, TimeSpan.Zero); return Observable.Create(observer => new TimedWindowOperator(observer, timeSpan, scheduler, distinctUntilChanged, resetTimerOnConsecutiveTrue, completionBehavior, @@ -293,6 +289,7 @@ public static IObservable PulseTrueFor( /// If set to "false", the resulting observable will not be distinct. Both consecutive "true" and "false" values will be emitted. Note that "true" values that occur during the pulse are not emitted; the pulse always ends with a single "true". /// If "true", every "false" that is emitted by will restart the pulse, also after the pulse has ended. A "false" that follows a "true" always (re)starts the pulse. /// Determines what happens when completes during a pulse: complete immediately without emitting the closing "true" (default), or emit it once the pulse ends and complete afterwards. + /// is zero or negative. /// public static IObservable PulseFalseFor( this IObservable source, diff --git a/src/Reactive.Boolean/DelayedTransitionOperator.cs b/src/Reactive.Boolean/DelayedTransitionOperator.cs index 57afd3a..295b284 100644 --- a/src/Reactive.Boolean/DelayedTransitionOperator.cs +++ b/src/Reactive.Boolean/DelayedTransitionOperator.cs @@ -20,7 +20,7 @@ internal sealed class DelayedTransitionOperator( bool? assumedInitialValue) : TimedBooleanOperator(observer, timeSpan, scheduler, distinctUntilChanged, completionBehavior) { - protected override void OnSourceValue(bool value) + protected override void OnSourceValue(bool value, bool? previous) { if (LastEmittedValue == null) { diff --git a/src/Reactive.Boolean/TimedBooleanOperator.cs b/src/Reactive.Boolean/TimedBooleanOperator.cs index fabbe0f..bc702b8 100644 --- a/src/Reactive.Boolean/TimedBooleanOperator.cs +++ b/src/Reactive.Boolean/TimedBooleanOperator.cs @@ -24,8 +24,8 @@ internal abstract class TimedBooleanOperator( private bool _terminated; /// - /// Inside : the value that preceded the one being handled. - /// Inside : the latest value. Null until the source has emitted. + /// The latest value received from the source, including the one being handled inside . + /// Null until the source has emitted. /// protected bool? LastSourceValue { get; private set; } @@ -47,7 +47,9 @@ internal abstract class TimedBooleanOperator( /// protected virtual bool HasPendingValue => TimerRunning; - protected abstract void OnSourceValue(bool value); + /// The value just received from the source. + /// The value that preceded it, or null when this is the first one. + protected abstract void OnSourceValue(bool value, bool? previous); protected abstract void OnTimerElapsed(); @@ -61,7 +63,8 @@ public IDisposable Run(IObservable source) _terminated = true; } }); - return new CompositeDisposable(subscription, _timer, stop); + // Terminate first so a timer callback that is already dequeued cannot emit while the rest is torn down. + return new CompositeDisposable(stop, subscription, _timer); } protected void Emit(bool value) @@ -100,8 +103,11 @@ private void OnNext(bool value) return; } - OnSourceValue(value); + // Recorded before dispatching so a timer that fires synchronously (e.g. Scheduler.Immediate) or a source + // that is fed re-entrantly from the observer never reads or overwrites a stale value. + var previous = LastSourceValue; LastSourceValue = value; + OnSourceValue(value, previous); } } diff --git a/src/Reactive.Boolean/TimedWindowOperator.cs b/src/Reactive.Boolean/TimedWindowOperator.cs index fe68d0f..a4e77d0 100644 --- a/src/Reactive.Boolean/TimedWindowOperator.cs +++ b/src/Reactive.Boolean/TimedWindowOperator.cs @@ -21,7 +21,7 @@ internal sealed class TimedWindowOperator( { protected override bool HasPendingValue => TimerRunning && (forceFalseAtEnd || LastSourceValue == false); - protected override void OnSourceValue(bool value) + protected override void OnSourceValue(bool value, bool? previous) { if (!value) { @@ -36,7 +36,7 @@ protected override void OnSourceValue(bool value) } // A rising edge always opens a window; a repeated "true" only restarts it when asked to. - if (LastSourceValue != true) + if (previous != true) { Emit(true); StartTimer(); diff --git a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Operators.And.Tests.cs b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Operators.And.Tests.cs index c78beae..4975c20 100644 --- a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Operators.And.Tests.cs +++ b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Operators.And.Tests.cs @@ -251,5 +251,85 @@ public void And_Multiple_NotDistinct() subject2.OnNext(false); CollectionAssert.AreEqual(new[] { false, false, false, true, true, false, false }, results); } + [TestMethod] + [DataRow(false, false, true)] + [DataRow(false, true, true)] + [DataRow(true, false, true)] + [DataRow(true, true, false)] + public void Nand(bool input1, bool input2, bool expectedOutput) + { + var subject1 = new Subject(); + var subject2 = new Subject(); + var nand = subject1.Nand(subject2); + + bool? result = null; + nand.Subscribe(b => result = b); + + subject1.OnNext(input1); + subject2.OnNext(input2); + + Assert.AreEqual(expectedOutput, result); + } + + [TestMethod] + public void Nand_EveryOverload_IsInverseOfAnd() + { + for (var bits = 0; bits < 16; bits++) + { + var inputs = Enumerable.Range(0, 4).Select(i => (bits & (1 << i)) != 0).ToArray(); + var subjects = inputs.Select(_ => new Subject()).ToArray(); + var expectedThree = !(inputs[0] && inputs[1] && inputs[2]); + var expectedFour = !inputs.All(v => v); + + var overloads = new (string Name, IObservable Nand, bool Expected)[] + { + ("3-arity", subjects[0].Nand(subjects[1], subjects[2], OperatorDistinctness.NotDistinct), expectedThree), + ("4-arity", subjects[0].Nand(subjects[1], subjects[2], subjects[3], OperatorDistinctness.NotDistinct), expectedFour), + ("params", subjects[0].Nand(subjects[1], subjects[2], subjects[3]), expectedFour), + ("enumerable", subjects[0].Nand(subjects.Skip(1), OperatorDistinctness.NotDistinct), expectedFour), + ("collection", subjects.Nand(), expectedFour), + }; + var results = new bool?[overloads.Length]; + for (var i = 0; i < overloads.Length; i++) + { + var index = i; + overloads[i].Nand.Subscribe(b => results[index] = b); + } + + for (var i = 0; i < subjects.Length; i++) + { + subjects[i].OnNext(inputs[i]); + } + + for (var i = 0; i < overloads.Length; i++) + { + Assert.AreEqual(overloads[i].Expected, results[i], $"{overloads[i].Name} with inputs {string.Join(",", inputs)}"); + } + } + } + + [TestMethod] + public void And_EmptyCollection_EmitsTrueAndCompletes() + { + var results = new List(); + var completed = false; + + Array.Empty>().And().Subscribe(results.Add, () => completed = true); + + CollectionAssert.AreEqual(new[] { true }, results); + Assert.IsTrue(completed); + } + + [TestMethod] + public void And_NullObservable_Throws() + { + var subject = new Subject(); + + Assert.ThrowsExactly(() => ((IObservable)null!).And(subject, subject)); + Assert.ThrowsExactly(() => subject.And((IEnumerable>)null!)); + Assert.ThrowsExactly(() => subject.And(new IObservable[] { null! })); + Assert.ThrowsExactly(() => subject.And(subject, null!, subject, OperatorDistinctness.NotDistinct)); + } + } } \ No newline at end of file diff --git a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Operators.Or.Tests.cs b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Operators.Or.Tests.cs index 882a67e..bd09e4d 100644 --- a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Operators.Or.Tests.cs +++ b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Operators.Or.Tests.cs @@ -260,5 +260,85 @@ public void Or_Multiple_NotDistinct() subject2.OnNext(true); CollectionAssert.AreEqual(new[] { true, true, true, false, false, true, true }, results); } + [TestMethod] + [DataRow(false, false, true)] + [DataRow(false, true, false)] + [DataRow(true, false, false)] + [DataRow(true, true, false)] + public void Nor(bool input1, bool input2, bool expectedOutput) + { + var subject1 = new Subject(); + var subject2 = new Subject(); + var nor = subject1.Nor(subject2); + + bool? result = null; + nor.Subscribe(b => result = b); + + subject1.OnNext(input1); + subject2.OnNext(input2); + + Assert.AreEqual(expectedOutput, result); + } + + [TestMethod] + public void Nor_EveryOverload_IsInverseOfOr() + { + for (var bits = 0; bits < 16; bits++) + { + var inputs = Enumerable.Range(0, 4).Select(i => (bits & (1 << i)) != 0).ToArray(); + var subjects = inputs.Select(_ => new Subject()).ToArray(); + var expectedThree = !(inputs[0] || inputs[1] || inputs[2]); + var expectedFour = !inputs.Any(v => v); + + var overloads = new (string Name, IObservable Nor, bool Expected)[] + { + ("3-arity", subjects[0].Nor(subjects[1], subjects[2], OperatorDistinctness.NotDistinct), expectedThree), + ("4-arity", subjects[0].Nor(subjects[1], subjects[2], subjects[3], OperatorDistinctness.NotDistinct), expectedFour), + ("params", subjects[0].Nor(subjects[1], subjects[2], subjects[3]), expectedFour), + ("enumerable", subjects[0].Nor(subjects.Skip(1), OperatorDistinctness.NotDistinct), expectedFour), + ("collection", subjects.Nor(), expectedFour), + }; + var results = new bool?[overloads.Length]; + for (var i = 0; i < overloads.Length; i++) + { + var index = i; + overloads[i].Nor.Subscribe(b => results[index] = b); + } + + for (var i = 0; i < subjects.Length; i++) + { + subjects[i].OnNext(inputs[i]); + } + + for (var i = 0; i < overloads.Length; i++) + { + Assert.AreEqual(overloads[i].Expected, results[i], $"{overloads[i].Name} with inputs {string.Join(",", inputs)}"); + } + } + } + + [TestMethod] + public void Or_EmptyCollection_EmitsFalseAndCompletes() + { + var results = new List(); + var completed = false; + + Array.Empty>().Or().Subscribe(results.Add, () => completed = true); + + CollectionAssert.AreEqual(new[] { false }, results); + Assert.IsTrue(completed); + } + + [TestMethod] + public void Or_NullObservable_Throws() + { + var subject = new Subject(); + + Assert.ThrowsExactly(() => ((IObservable)null!).Or(subject, subject)); + Assert.ThrowsExactly(() => subject.Or((IEnumerable>)null!)); + Assert.ThrowsExactly(() => subject.Or(new IObservable[] { null! })); + Assert.ThrowsExactly(() => subject.Or(subject, null!, subject, OperatorDistinctness.NotDistinct)); + } + } } \ No newline at end of file diff --git a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.LimitTrueDuration.Tests.cs b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.LimitTrueDuration.Tests.cs index 8f88bfe..8e2d90e 100644 --- a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.LimitTrueDuration.Tests.cs +++ b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.LimitTrueDuration.Tests.cs @@ -521,5 +521,15 @@ public void LimitTrueDuration_DisposeCancelsTimer(bool distinctUntilChanged, boo scheduler.AdvanceBy(2); CollectionAssert.AreEqual(new[] { true }, results); } + [TestMethod] + public void LimitTrueDuration_ZeroOrNegativeTimeSpan_Throws() + { + var subject = new Subject(); + var scheduler = new TestScheduler(); + + Assert.ThrowsExactly(() => subject.LimitTrueDuration(TimeSpan.Zero, scheduler)); + Assert.ThrowsExactly(() => subject.LimitTrueDuration(TimeSpan.FromTicks(-1), scheduler)); + } + } } \ No newline at end of file diff --git a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.PersistTrueFor.Tests.cs b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.PersistTrueFor.Tests.cs index 195bc23..46b855b 100644 --- a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.PersistTrueFor.Tests.cs +++ b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.PersistTrueFor.Tests.cs @@ -476,5 +476,15 @@ public void PersistTrueFor_DisposeCancelsTimer(bool resetTimerOnConsecutiveFalse scheduler.AdvanceBy(2); CollectionAssert.AreEqual(new[] { true }, results); } + [TestMethod] + public void PersistTrueFor_ZeroOrNegativeTimeSpan_Throws() + { + var subject = new Subject(); + var scheduler = new TestScheduler(); + + Assert.ThrowsExactly(() => subject.PersistTrueFor(TimeSpan.Zero, scheduler)); + Assert.ThrowsExactly(() => subject.PersistTrueFor(TimeSpan.FromTicks(-1), scheduler)); + } + } } \ No newline at end of file diff --git a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.PulseTrueFor.Tests.cs b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.PulseTrueFor.Tests.cs index cca4af6..54fc8ac 100644 --- a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.PulseTrueFor.Tests.cs +++ b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.PulseTrueFor.Tests.cs @@ -479,11 +479,13 @@ public void PulseTrueFor_DisposeCancelsTimer(bool distinctUntilChanged, bool res } [TestMethod] - public void PulseTrueFor_ZeroTimeSpan_ReturnsSource() + public void PulseTrueFor_ZeroOrNegativeTimeSpan_Throws() { var subject = new Subject(); + var scheduler = new TestScheduler(); - Assert.AreSame(subject, subject.PulseTrueFor(TimeSpan.Zero, new TestScheduler())); + Assert.ThrowsExactly(() => subject.PulseTrueFor(TimeSpan.Zero, scheduler)); + Assert.ThrowsExactly(() => subject.PulseTrueFor(TimeSpan.FromTicks(-1), scheduler)); } [TestMethod] diff --git a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.TrueForAtLeast.Tests.cs b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.TrueForAtLeast.Tests.cs index a2f76b9..70d4d11 100644 --- a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.TrueForAtLeast.Tests.cs +++ b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.TrueForAtLeast.Tests.cs @@ -566,5 +566,15 @@ public void TrueForAtLeast_DisposeCancelsTimer(bool distinctUntilChanged, bool r scheduler.AdvanceBy(2); CollectionAssert.AreEqual(new[] { true }, results); } + [TestMethod] + public void TrueForAtLeast_ZeroOrNegativeTimeSpan_Throws() + { + var subject = new Subject(); + var scheduler = new TestScheduler(); + + Assert.ThrowsExactly(() => subject.TrueForAtLeast(TimeSpan.Zero, scheduler)); + Assert.ThrowsExactly(() => subject.TrueForAtLeast(TimeSpan.FromTicks(-1), scheduler)); + } + } } \ No newline at end of file diff --git a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.WhenStableFor.Tests.cs b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.WhenStableFor.Tests.cs index 545e5aa..b82edc1 100644 --- a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.WhenStableFor.Tests.cs +++ b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.WhenStableFor.Tests.cs @@ -449,11 +449,37 @@ public void WhenStableFor_DisposeCancelsTimer(bool resetTimerOnConsecutiveValue, } [TestMethod] - public void WhenStableFor_ZeroTimeSpan_ReturnsSource() + public void WhenStableFor_ZeroOrNegativeTimeSpan_Throws() { var subject = new Subject(); + var scheduler = new TestScheduler(); + + Assert.ThrowsExactly(() => subject.WhenStableFor(TimeSpan.Zero, scheduler)); + Assert.ThrowsExactly(() => subject.WhenStableFor(TimeSpan.FromTicks(-1), scheduler)); + } - Assert.AreSame(subject, subject.WhenStableFor(TimeSpan.Zero, new TestScheduler())); + [TestMethod] + public void WhenStableFor_ValueFedBackFromObserver_IsNotLost() + { + var subject = new Subject(); + var scheduler = new TestScheduler(); + var stableObservable = subject.WhenStableFor(TimeSpan.FromTicks(2), scheduler); + + var results = new List(); + stableObservable.Subscribe(b => + { + results.Add(b); + if (b) + { + subject.OnNext(false); + } + }); + + subject.OnNext(true); + CollectionAssert.AreEqual(new[] { true }, results); + + scheduler.AdvanceBy(2); + CollectionAssert.AreEqual(new[] { true, false }, results); } } } diff --git a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.WhenTrueFor.Tests.cs b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.WhenTrueFor.Tests.cs index 1776144..bef82e7 100644 --- a/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.WhenTrueFor.Tests.cs +++ b/tests/Reactive.Boolean.Tests/BooleanObservableExtensions.Scheduling.WhenTrueFor.Tests.cs @@ -1,4 +1,5 @@ using Microsoft.Reactive.Testing; +using System.Reactive.Concurrency; using System.Reactive.Linq; using System.Reactive.Subjects; @@ -473,5 +474,28 @@ public void WhenTrueFor_DisposeCancelsTimer(bool resetTimerOnConsecutiveTrue, bo scheduler.AdvanceBy(2); CollectionAssert.AreEqual(new[] { false }, results); } + + [TestMethod] + public void WhenTrueFor_ZeroOrNegativeTimeSpan_Throws() + { + var subject = new Subject(); + var scheduler = new TestScheduler(); + + Assert.ThrowsExactly(() => subject.WhenTrueFor(TimeSpan.Zero, scheduler)); + Assert.ThrowsExactly(() => subject.WhenTrueFor(TimeSpan.FromTicks(-1), scheduler)); + } + + [TestMethod] + public void WhenTrueFor_ImmediateScheduler_TimerFiresInline() + { + var subject = new Subject(); + var memoryObservable = subject.WhenTrueFor(TimeSpan.FromTicks(1), Scheduler.Immediate); + + var results = new List(); + memoryObservable.Subscribe(results.Add); + + subject.OnNext(true); + CollectionAssert.AreEqual(new[] { false, true }, results); + } } } \ No newline at end of file