diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 54197f3..b78a452 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -10,7 +10,27 @@ permissions: contents: read jobs: + sonarcloud-token: + name: validate-token + runs-on: ubuntu-latest + outputs: + available: ${{ steps.check.outputs.available }} + steps: + - name: Check SonarCloud token + id: check + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + if [ -n "${SONAR_TOKEN:-}" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + echo "::notice::SONAR_TOKEN is not configured; skipping SonarCloud analysis for this run." + fi + sonarcloud: + needs: sonarcloud-token + if: ${{ needs.sonarcloud-token.outputs.available == 'true' }} uses: reactiveui/actions-common/.github/workflows/workflow-common-sonarcloud.yml@main with: productNamespacePrefix: ReactiveUI.Primitives diff --git a/README.md b/README.md index 2c8dab4..5a58062 100644 --- a/README.md +++ b/README.md @@ -596,12 +596,43 @@ Use the generated bridge only at boundaries. Prefer native ReactiveUI.Primitives ## Benchmarks and performance posture -Benchmarks live in `src/ReactiveUI.Primitives.Benchmarks`. The benchmark project may reference System.Reactive and R3 to compare throughput and allocation behavior; the production package must not. +Benchmarks live in `src/benchmarks/ReactiveUI.Primitives.Benchmarks`. The benchmark project may reference System.Reactive and R3 to compare throughput and allocation behavior; the production package must not. -Recovered benchmark evidence in `docs/PERFORMANCE.md` records that the optimized `Signal` single-subscriber dispatch path outperformed System.Reactive and R3 on focused subject throughput cases: +The latest joined BenchmarkDotNet ShortRun was captured on 2026-05-25 with .NET SDK 10.0.300 on Windows 11, using: -- Count=32: ReactiveUI.Primitives 88.21 ns / 208 B; System.Reactive 117.55 ns / 224 B; R3 139.75 ns / 232 B. -- Count=1024: ReactiveUI.Primitives 1,620.33 ns / 208 B; System.Reactive 1,751.44 ns / 224 B; R3 2,396.59 ns / 232 B. +```powershell +dotnet run --project src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj --configuration Release --no-build -- -f '*' -j Short --join +``` + +Raw artifacts for the joined run are under `BenchmarkDotNet.Artifacts/results/BenchmarkRun-joined-2026-05-25-21-12-14-report.*`. The focused `FromEnumerable` row was captured in `src/BenchmarkDotNet.Artifacts/results/ReactiveUI.Primitives.Benchmarks.FactoryFromEnumerableBenchmarks-report.*` after the dedicated inline fast path was added. ShortRun is useful for fast regression checks; rerun with a longer BenchmarkDotNet job before making release claims. + +| Scenario | ReactiveUI.Primitives | System.Reactive | R3 | +|---|---:|---:|---:| +| Completed task bridge | 17.6833 ns / 88 B | 1,348.2890 ns / 793 B | n/a | +| Pocket / composite dispose | 90.8799 ns / 408 B | 138.6110 ns / 512 B | n/a | +| Current-thread schedule | 22.8205 ns / 88 B | 28.3162 ns / 88 B | n/a | +| Safe witness wrapper | 40.2300 ns / 168 B | n/a | n/a | +| Completed spark | 0.3007 ns / 0 B | n/a | n/a | +| Return subscribe | 0.4417 ns / 0 B | 91.5187 ns / 120 B | 49.3844 ns / 72 B | +| Empty subscribe | 7.3897 ns / 40 B | 79.6293 ns / 96 B | 43.8897 ns / 48 B | +| Range subscribe | 55.9990 ns / 96 B | 4,153.4012 ns / 2,472 B | 119.9919 ns / 72 B | +| Repeat subscribe | 10.3262 ns / 0 B | 3,951.5395 ns / 2,408 B | 116.7110 ns / 72 B | +| FromEnumerable subscribe | 48.9910 ns / 40 B | 3,740.3600 ns / 2,504 B | 131.3610 ns / 80 B | +| Throw subscribe | 100.3490 ns / 120 B | 190.9367 ns / 240 B | 158.5640 ns / 192 B | +| Map + Keep | 213.9322 ns / 208 B | 4,463.8969 ns / 2,616 B | 423.8154 ns / 264 B | +| DistinctBy + Count + Any | 427.3704 ns / 992 B | 8,842.7094 ns / 5,896 B | 932.2863 ns / 1,280 B | +| StartWith + Append + DefaultIfEmpty | 79.0351 ns / 184 B | 1,511.0960 ns / 1,257 B | 226.6506 ns / 280 B | +| SelectMany over ranges | 1,174.3683 ns / 712 B | 5,989.3754 ns / 3,872 B | 1,530.4454 ns / 1,032 B | +| Zip over ranges | 1,920.5231 ns / 1,320 B | 5,434.1159 ns / 2,976 B | 1,103.3186 ns / 648 B | +| Replay subscribe | 491.2126 ns / 320 B | 944.9225 ns / 696 B | n/a | +| Behaviour signal, 32 values | 717.1898 ns / 176 B | 735.4731 ns / 200 B | 831.3793 ns / 184 B | +| Behaviour signal, 1024 values | 19,587.6333 ns / 176 B | 18,925.1658 ns / 200 B | 21,464.7502 ns / 184 B | +| Signal subscribe/dispose, 8 subscribers | 415.4351 ns / 1,176 B | 506.4101 ns / 1,288 B | 719.0130 ns / 840 B | +| Signal subscribe/dispose, 64 subscribers | 4,503.8029 ns / 8,864 B | 8,526.7609 ns / 38,472 B | 5,480.4075 ns / 6,216 B | +| Signal emit, 32 values | 108.2371 ns / 160 B | 122.6897 ns / 136 B | 213.9175 ns / 152 B | +| Signal emit, 1024 values | 2,130.8298 ns / 160 B | 1,994.6875 ns / 136 B | 3,677.6208 ns / 152 B | + +Current benchmark coverage is intentionally visible rather than overstated. The next benchmark expansion areas are factory/adapters (`Never`, `Create`, `Defer`, `FromEnumerable`, `FromAsyncEnumerable`, `Start`, `Unfold`, `Use`), time/scheduler operators (`Delay`, `DelayStart`, `Throttle`, `Sample`, `Timestamp`, `TimeInterval`, `Timeout`, `ObserveOn`), higher-order combinators (`Concat`, `Merge`, `Race`, `Switch`, `CombineLatest`, `WithLatest`, `ForkJoin`), terminal/collection APIs, connectable/share APIs, and state/task command surfaces. Performance constraints used by the project: @@ -619,7 +650,7 @@ Performance constraints used by the project: | `src/ReactiveUI.Primitives.SystemReactiveBridge.Generator` | Source generator for System.Reactive bridge adapters. | | `src/ReactiveUI.Primitives.R3Bridge.Generator` | Source generator for R3 bridge adapters. | | `src/ReactiveUI.Primitives.Tests` | Test project using Microsoft Testing Platform/TUnit-style validation. | -| `src/ReactiveUI.Primitives.Benchmarks` | BenchmarkDotNet comparison harness. | +| `src/benchmarks/ReactiveUI.Primitives.Benchmarks` | BenchmarkDotNet comparison harness. | | `docs/API-COVERAGE.md` | Public API inventory and parity notes. | | `docs/PERFORMANCE.md` | Benchmark plan and recovered benchmark evidence. | | `docs/TASKLIST.md` | Project task/status notes. | @@ -640,7 +671,7 @@ git diff --check To run the focused benchmark used by the performance notes: ```bash -"/mnt/c/Program Files/dotnet/dotnet.exe" run --project src/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj --configuration Release --no-build -- --filter '*SubjectThroughput*' +"/mnt/c/Program Files/dotnet/dotnet.exe" run --project src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj --configuration Release --no-build -- --filter '*SubjectThroughput*' ``` For NuGet package verification, inspect the generated `.nupkg` and confirm: diff --git a/src/ReactiveUI.Primitives/Signal/Signal{T}.cs b/src/ReactiveUI.Primitives/Signal/Signal{T}.cs index d303ea5..8b7553e 100644 --- a/src/ReactiveUI.Primitives/Signal/Signal{T}.cs +++ b/src/ReactiveUI.Primitives/Signal/Signal{T}.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Runtime.ExceptionServices; +using System.Threading; using ReactiveUI.Primitives.Disposables; namespace ReactiveUI.Primitives.Signals; @@ -34,7 +35,7 @@ public class Signal : ISignal /// Executes the new operation. /// /// The result. - private readonly object _observerLock = new(); + private SpinLock _observerLock = new(false); /// /// Stores state for the signal implementation. @@ -46,6 +47,11 @@ public class Signal : ISignal /// private SignalSubscription? _singleActionSubscription; + /// + /// Stores state for the signal implementation. + /// + private SignalSubscription? _singleObserverSubscription; + /// /// Stores state for the signal implementation. /// @@ -79,7 +85,7 @@ public class Signal : ISignal /// /// Gets a value indicating whether indicates whether the subject has observers subscribed to it. /// - public virtual bool HasObservers => (_singleActionSubscription != null || _subscriptionCount != 0) && !_isStopped; + public virtual bool HasObservers => (_singleActionSubscription != null || _singleObserverSubscription != null || _subscriptionCount != 0) && !_isStopped; /// /// Gets a value indicating whether indicates whether the subject has been disposed. @@ -100,21 +106,32 @@ public void Dispose() /// public void OnCompleted() { + SignalSubscription? singleObserver; SignalSubscription?[]? subscriptions; + var lockTaken = false; - lock (_observerLock) + try { + _observerLock.Enter(ref lockTaken); ThrowIfDisposed(); if (_isStopped) { return; } + singleObserver = _singleObserverSubscription; subscriptions = ClearObserversLocked(); _isStopped = true; } + finally + { + if (lockTaken) + { + _observerLock.Exit(false); + } + } - Completed(subscriptions); + Completed(singleObserver, subscriptions); } /// @@ -128,11 +145,14 @@ public void OnError(Exception error) throw new ArgumentNullException(nameof(error)); } + SignalSubscription? singleObserver; SignalSubscription?[]? subscriptions; var hasActionSubscribers = false; + var lockTaken = false; - lock (_observerLock) + try { + _observerLock.Enter(ref lockTaken); ThrowIfDisposed(); if (_isStopped) { @@ -141,11 +161,19 @@ public void OnError(Exception error) _exception = error; hasActionSubscribers = _singleActionSubscription != null || HasActionSubscribers(_subscriptions); + singleObserver = _singleObserverSubscription; subscriptions = ClearObserversLocked(); _isStopped = true; } + finally + { + if (lockTaken) + { + _observerLock.Exit(false); + } + } - Error(subscriptions, error); + Error(singleObserver, subscriptions, error); if (!hasActionSubscribers) { return; @@ -158,7 +186,17 @@ public void OnError(Exception error) /// Called when [next]. /// /// The value. - public void OnNext(T value) => _onNext(value); + public void OnNext(T value) + { + var singleObserver = Volatile.Read(ref _singleObserverSubscription); + if (singleObserver != null) + { + singleObserver.Observer!.OnNext(value); + return; + } + + _onNext(value); + } /// /// Subscribes the specified observer. @@ -177,18 +215,36 @@ public IDisposable Subscribe(IObserver observer) Exception? ex; bool stopped; SignalSubscription? subscription = null; + var lockTaken = false; - lock (_observerLock) + try { + _observerLock.Enter(ref lockTaken); ThrowIfDisposed(); stopped = _isStopped; ex = _exception; if (!stopped) { - PromoteSingleActionObserverLocked(); - subscription = new SignalSubscription(this, observer); - AddSubscriptionLocked(subscription); - _onNext = DispatchSubscriptions; + if (_singleActionSubscription == null && _singleObserverSubscription == null && _subscriptionCount == 0) + { + subscription = new SignalSubscription(this, observer); + _singleObserverSubscription = subscription; + } + else + { + PromoteSingleObserverLocked(); + PromoteSingleActionObserverLocked(); + subscription = new SignalSubscription(this, observer); + AddSubscriptionLocked(subscription); + _onNext = DispatchSubscriptions; + } + } + } + finally + { + if (lockTaken) + { + _observerLock.Exit(false); } } @@ -224,28 +280,38 @@ internal IDisposable SubscribeAction(Action onNext) Exception? ex; bool stopped; SignalSubscription? subscription = null; + var lockTaken = false; - lock (_observerLock) + try { + _observerLock.Enter(ref lockTaken); ThrowIfDisposed(); stopped = _isStopped; ex = _exception; if (!stopped) { subscription = new SignalSubscription(this, onNext); - if (_singleActionSubscription == null && _subscriptionCount == 0) + if (_singleActionSubscription == null && _singleObserverSubscription == null && _subscriptionCount == 0) { _singleActionSubscription = subscription; _onNext = onNext; } else { + PromoteSingleObserverLocked(); PromoteSingleActionObserverLocked(); AddSubscriptionLocked(subscription); _onNext = DispatchSubscriptions; } } } + finally + { + if (lockTaken) + { + _observerLock.Exit(false); + } + } if (subscription != null) { @@ -277,17 +343,29 @@ protected virtual void Dispose(bool disposing) } SignalSubscription? singleActionSubscription; + SignalSubscription? singleObserverSubscription; SignalSubscription?[]? subscriptions; - lock (_observerLock) + var lockTaken = false; + try { + _observerLock.Enter(ref lockTaken); singleActionSubscription = _singleActionSubscription; + singleObserverSubscription = _singleObserverSubscription; subscriptions = ClearObserversLocked(); _exception = null; _onNext = ThrowDisposedOnNext; _isDisposed = true; } + finally + { + if (lockTaken) + { + _observerLock.Exit(false); + } + } singleActionSubscription?.Dispose(); + singleObserverSubscription?.Dispose(); DisposeSubscriptions(subscriptions); } @@ -299,9 +377,11 @@ protected virtual void Dispose(bool disposing) /// /// Executes the Completed operation. /// + /// The single observer fast-path subscription. /// The subscriptions value. - private static void Completed(SignalSubscription?[]? subscriptions) + private static void Completed(SignalSubscription? singleObserver, SignalSubscription?[]? subscriptions) { + singleObserver?.Observer?.OnCompleted(); if (subscriptions == null) { return; @@ -316,10 +396,12 @@ private static void Completed(SignalSubscription?[]? subscriptions) /// /// Executes the Error operation. /// + /// The single observer fast-path subscription. /// The subscriptions value. /// The exception value. - private static void Error(SignalSubscription?[]? subscriptions, Exception exception) + private static void Error(SignalSubscription? singleObserver, SignalSubscription?[]? subscriptions, Exception exception) { + singleObserver?.Observer?.OnError(exception); if (subscriptions == null) { return; @@ -431,6 +513,7 @@ private void AddSubscriptionLocked(SignalSubscription subscription) private SignalSubscription?[]? ClearObserversLocked() { _singleActionSubscription = null; + _singleObserverSubscription = null; var subscriptions = _subscriptions; Volatile.Write(ref _subscriptions, null); _subscriptionCount = 0; @@ -454,38 +537,95 @@ private void PromoteSingleActionObserverLocked() AddSubscriptionLocked(single); } + /// + /// Executes the PromoteSingleObserverLocked operation. + /// + private void PromoteSingleObserverLocked() + { + var single = _singleObserverSubscription; + if (single == null) + { + return; + } + + _singleObserverSubscription = null; + AddSubscriptionLocked(single); + } + /// /// Executes the Remove operation. /// /// The subscription value. private void Remove(SignalSubscription subscription) { - lock (_observerLock) + var lockTaken = false; + try { - if (ReferenceEquals(_singleActionSubscription, subscription)) + _observerLock.Enter(ref lockTaken); + if (RemoveSingleSubscriptionLocked(subscription)) { - _singleActionSubscription = null; - _onNext = _subscriptionCount == 0 ? NoopOnNext : DispatchSubscriptions; return; } - var subscriptions = _subscriptions; - var index = subscription.Index; - if (subscriptions == null || - (uint)index >= (uint)subscriptions.Length || - !ReferenceEquals(subscriptions[index], subscription)) + RemoveArraySubscriptionLocked(subscription); + } + finally + { + if (lockTaken) { - return; + _observerLock.Exit(false); } + } + } - Volatile.Write(ref subscriptions[index], null); - _subscriptionCount--; - if (_subscriptionCount == 0) - { - _subscriptionTail = 0; - _onNext = NoopOnNext; - } + /// + /// Removes a single-subscription fast path entry. + /// + /// The subscription value. + /// true when a single subscription was removed; otherwise, false. + private bool RemoveSingleSubscriptionLocked(SignalSubscription subscription) + { + if (ReferenceEquals(_singleActionSubscription, subscription)) + { + _singleActionSubscription = null; + _onNext = _subscriptionCount == 0 && _singleObserverSubscription == null ? NoopOnNext : DispatchSubscriptions; + return true; } + + if (!ReferenceEquals(_singleObserverSubscription, subscription)) + { + return false; + } + + _singleObserverSubscription = null; + _onNext = _subscriptionCount == 0 && _singleActionSubscription == null ? NoopOnNext : DispatchSubscriptions; + return true; + } + + /// + /// Removes an array-backed subscription. + /// + /// The subscription value. + private void RemoveArraySubscriptionLocked(SignalSubscription subscription) + { + var subscriptions = _subscriptions; + var index = subscription.Index; + if (subscriptions == null || + (uint)index >= (uint)subscriptions.Length || + !ReferenceEquals(subscriptions[index], subscription)) + { + return; + } + + Volatile.Write(ref subscriptions[index], null); + _subscriptionCount--; + if (_subscriptionCount != 0) + { + return; + } + + _subscriptionTail = 0; + _onNext = _singleActionSubscription == null && _singleObserverSubscription == null ? NoopOnNext : DispatchSubscriptions; } /// diff --git a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.AggregateHelpers.cs b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.AggregateHelpers.cs new file mode 100644 index 0000000..44cba1f --- /dev/null +++ b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.AggregateHelpers.cs @@ -0,0 +1,1158 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Core; + +namespace ReactiveUI.Primitives; + +/// +/// Private helper types for aggregate and distinct parity operators. +/// +public static partial class LinqMixins +{ + /// + /// Source that can count values through an operator-specific fast path. + /// + private interface ICountSource + { + /// + /// Subscribes a count observer directly to the underlying source. + /// + /// The downstream observer. + /// The subscription cleanup. + IDisposable SubscribeCount(IObserver observer); + + /// + /// Subscribes a long-count observer directly to the underlying source. + /// + /// The downstream observer. + /// The subscription cleanup. + IDisposable SubscribeLongCount(IObserver observer); + } + + /// + /// Distinct-by operator implemented without delegate observer wrappers. + /// + /// The source value type. + /// The key type. + private sealed class DistinctBySignal : IRequireCurrentThread, ICountSource + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// The key selector. + /// + private readonly Func _keySelector; + + /// + /// The key comparer. + /// + private readonly IEqualityComparer? _comparer; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// The key selector. + /// The key comparer. + internal DistinctBySignal(IObservable source, Func keySelector, IEqualityComparer? comparer) + { + _source = source; + _keySelector = keySelector; + _comparer = comparer; + } + + /// + public bool IsRequiredSubscribeOnCurrentThread() => + _source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread(); + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + var sink = new DistinctByObserver(observer, _keySelector, _comparer); + sink.SetSubscription(_source.Subscribe(sink)); + return sink; + } + + /// + public IDisposable SubscribeCount(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + var sink = new DistinctByCountObserver(observer, _keySelector, _comparer); + sink.SetSubscription(_source.Subscribe(sink)); + return sink; + } + + /// + public IDisposable SubscribeLongCount(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + var sink = new DistinctByLongCountObserver(observer, _keySelector, _comparer); + sink.SetSubscription(_source.Subscribe(sink)); + return sink; + } + } + + /// + /// Count operator implemented without fold composition. + /// + /// The source value type. + private sealed class CountSignal : IRequireCurrentThread + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + internal CountSignal(IObservable source) => _source = source; + + /// + public bool IsRequiredSubscribeOnCurrentThread() => + _source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread(); + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + if (_source is ICountSource countSource) + { + return countSource.SubscribeCount(observer); + } + + var sink = new CountObserver(observer); + sink.SetSubscription(_source.Subscribe(sink)); + return sink; + } + } + + /// + /// Predicate count operator implemented without fold composition. + /// + /// The source value type. + private sealed class CountPredicateSignal : IRequireCurrentThread + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// The predicate. + /// + private readonly Func _predicate; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// The predicate. + internal CountPredicateSignal(IObservable source, Func predicate) + { + _source = source; + _predicate = predicate; + } + + /// + public bool IsRequiredSubscribeOnCurrentThread() => + _source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread(); + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + var sink = new CountPredicateObserver(observer, _predicate); + sink.SetSubscription(_source.Subscribe(sink)); + return sink; + } + } + + /// + /// Long-count operator implemented without fold composition. + /// + /// The source value type. + private sealed class LongCountSignal : IRequireCurrentThread + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + internal LongCountSignal(IObservable source) => _source = source; + + /// + public bool IsRequiredSubscribeOnCurrentThread() => + _source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread(); + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + if (_source is ICountSource countSource) + { + return countSource.SubscribeLongCount(observer); + } + + var sink = new LongCountObserver(observer); + sink.SetSubscription(_source.Subscribe(sink)); + return sink; + } + } + + /// + /// Predicate long-count operator implemented without fold composition. + /// + /// The source value type. + private sealed class LongCountPredicateSignal : IRequireCurrentThread + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// The predicate. + /// + private readonly Func _predicate; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// The predicate. + internal LongCountPredicateSignal(IObservable source, Func predicate) + { + _source = source; + _predicate = predicate; + } + + /// + public bool IsRequiredSubscribeOnCurrentThread() => + _source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread(); + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + var sink = new LongCountPredicateObserver(observer, _predicate); + sink.SetSubscription(_source.Subscribe(sink)); + return sink; + } + } + + /// + /// Any operator implemented without predicate composition. + /// + /// The source value type. + private sealed class AnySignal : IRequireCurrentThread + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + internal AnySignal(IObservable source) => _source = source; + + /// + public bool IsRequiredSubscribeOnCurrentThread() => + _source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread(); + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + var sink = new AnyObserver(observer); + sink.SetSubscription(_source.Subscribe(sink)); + return sink; + } + } + + /// + /// Predicate any operator implemented without delegate observer wrappers. + /// + /// The source value type. + private sealed class AnyPredicateSignal : IRequireCurrentThread + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// The predicate. + /// + private readonly Func _predicate; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// The predicate. + internal AnyPredicateSignal(IObservable source, Func predicate) + { + _source = source; + _predicate = predicate; + } + + /// + public bool IsRequiredSubscribeOnCurrentThread() => + _source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread(); + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + var sink = new AnyPredicateObserver(observer, _predicate); + sink.SetSubscription(_source.Subscribe(sink)); + return sink; + } + } + + /// + /// Observer for distinct-by. + /// + /// The source value type. + /// The key type. + private sealed class DistinctByObserver : SingleSourceObserver + { + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// The key selector. + /// + private readonly Func _keySelector; + + /// + /// The observed keys. + /// + private readonly HashSet _seen; + + /// + /// A value indicating whether the observer has terminated. + /// + private bool _done; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + /// The key selector. + /// The key comparer. + internal DistinctByObserver(IObserver observer, Func keySelector, IEqualityComparer? comparer) + { + _observer = observer; + _keySelector = keySelector; + _seen = comparer == null ? [] : new(comparer); + } + + /// + public override void OnNext(T value) + { + if (_done || !_seen.Add(_keySelector(value))) + { + return; + } + + try + { + _observer.OnNext(value); + } + catch + { + Dispose(); + throw; + } + } + + /// + public override void OnError(Exception error) + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnError(error); + } + finally + { + Dispose(); + } + } + + /// + public override void OnCompleted() + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + } + + /// + /// Observer for counting all values. + /// + /// The source value type. + private sealed class CountObserver : SingleSourceObserver + { + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// The running count. + /// + private int _count; + + /// + /// A value indicating whether the observer has terminated. + /// + private bool _done; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + internal CountObserver(IObserver observer) => _observer = observer; + + /// + public override void OnNext(T value) + { + if (_done) + { + return; + } + + _count = checked(_count + 1); + } + + /// + public override void OnError(Exception error) + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnError(error); + } + finally + { + Dispose(); + } + } + + /// + public override void OnCompleted() + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnNext(_count); + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + } + + /// + /// Observer for predicate count. + /// + /// The source value type. + private sealed class CountPredicateObserver : SingleSourceObserver + { + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// The predicate. + /// + private readonly Func _predicate; + + /// + /// The running count. + /// + private int _count; + + /// + /// A value indicating whether the observer has terminated. + /// + private bool _done; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + /// The predicate. + internal CountPredicateObserver(IObserver observer, Func predicate) + { + _observer = observer; + _predicate = predicate; + } + + /// + public override void OnNext(T value) + { + if (_done || !_predicate(value)) + { + return; + } + + _count = checked(_count + 1); + } + + /// + public override void OnError(Exception error) + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnError(error); + } + finally + { + Dispose(); + } + } + + /// + public override void OnCompleted() + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnNext(_count); + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + } + + /// + /// Observer for counting distinct keys. + /// + /// The source value type. + /// The key type. + private sealed class DistinctByCountObserver : SingleSourceObserver + { + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// The key selector. + /// + private readonly Func _keySelector; + + /// + /// The observed keys. + /// + private readonly HashSet _seen; + + /// + /// The running count. + /// + private int _count; + + /// + /// A value indicating whether the observer has terminated. + /// + private bool _done; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + /// The key selector. + /// The key comparer. + internal DistinctByCountObserver(IObserver observer, Func keySelector, IEqualityComparer? comparer) + { + _observer = observer; + _keySelector = keySelector; + _seen = comparer == null ? [] : new(comparer); + } + + /// + public override void OnNext(T value) + { + if (_done || !_seen.Add(_keySelector(value))) + { + return; + } + + _count = checked(_count + 1); + } + + /// + public override void OnError(Exception error) + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnError(error); + } + finally + { + Dispose(); + } + } + + /// + public override void OnCompleted() + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnNext(_count); + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + } + + /// + /// Observer for long-counting all values. + /// + /// The source value type. + private sealed class LongCountObserver : SingleSourceObserver + { + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// The running count. + /// + private long _count; + + /// + /// A value indicating whether the observer has terminated. + /// + private bool _done; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + internal LongCountObserver(IObserver observer) => _observer = observer; + + /// + public override void OnNext(T value) + { + if (_done) + { + return; + } + + _count = checked(_count + 1L); + } + + /// + public override void OnError(Exception error) + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnError(error); + } + finally + { + Dispose(); + } + } + + /// + public override void OnCompleted() + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnNext(_count); + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + } + + /// + /// Observer for predicate long-count. + /// + /// The source value type. + private sealed class LongCountPredicateObserver : SingleSourceObserver + { + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// The predicate. + /// + private readonly Func _predicate; + + /// + /// The running count. + /// + private long _count; + + /// + /// A value indicating whether the observer has terminated. + /// + private bool _done; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + /// The predicate. + internal LongCountPredicateObserver(IObserver observer, Func predicate) + { + _observer = observer; + _predicate = predicate; + } + + /// + public override void OnNext(T value) + { + if (_done || !_predicate(value)) + { + return; + } + + _count = checked(_count + 1L); + } + + /// + public override void OnError(Exception error) + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnError(error); + } + finally + { + Dispose(); + } + } + + /// + public override void OnCompleted() + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnNext(_count); + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + } + + /// + /// Observer for long-counting distinct keys. + /// + /// The source value type. + /// The key type. + private sealed class DistinctByLongCountObserver : SingleSourceObserver + { + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// The key selector. + /// + private readonly Func _keySelector; + + /// + /// The observed keys. + /// + private readonly HashSet _seen; + + /// + /// The running count. + /// + private long _count; + + /// + /// A value indicating whether the observer has terminated. + /// + private bool _done; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + /// The key selector. + /// The key comparer. + internal DistinctByLongCountObserver(IObserver observer, Func keySelector, IEqualityComparer? comparer) + { + _observer = observer; + _keySelector = keySelector; + _seen = comparer == null ? [] : new(comparer); + } + + /// + public override void OnNext(T value) + { + if (_done || !_seen.Add(_keySelector(value))) + { + return; + } + + _count = checked(_count + 1L); + } + + /// + public override void OnError(Exception error) + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnError(error); + } + finally + { + Dispose(); + } + } + + /// + public override void OnCompleted() + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnNext(_count); + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + } + + /// + /// Observer for detecting whether any value is present. + /// + /// The source value type. + private sealed class AnyObserver : SingleSourceObserver + { + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// A value indicating whether the observer has terminated. + /// + private bool _done; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + internal AnyObserver(IObserver observer) => _observer = observer; + + /// + public override void OnNext(T value) + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnNext(true); + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + + /// + public override void OnError(Exception error) + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnError(error); + } + finally + { + Dispose(); + } + } + + /// + public override void OnCompleted() + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnNext(false); + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + } + + /// + /// Observer for detecting whether any value matches a predicate. + /// + /// The source value type. + private sealed class AnyPredicateObserver : SingleSourceObserver + { + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// The predicate. + /// + private readonly Func _predicate; + + /// + /// A value indicating whether the observer has terminated. + /// + private bool _done; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + /// The predicate. + internal AnyPredicateObserver(IObserver observer, Func predicate) + { + _observer = observer; + _predicate = predicate; + } + + /// + public override void OnNext(T value) + { + if (_done || !_predicate(value)) + { + return; + } + + _done = true; + try + { + _observer.OnNext(true); + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + + /// + public override void OnError(Exception error) + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnError(error); + } + finally + { + Dispose(); + } + } + + /// + public override void OnCompleted() + { + if (_done) + { + return; + } + + _done = true; + try + { + _observer.OnNext(false); + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + } +} diff --git a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.Helpers.cs b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.Helpers.cs new file mode 100644 index 0000000..1cce3bc --- /dev/null +++ b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.Helpers.cs @@ -0,0 +1,778 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; + +#pragma warning disable SA1107, SA1116, SA1117, SA1501, SA1611, SA1615, SA1618 + +namespace ReactiveUI.Primitives; + +/// +/// Private helper types for parity operators. +/// +public static partial class LinqMixins +{ + /// + /// Prepends a single value without composing through concat and return signals. + /// + /// The source value type. + private sealed class PrependSignal : Signals.Core.IInlineSignal + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// The value emitted before source subscription. + /// + private readonly T _value; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// The prepended value. + internal PrependSignal(IObservable source, T value) + { + _source = source; + _value = value; + } + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + observer.OnNext(_value); + return _source.Subscribe(observer); + } + + /// + public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) + { + onNext(_value); + return _source.Subscribe(onNext, onError, onCompleted); + } + } + + /// + /// Prepends an enumerable without composing through concat and enumerable signals. + /// + /// The source value type. + private sealed class StartWithEnumerableSignal : Signals.Core.IInlineSignal + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// Values emitted before source subscription. + /// + private readonly IEnumerable _values; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// Values emitted before source subscription. + internal StartWithEnumerableSignal(IObservable source, IEnumerable values) + { + _source = source; + _values = values; + } + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + foreach (var value in _values) + { + observer.OnNext(value); + } + + return _source.Subscribe(observer); + } + + /// + public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) + { + foreach (var value in _values) + { + onNext(value); + } + + return _source.Subscribe(onNext, onError, onCompleted); + } + } + + /// + /// Appends a single value after source completion. + /// + /// The source value type. + private sealed class AppendSignal : IObservable + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// The value emitted after source completion. + /// + private readonly T _value; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// The appended value. + internal AppendSignal(IObservable source, T value) + { + _source = source; + _value = value; + } + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + var sink = new AppendObserver(observer, _value); + sink.SetSubscription(_source.Subscribe(sink)); + return sink; + } + } + + /// + /// Emits a default value when the source completes without values. + /// + /// The source value type. + private sealed class DefaultIfEmptySignal : IObservable + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// Value emitted for an empty source. + /// + private readonly T _defaultValue; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// Value emitted for an empty source. + internal DefaultIfEmptySignal(IObservable source, T defaultValue) + { + _source = source; + _defaultValue = defaultValue; + } + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + var sink = new DefaultIfEmptyObserver(observer, _defaultValue); + sink.SetSubscription(_source.Subscribe(sink)); + return sink; + } + } + + /// + /// Shared disposable sink for single-source terminal operators. + /// + private abstract class SingleSourceObserver : IObserver, IDisposable + { + /// + /// Disposed marker. + /// + private static readonly IDisposable DisposedSentinel = new DisposedMarker(); + + /// + /// Upstream subscription. + /// + private IDisposable? _subscription; + + /// + public abstract void OnNext(T value); + + /// + public abstract void OnError(Exception error); + + /// + public abstract void OnCompleted(); + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Assigns the upstream subscription. + /// + /// The upstream subscription. + internal void SetSubscription(IDisposable subscription) + { + if (Interlocked.CompareExchange(ref _subscription, subscription, null) == null) + { + return; + } + + subscription.Dispose(); + } + + /// + /// Releases the upstream subscription. + /// + /// A value indicating whether managed resources should be disposed. + protected virtual void Dispose(bool disposing) + { + var subscription = Interlocked.Exchange(ref _subscription, DisposedSentinel); + if (subscription == null || ReferenceEquals(subscription, DisposedSentinel) || !disposing) + { + return; + } + + subscription.Dispose(); + } + + /// + /// Disposable marker for disposed sinks. + /// + private sealed class DisposedMarker : IDisposable + { + /// + public void Dispose() + { + } + } + } + + /// + /// Observer for append. + /// + /// The source value type. + private sealed class AppendObserver : SingleSourceObserver + { + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// The appended value. + /// + private readonly T _value; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + /// The appended value. + internal AppendObserver(IObserver observer, T value) + { + _observer = observer; + _value = value; + } + + /// + public override void OnNext(T value) + { + try + { + _observer.OnNext(value); + } + catch + { + Dispose(); + throw; + } + } + + /// + public override void OnError(Exception error) + { + try + { + _observer.OnError(error); + } + finally + { + Dispose(); + } + } + + /// + public override void OnCompleted() + { + try + { + _observer.OnNext(_value); + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + } + + /// + /// Observer for default-if-empty. + /// + /// The source value type. + private sealed class DefaultIfEmptyObserver : SingleSourceObserver + { + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// Value emitted for an empty source. + /// + private readonly T _defaultValue; + + /// + /// A value indicating whether the source produced any values. + /// + private bool _seen; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + /// Value emitted for an empty source. + internal DefaultIfEmptyObserver(IObserver observer, T defaultValue) + { + _observer = observer; + _defaultValue = defaultValue; + } + + /// + public override void OnNext(T value) + { + _seen = true; + try + { + _observer.OnNext(value); + } + catch + { + Dispose(); + throw; + } + } + + /// + public override void OnError(Exception error) + { + try + { + _observer.OnError(error); + } + finally + { + Dispose(); + } + } + + /// + public override void OnCompleted() + { + try + { + if (!_seen) + { + _observer.OnNext(_defaultValue); + } + + _observer.OnCompleted(); + } + finally + { + Dispose(); + } + } + } + + /// + /// Coordinates a sampled observable sequence. + /// + /// The source value type. + private sealed class SampleCoordinator : IDisposable + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// The sample period. + /// + private readonly TimeSpan _period; + + /// + /// The sequencer used to schedule ticks. + /// + private readonly ISequencer _sequencer; + + /// + /// The synchronization gate. + /// + private readonly OperatorGate _gate = new(); + + /// + /// The active subscriptions. + /// + private readonly MultipleDisposable _subscriptions = new(); + + /// + /// The timer slot. + /// + private readonly SingleReplaceableDisposable _timer = new(); + + /// + /// The downstream observer. + /// + private IObserver? _observer; + + /// + /// A value indicating whether a latest value is available. + /// + private bool _hasLatest; + + /// + /// The latest value. + /// + private T? _latest; + + /// + /// A value indicating whether the source has completed. + /// + private bool _done; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// The sample period. + /// The sequencer used to schedule ticks. + internal SampleCoordinator(IObservable source, TimeSpan period, ISequencer sequencer) + { + _source = source; + _period = period; + _sequencer = sequencer; + } + + /// + /// Releases the active subscriptions. + /// + public void Dispose() + { + _timer.Dispose(); + _subscriptions.Dispose(); + } + + /// + /// Starts sampling the source. + /// + /// The downstream observer. + /// The coordinator that owns the subscription cleanup. + internal SampleCoordinator Run(IObserver observer) + { + _observer = observer; + _subscriptions.Add(_timer); + _subscriptions.Add(_source.Subscribe(OnNext, observer.OnError, OnCompleted)); + ScheduleNext(); + return this; + } + + /// + /// Records the latest source value. + /// + /// The source value. + private void OnNext(T value) + { + lock (_gate.SyncRoot) + { + _hasLatest = true; + _latest = value; + } + } + + /// + /// Marks the source as completed. + /// + private void OnCompleted() + { + lock (_gate.SyncRoot) + { + _done = true; + } + + _observer!.OnCompleted(); + } + + /// + /// Schedules the next sample tick. + /// + private void ScheduleNext() => + _timer.Create(_sequencer.Schedule(_period, Tick)); + + /// + /// Handles a sample tick. + /// + private void Tick() + { + if (!TryTake(out var value)) + { + return; + } + + _observer!.OnNext(value); + if (_timer.IsDisposed) + { + return; + } + + ScheduleNext(); + } + + /// + /// Attempts to take the latest value. + /// + /// The latest value. + /// true when a value should be emitted; otherwise, false. + private bool TryTake(out T value) + { + lock (_gate.SyncRoot) + { + if (_done || !_hasLatest) + { + value = default!; + return false; + } + + value = _latest!; + _hasLatest = false; + return true; + } + } + } + + /// + /// Coordinates a two-source fork-join operation. + /// + /// The left value type. + /// The right value type. + /// The result value type. + private sealed class ForkJoinCoordinator + { + /// + /// The synchronization gate. + /// + private readonly OperatorGate _gate = new(); + + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// The projection function. + /// + private readonly Func _selector; + + /// + /// A value indicating whether the left source produced a value. + /// + private bool _hasLeft; + + /// + /// A value indicating whether the right source produced a value. + /// + private bool _hasRight; + + /// + /// A value indicating whether the left source completed. + /// + private bool _leftDone; + + /// + /// A value indicating whether the right source completed. + /// + private bool _rightDone; + + /// + /// The latest left value. + /// + private TLeft? _latestLeft; + + /// + /// The latest right value. + /// + private TRight? _latestRight; + + /// + /// Initializes a new instance of the class. + /// + /// The downstream observer. + /// The projection function. + internal ForkJoinCoordinator(IObserver observer, Func selector) + { + _observer = observer; + _selector = selector; + } + + /// + /// Subscribes to both fork-join sources. + /// + /// The left source. + /// The right source. + /// The subscription cleanup. + internal MultipleDisposable Run(IObservable left, IObservable right) => + new( + left.Subscribe(OnLeftNext, _observer.OnError, OnLeftCompleted), + right.Subscribe(OnRightNext, _observer.OnError, OnRightCompleted)); + + /// + /// Records a left value. + /// + /// The left value. + private void OnLeftNext(TLeft value) + { + lock (_gate.SyncRoot) + { + _hasLeft = true; + _latestLeft = value; + } + } + + /// + /// Records a right value. + /// + /// The right value. + private void OnRightNext(TRight value) + { + lock (_gate.SyncRoot) + { + _hasRight = true; + _latestRight = value; + } + } + + /// + /// Marks the left source as complete. + /// + private void OnLeftCompleted() + { + if (!CompleteLeft(out var result, out var emit)) + { + return; + } + + Finish(result, emit); + } + + /// + /// Marks the right source as complete. + /// + private void OnRightCompleted() + { + if (!CompleteRight(out var result, out var emit)) + { + return; + } + + Finish(result, emit); + } + + /// + /// Marks the left source complete and computes the result if both sources are complete. + /// + /// The result to emit. + /// A value indicating whether a result should be emitted. + /// true when fork-join is ready to finish; otherwise, false. + private bool CompleteLeft(out TResult result, out bool emit) + { + lock (_gate.SyncRoot) + { + _leftDone = true; + return TryFinish(out result, out emit); + } + } + + /// + /// Marks the right source complete and computes the result if both sources are complete. + /// + /// The result to emit. + /// A value indicating whether a result should be emitted. + /// true when fork-join is ready to finish; otherwise, false. + private bool CompleteRight(out TResult result, out bool emit) + { + lock (_gate.SyncRoot) + { + _rightDone = true; + return TryFinish(out result, out emit); + } + } + + /// + /// Computes the final result when both sources are complete. + /// + /// The result to emit. + /// A value indicating whether a result should be emitted. + /// true when both sources are complete; otherwise, false. + private bool TryFinish(out TResult result, out bool emit) + { + if (!_leftDone || !_rightDone) + { + result = default!; + emit = false; + return false; + } + + emit = _hasLeft && _hasRight; + result = emit ? _selector(_latestLeft!, _latestRight!) : default!; + return true; + } + + /// + /// Emits the final result and completes. + /// + /// The result to emit. + /// A value indicating whether a result should be emitted. + private void Finish(TResult result, bool emit) + { + if (emit) + { + _observer.OnNext(result); + } + + _observer.OnCompleted(); + } + } +} diff --git a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.SelectMany.cs b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.SelectMany.cs new file mode 100644 index 0000000..511414b --- /dev/null +++ b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.SelectMany.cs @@ -0,0 +1,698 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives; + +/// +/// SelectMany helper implementations. +/// +public static partial class LinqMixins +{ + /// + /// Concatenating SelectMany signal that avoids the Map + Concat composition path. + /// + /// The source value type. + /// The result value type. + private sealed class SelectManySignal : IObservable + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// Projects source values to inner observables. + /// + private readonly Func> _selector; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// Projects source values to inner observables. + internal SelectManySignal(IObservable source, Func> selector) + { + _source = source; + _selector = selector; + } + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + return new SelectManyCoordinator(_source, _selector, observer).Run(); + } + } + + /// + /// Concatenating SelectMany signal with an outer/inner result selector. + /// + /// The source value type. + /// The inner value type. + /// The result value type. + private sealed class SelectManyResultSignal : IObservable + { + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// Projects source values to inner observables. + /// + private readonly Func> _collectionSelector; + + /// + /// Projects outer and inner values to result values. + /// + private readonly Func _resultSelector; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// Projects source values to inner observables. + /// Projects outer and inner values to result values. + internal SelectManyResultSignal( + IObservable source, + Func> collectionSelector, + Func resultSelector) + { + _source = source; + _collectionSelector = collectionSelector; + _resultSelector = resultSelector; + } + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + return new SelectManyResultCoordinator( + _source, + _collectionSelector, + _resultSelector, + observer).Run(); + } + } + + /// + /// Coordinates concat-style SelectMany subscriptions. + /// + /// The source value type. + /// The result value type. + private sealed class SelectManyCoordinator : IDisposable + { + /// + /// Synchronizes subscription state. + /// + private readonly object _gate = new(); + + /// + /// The source observable. + /// + private readonly IObservable _source; + + /// + /// Projects source values to inner observables. + /// + private readonly Func> _selector; + + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// Observer used for the outer source. + /// + private readonly OuterObserver _outerObserver; + + /// + /// Observer used for active inner sources. + /// + private readonly InnerObserver _innerObserver; + + /// + /// Queued inner sources waiting for the active inner source to complete. + /// + private Queue>? _queue; + + /// + /// Outer subscription. + /// + private IDisposable? _outer; + + /// + /// Active inner subscription. + /// + private IDisposable? _inner; + + /// + /// Value indicating whether an inner source is active. + /// + private bool _active; + + /// + /// Value indicating whether the outer source has completed. + /// + private bool _outerCompleted; + + /// + /// Value indicating whether the coordinator has stopped. + /// + private bool _disposed; + + /// + /// Value indicating whether the active inner source is currently subscribing. + /// + private bool _subscribingInner; + + /// + /// Value indicating whether the active inner source completed while its subscribe call was still on the stack. + /// + private bool _completedInnerWhileSubscribing; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// Projects source values to inner observables. + /// The downstream observer. + internal SelectManyCoordinator( + IObservable source, + Func> selector, + IObserver observer) + { + _source = source; + _selector = selector; + _observer = observer; + _outerObserver = new OuterObserver(this); + _innerObserver = new InnerObserver(this); + } + + /// + public void Dispose() + { + IDisposable? outer; + IDisposable? inner; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + outer = _outer; + inner = _inner; + _outer = null; + _inner = null; + _queue = null; + } + + outer?.Dispose(); + inner?.Dispose(); + } + + /// + /// Subscribes to the outer source. + /// + /// The subscription cleanup. + internal IDisposable Run() + { + var outer = _source.Subscribe(_outerObserver); + lock (_gate) + { + if (_disposed) + { + outer.Dispose(); + return Disposable.Empty; + } + + _outer = outer; + return this; + } + } + + /// + /// Handles a source value. + /// + /// The source value. + private void OnOuterNext(TSource value) + { + IObservable inner; + try + { + inner = _selector(value) ?? throw new InvalidOperationException("The SelectMany selector returned null."); + } + catch (Exception error) + { + OnError(error); + return; + } + + if (!TryStartOrQueue(inner)) + { + return; + } + + SubscribeInner(inner); + } + + /// + /// Handles outer source completion. + /// + private void OnOuterCompleted() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _outerCompleted = true; + } + + Drain(); + } + + /// + /// Forwards an inner value. + /// + /// The inner value. + private void OnInnerNext(TResult value) + { + lock (_gate) + { + if (_disposed) + { + return; + } + } + + _observer.OnNext(value); + } + + /// + /// Handles active inner source completion. + /// + private void OnInnerCompleted() + { + IDisposable? inner; + lock (_gate) + { + if (_disposed) + { + return; + } + + if (_subscribingInner) + { + _completedInnerWhileSubscribing = true; + _active = false; + return; + } + + inner = _inner; + _inner = null; + _active = false; + } + + inner?.Dispose(); + Drain(); + } + + /// + /// Handles an outer or inner error. + /// + /// The error. + private void OnError(Exception error) + { + IDisposable? outer; + IDisposable? inner; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + outer = _outer; + inner = _inner; + _outer = null; + _inner = null; + _queue = null; + } + + outer?.Dispose(); + inner?.Dispose(); + _observer.OnError(error); + } + + /// + /// Starts an inner source immediately or queues it behind the active inner source. + /// + /// The inner source. + /// true when the source should be subscribed immediately; otherwise, false. + private bool TryStartOrQueue(IObservable inner) + { + lock (_gate) + { + if (_disposed) + { + return false; + } + + if (!_active) + { + _active = true; + return true; + } + + (_queue ??= new Queue>()).Enqueue(inner); + return false; + } + } + + /// + /// Subscribes to an inner source. + /// + /// The inner source. + private void SubscribeInner(IObservable inner) + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _subscribingInner = true; + _completedInnerWhileSubscribing = false; + } + + IDisposable subscription; + try + { + subscription = inner.Subscribe(_innerObserver); + } + catch (Exception error) + { + CompleteSubscribe(error); + return; + } + + var completed = CompleteSubscribe(subscription); + if (!completed) + { + return; + } + + subscription.Dispose(); + Drain(); + } + + /// + /// Completes an inner subscribe call that threw. + /// + /// The subscribe error. + private void CompleteSubscribe(Exception error) + { + lock (_gate) + { + _subscribingInner = false; + _active = false; + } + + OnError(error); + } + + /// + /// Completes an inner subscribe call. + /// + /// The inner subscription. + /// true when the inner source completed synchronously; otherwise, false. + private bool CompleteSubscribe(IDisposable subscription) + { + lock (_gate) + { + _subscribingInner = false; + if (_disposed || _completedInnerWhileSubscribing) + { + return true; + } + + _inner = subscription; + return false; + } + } + + /// + /// Drains queued inner sources and completes when the outer source and queue are finished. + /// + private void Drain() + { + while (true) + { + IObservable? next = null; + var complete = false; + lock (_gate) + { + if (_disposed || _active) + { + return; + } + + if (_queue is { Count: > 0 } queue) + { + _active = true; + next = queue.Dequeue(); + } + else if (_outerCompleted) + { + _disposed = true; + complete = true; + } + } + + if (next != null) + { + SubscribeInner(next); + continue; + } + + if (complete) + { + _observer.OnCompleted(); + } + + return; + } + } + + /// + /// Outer source observer. + /// + private sealed class OuterObserver : IObserver + { + /// + /// Owning coordinator. + /// + private readonly SelectManyCoordinator _parent; + + /// + /// Initializes a new instance of the class. + /// + /// Owning coordinator. + internal OuterObserver(SelectManyCoordinator parent) => _parent = parent; + + /// + public void OnCompleted() => _parent.OnOuterCompleted(); + + /// + public void OnError(Exception error) => _parent.OnError(error); + + /// + public void OnNext(TSource value) => _parent.OnOuterNext(value); + } + + /// + /// Inner source observer. + /// + private sealed class InnerObserver : IObserver + { + /// + /// Owning coordinator. + /// + private readonly SelectManyCoordinator _parent; + + /// + /// Initializes a new instance of the class. + /// + /// Owning coordinator. + internal InnerObserver(SelectManyCoordinator parent) => _parent = parent; + + /// + public void OnCompleted() => _parent.OnInnerCompleted(); + + /// + public void OnError(Exception error) => _parent.OnError(error); + + /// + public void OnNext(TResult value) => _parent.OnInnerNext(value); + } + } + + /// + /// Coordinates concat-style SelectMany subscriptions with a result selector. + /// + /// The source value type. + /// The inner value type. + /// The result value type. + private sealed class SelectManyResultCoordinator : IDisposable + { + /// + /// The inner SelectMany coordinator. + /// + private readonly SelectManyCoordinator _inner; + + /// + /// Initializes a new instance of the class. + /// + /// The source observable. + /// Projects source values to inner observables. + /// Projects outer and inner values to result values. + /// The downstream observer. + internal SelectManyResultCoordinator( + IObservable source, + Func> collectionSelector, + Func resultSelector, + IObserver observer) => + _inner = new SelectManyCoordinator( + source, + value => new SelectManyResultInnerSignal( + value, + collectionSelector(value), + resultSelector), + observer); + + /// + public void Dispose() => _inner.Dispose(); + + /// + /// Subscribes to the outer source. + /// + /// The subscription cleanup. + internal IDisposable Run() => _inner.Run(); + } + + /// + /// Maps inner values with a captured outer value. + /// + /// The source value type. + /// The inner value type. + /// The result value type. + private sealed class SelectManyResultInnerSignal : IObservable + { + /// + /// Captured outer value. + /// + private readonly TSource _sourceValue; + + /// + /// Inner observable. + /// + private readonly IObservable _source; + + /// + /// Projects outer and inner values to result values. + /// + private readonly Func _selector; + + /// + /// Initializes a new instance of the class. + /// + /// Captured outer value. + /// Inner observable. + /// Projects outer and inner values to result values. + internal SelectManyResultInnerSignal( + TSource sourceValue, + IObservable source, + Func selector) + { + _sourceValue = sourceValue; + _source = source ?? throw new InvalidOperationException("The SelectMany collection selector returned null."); + _selector = selector; + } + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + return _source.Subscribe(new ResultObserver(_sourceValue, _selector, observer)); + } + + /// + /// Maps inner source values. + /// + private sealed class ResultObserver : IObserver + { + /// + /// Captured outer value. + /// + private readonly TSource _sourceValue; + + /// + /// Projects outer and inner values to result values. + /// + private readonly Func _selector; + + /// + /// The downstream observer. + /// + private readonly IObserver _observer; + + /// + /// Initializes a new instance of the class. + /// + /// Captured outer value. + /// Projects outer and inner values to result values. + /// The downstream observer. + internal ResultObserver( + TSource sourceValue, + Func selector, + IObserver observer) + { + _sourceValue = sourceValue; + _selector = selector; + _observer = observer; + } + + /// + public void OnCompleted() => _observer.OnCompleted(); + + /// + public void OnError(Exception error) => _observer.OnError(error); + + /// + public void OnNext(TCollection value) => _observer.OnNext(_selector(_sourceValue, value)); + } + } +} diff --git a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs index 63b4be9..931924e 100644 --- a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs +++ b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs @@ -31,7 +31,7 @@ public static IObservable Prepend(this IObservable source, T value) throw new ArgumentNullException(nameof(source)); } - return Signal.Concat(Signal.Return(value), source); + return new PrependSignal(source, value); } /// @@ -44,7 +44,7 @@ public static IObservable Append(this IObservable source, T value) throw new ArgumentNullException(nameof(source)); } - return Signal.Concat(source, Signal.Return(value)); + return new AppendSignal(source, value); } /// @@ -67,7 +67,7 @@ public static IObservable StartWith(this IObservable source, params T[] throw new ArgumentNullException(nameof(values)); } - return Signal.Concat(Signal.FromEnumerable(values), source); + return new StartWithEnumerableSignal(source, values); } /// @@ -85,7 +85,7 @@ public static IObservable StartWith(this IObservable source, IEnumerabl throw new ArgumentNullException(nameof(values)); } - return Signal.Concat(Signal.FromEnumerable(values), source); + return new StartWithEnumerableSignal(source, values); } /// @@ -211,26 +211,7 @@ public static IObservable DefaultIfEmpty(this IObservable source, T def throw new ArgumentNullException(nameof(source)); } - return Signal.CreateSafe(observer => - { - var seen = false; - return source.Subscribe( - value => - { - seen = true; - observer.OnNext(value); - }, - observer.OnError, - () => - { - if (!seen) - { - observer.OnNext(defaultValue); - } - - observer.OnCompleted(); - }); - }); + return new DefaultIfEmptySignal(source, defaultValue); } /// @@ -254,22 +235,7 @@ public static IObservable DistinctBy(this IObservable source, Fun throw new ArgumentNullException(nameof(keySelector)); } - return Signal.CreateSafe(observer => - { - var seen = new HashSet(comparer); - return source.Subscribe( - value => - { - if (!seen.Add(keySelector(value))) - { - return; - } - - observer.OnNext(value); - }, - observer.OnError, - observer.OnCompleted); - }); + return new DistinctBySignal(source, keySelector, comparer); } /// @@ -411,11 +377,7 @@ public static IObservable SelectMany(this IObservable throw new ArgumentNullException(nameof(selector)); } - return Signal.Create(observer => - { - var sources = source.Map(selector); - return sources.Concat().Subscribe(observer); - }); + return new SelectManySignal(source, selector); } /// @@ -436,13 +398,21 @@ public static IObservable SelectMany( throw new ArgumentNullException(nameof(resultSelector)); } - return source.SelectMany(value => collectionSelector(value).Map(inner => resultSelector(value, inner))); + return new SelectManyResultSignal(source, collectionSelector, resultSelector); } /// /// Counts the source values as an . /// - public static IObservable Count(this IObservable source) => source.Count(_ => true); + public static IObservable Count(this IObservable source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + return new CountSignal(source); + } /// /// Counts source values that satisfy the predicate as an . @@ -454,13 +424,26 @@ public static IObservable Count(this IObservable source, Func predicate(value) ? checked(count + 1) : count); + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + return new CountPredicateSignal(source, predicate); } /// /// Counts the source values as an . /// - public static IObservable LongCount(this IObservable source) => source.LongCount(_ => true); + public static IObservable LongCount(this IObservable source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + return new LongCountSignal(source); + } /// /// Counts source values that satisfy the predicate as an . @@ -472,13 +455,26 @@ public static IObservable LongCount(this IObservable source, Func predicate(value) ? checked(count + 1L) : count); + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + return new LongCountPredicateSignal(source, predicate); } /// /// Emits true when any value is present. /// - public static IObservable Any(this IObservable source) => source.Any(_ => true); + public static IObservable Any(this IObservable source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + return new AnySignal(source); + } /// /// Emits true when any value satisfies the predicate. @@ -495,33 +491,7 @@ public static IObservable Any(this IObservable source, Func throw new ArgumentNullException(nameof(predicate)); } - return Signal.CreateSafe(observer => - { - var matched = false; - return source.Subscribe( - value => - { - if (matched || !predicate(value)) - { - return; - } - - matched = true; - observer.OnNext(true); - observer.OnCompleted(); - }, - observer.OnError, - () => - { - if (matched) - { - return; - } - - observer.OnNext(false); - observer.OnCompleted(); - }); - }); + return new AnyPredicateSignal(source, predicate); } /// @@ -942,365 +912,7 @@ private static Task FirstOrDefaultCoreAsync(this IObservable source, bo { completion.TrySetException(new InvalidOperationException("The source completed without producing a value.")); } - }); + }); return completion.Task; } - - /// - /// Coordinates a sampled observable sequence. - /// - /// The source value type. - private sealed class SampleCoordinator : IDisposable - { - /// - /// The source observable. - /// - private readonly IObservable _source; - - /// - /// The sample period. - /// - private readonly TimeSpan _period; - - /// - /// The sequencer used to schedule ticks. - /// - private readonly ISequencer _sequencer; - - /// - /// The synchronization gate. - /// - private readonly OperatorGate _gate = new(); - - /// - /// The active subscriptions. - /// - private readonly MultipleDisposable _subscriptions = new(); - - /// - /// The timer slot. - /// - private readonly SingleReplaceableDisposable _timer = new(); - - /// - /// The downstream observer. - /// - private IObserver? _observer; - - /// - /// A value indicating whether a latest value is available. - /// - private bool _hasLatest; - - /// - /// The latest value. - /// - private T? _latest; - - /// - /// A value indicating whether the source has completed. - /// - private bool _done; - - /// - /// Initializes a new instance of the class. - /// - /// The source observable. - /// The sample period. - /// The sequencer used to schedule ticks. - internal SampleCoordinator(IObservable source, TimeSpan period, ISequencer sequencer) - { - _source = source; - _period = period; - _sequencer = sequencer; - } - - /// - /// Releases the active subscriptions. - /// - public void Dispose() - { - _timer.Dispose(); - _subscriptions.Dispose(); - } - - /// - /// Starts sampling the source. - /// - /// The downstream observer. - /// The coordinator that owns the subscription cleanup. - internal SampleCoordinator Run(IObserver observer) - { - _observer = observer; - _subscriptions.Add(_timer); - _subscriptions.Add(_source.Subscribe(OnNext, observer.OnError, OnCompleted)); - ScheduleNext(); - return this; - } - - /// - /// Records the latest source value. - /// - /// The source value. - private void OnNext(T value) - { - lock (_gate.SyncRoot) - { - _hasLatest = true; - _latest = value; - } - } - - /// - /// Marks the source as completed. - /// - private void OnCompleted() - { - lock (_gate.SyncRoot) - { - _done = true; - } - - _observer!.OnCompleted(); - } - - /// - /// Schedules the next sample tick. - /// - private void ScheduleNext() => - _timer.Create(_sequencer.Schedule(_period, Tick)); - - /// - /// Handles a sample tick. - /// - private void Tick() - { - if (!TryTake(out var value)) - { - return; - } - - _observer!.OnNext(value); - if (_timer.IsDisposed) - { - return; - } - - ScheduleNext(); - } - - /// - /// Attempts to take the latest value. - /// - /// The latest value. - /// true when a value should be emitted; otherwise, false. - private bool TryTake(out T value) - { - lock (_gate.SyncRoot) - { - if (_done || !_hasLatest) - { - value = default!; - return false; - } - - value = _latest!; - _hasLatest = false; - return true; - } - } - } - - /// - /// Coordinates a two-source fork-join operation. - /// - /// The left value type. - /// The right value type. - /// The result value type. - private sealed class ForkJoinCoordinator - { - /// - /// The synchronization gate. - /// - private readonly OperatorGate _gate = new(); - - /// - /// The downstream observer. - /// - private readonly IObserver _observer; - - /// - /// The projection function. - /// - private readonly Func _selector; - - /// - /// A value indicating whether the left source produced a value. - /// - private bool _hasLeft; - - /// - /// A value indicating whether the right source produced a value. - /// - private bool _hasRight; - - /// - /// A value indicating whether the left source completed. - /// - private bool _leftDone; - - /// - /// A value indicating whether the right source completed. - /// - private bool _rightDone; - - /// - /// The latest left value. - /// - private TLeft? _latestLeft; - - /// - /// The latest right value. - /// - private TRight? _latestRight; - - /// - /// Initializes a new instance of the class. - /// - /// The downstream observer. - /// The projection function. - internal ForkJoinCoordinator(IObserver observer, Func selector) - { - _observer = observer; - _selector = selector; - } - - /// - /// Subscribes to both fork-join sources. - /// - /// The left source. - /// The right source. - /// The subscription cleanup. - internal MultipleDisposable Run(IObservable left, IObservable right) => - new( - left.Subscribe(OnLeftNext, _observer.OnError, OnLeftCompleted), - right.Subscribe(OnRightNext, _observer.OnError, OnRightCompleted)); - - /// - /// Records a left value. - /// - /// The left value. - private void OnLeftNext(TLeft value) - { - lock (_gate.SyncRoot) - { - _hasLeft = true; - _latestLeft = value; - } - } - - /// - /// Records a right value. - /// - /// The right value. - private void OnRightNext(TRight value) - { - lock (_gate.SyncRoot) - { - _hasRight = true; - _latestRight = value; - } - } - - /// - /// Marks the left source as complete. - /// - private void OnLeftCompleted() - { - if (!CompleteLeft(out var result, out var emit)) - { - return; - } - - Finish(result, emit); - } - - /// - /// Marks the right source as complete. - /// - private void OnRightCompleted() - { - if (!CompleteRight(out var result, out var emit)) - { - return; - } - - Finish(result, emit); - } - - /// - /// Marks the left source complete and computes the result if both sources are complete. - /// - /// The result to emit. - /// A value indicating whether a result should be emitted. - /// true when fork-join is ready to finish; otherwise, false. - private bool CompleteLeft(out TResult result, out bool emit) - { - lock (_gate.SyncRoot) - { - _leftDone = true; - return TryFinish(out result, out emit); - } - } - - /// - /// Marks the right source complete and computes the result if both sources are complete. - /// - /// The result to emit. - /// A value indicating whether a result should be emitted. - /// true when fork-join is ready to finish; otherwise, false. - private bool CompleteRight(out TResult result, out bool emit) - { - lock (_gate.SyncRoot) - { - _rightDone = true; - return TryFinish(out result, out emit); - } - } - - /// - /// Computes the final result when both sources are complete. - /// - /// The result to emit. - /// A value indicating whether a result should be emitted. - /// true when both sources are complete; otherwise, false. - private bool TryFinish(out TResult result, out bool emit) - { - if (!_leftDone || !_rightDone) - { - result = default!; - emit = false; - return false; - } - - emit = _hasLeft && _hasRight; - result = emit ? _selector(_latestLeft!, _latestRight!) : default!; - return true; - } - - /// - /// Emits the final result and completes. - /// - /// The result to emit. - /// A value indicating whether a result should be emitted. - private void Finish(TResult result, bool emit) - { - if (emit) - { - _observer.OnNext(result); - } - - _observer.OnCompleted(); - } - } } diff --git a/src/ReactiveUI.Primitives/Signals/Core/FromEnumerableSignal{T}.cs b/src/ReactiveUI.Primitives/Signals/Core/FromEnumerableSignal{T}.cs new file mode 100644 index 0000000..8253c32 --- /dev/null +++ b/src/ReactiveUI.Primitives/Signals/Core/FromEnumerableSignal{T}.cs @@ -0,0 +1,127 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Core; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Signals.Core; + +/// +/// Represents a finite signal backed by an enumerable sequence. +/// +/// The value type. +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +internal sealed class FromEnumerableSignal : IRequireCurrentThread, IInlineSignal +{ + /// + /// Stores the source values. + /// + private readonly IEnumerable _values; + + /// + /// Initializes a new instance of the class. + /// + /// The source values. + public FromEnumerableSignal(IEnumerable values) => + _values = values; + + /// + /// Executes the IsRequiredSubscribeOnCurrentThread operation. + /// + /// . + public bool IsRequiredSubscribeOnCurrentThread() => false; + + /// + /// Executes the Subscribe operation. + /// + /// The observer value. + /// The subscription. + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + if (_values is T[] array) + { + for (var i = 0; i < array.Length; i++) + { + observer.OnNext(array[i]); + } + + observer.OnCompleted(); + return Disposable.Empty; + } + + if (_values is IReadOnlyList readOnlyList) + { + for (var i = 0; i < readOnlyList.Count; i++) + { + observer.OnNext(readOnlyList[i]); + } + + observer.OnCompleted(); + return Disposable.Empty; + } + + foreach (var value in _values) + { + observer.OnNext(value); + } + + observer.OnCompleted(); + return Disposable.Empty; + } + + /// + /// Executes the Subscribe operation. + /// + /// The onNext value. + /// The onError value. + /// The onCompleted value. + /// The subscription. + public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) + { + if (onNext == null) + { + throw new ArgumentNullException(nameof(onNext)); + } + + if (onCompleted == null) + { + throw new ArgumentNullException(nameof(onCompleted)); + } + + if (_values is T[] array) + { + for (var i = 0; i < array.Length; i++) + { + onNext(array[i]); + } + + onCompleted(); + return Disposable.Empty; + } + + if (_values is IReadOnlyList readOnlyList) + { + for (var i = 0; i < readOnlyList.Count; i++) + { + onNext(readOnlyList[i]); + } + + onCompleted(); + return Disposable.Empty; + } + + foreach (var value in _values) + { + onNext(value); + } + + onCompleted(); + return Disposable.Empty; + } +} diff --git a/src/ReactiveUI.Primitives/Signals/Core/ImmediateThrowSignal{T}.cs b/src/ReactiveUI.Primitives/Signals/Core/ImmediateThrowSignal{T}.cs new file mode 100644 index 0000000..ad961ed --- /dev/null +++ b/src/ReactiveUI.Primitives/Signals/Core/ImmediateThrowSignal{T}.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Core; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Signals.Core; + +/// +/// Represents the immediate Throw signal fast path. +/// +/// The T type. +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +internal sealed class ImmediateThrowSignal : IRequireCurrentThread, IInlineSignal +{ + /// + /// Stores the terminal error. + /// + private readonly Exception _error; + + /// + /// Initializes a new instance of the class. + /// + /// The terminal error. + public ImmediateThrowSignal(Exception error) => _error = error; + + /// + /// Executes the IsRequiredSubscribeOnCurrentThread operation. + /// + /// The result. + public bool IsRequiredSubscribeOnCurrentThread() => false; + + /// + /// Executes the Subscribe operation. + /// + /// The observer value. + /// The result. + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + observer.OnError(_error); + return Disposable.Empty; + } + + /// + /// Executes the Subscribe operation. + /// + /// The onNext value. + /// The onError value. + /// The onCompleted value. + /// The result. +#pragma warning disable RCS1163 // Signature matches IInlineSignal contract. + public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) + { + onError(_error); + return Disposable.Empty; + } +#pragma warning restore RCS1163 // Signature matches IInlineSignal contract. +} diff --git a/src/ReactiveUI.Primitives/Signals/Core/ThrowSignal{T}.cs b/src/ReactiveUI.Primitives/Signals/Core/ThrowSignal{T}.cs index f66d6e4..a25acc3 100644 --- a/src/ReactiveUI.Primitives/Signals/Core/ThrowSignal{T}.cs +++ b/src/ReactiveUI.Primitives/Signals/Core/ThrowSignal{T}.cs @@ -52,11 +52,19 @@ protected override IDisposable SubscribeCore(IObserver observer, IDisposable return Disposable.Empty; } - return _scheduler.Schedule(() => - { - observer.OnError(_error); - observer.OnCompleted(); - }); + return _scheduler.Schedule((observer, _error), static (_, state) => SignalError(state)); + } + + /// + /// Emits the scheduled error notification. + /// + /// The observer and error state. + /// An empty disposable. + private static IDisposable SignalError((IObserver Observer, Exception Error) state) + { + state.Observer.OnError(state.Error); + state.Observer.OnCompleted(); + return Disposable.Empty; } /// diff --git a/src/ReactiveUI.Primitives/Signals/Signal{Create}.cs b/src/ReactiveUI.Primitives/Signals/Signal{Create}.cs index 18421e2..45e25d1 100644 --- a/src/ReactiveUI.Primitives/Signals/Signal{Create}.cs +++ b/src/ReactiveUI.Primitives/Signals/Signal{Create}.cs @@ -139,8 +139,15 @@ public static IObservable CreateSafe(Func, IDisposable> subsc /// The type. /// The observable factory. /// An Observable. - public static IObservable Defer(Func> observableFactory) => - new DeferSignal(observableFactory); + public static IObservable Defer(Func> observableFactory) + { + if (observableFactory == null) + { + throw new ArgumentNullException(nameof(observableFactory)); + } + + return new DeferSignal(observableFactory); + } /// /// Witnesses the on. diff --git a/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs b/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs index 7d8f1ca..e602933 100644 --- a/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs +++ b/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs @@ -164,6 +164,13 @@ public static IObservable Use(Func resourceFactory, }); } + /// + /// Creates a signal whose subscription lifetime owns a resource. + /// + public static IObservable Using(Func resourceFactory, Func> signalFactory) + where TResource : IDisposable => + Use(resourceFactory, signalFactory); + /// /// Creates a signal from an enumerable sequence. /// @@ -174,16 +181,7 @@ public static IObservable FromEnumerable(IEnumerable values) throw new ArgumentNullException(nameof(values)); } - return CreateSafe(observer => - { - foreach (var value in values) - { - observer.OnNext(value); - } - - observer.OnCompleted(); - return Disposable.Empty; - }); + return new FromEnumerableSignal(values); } /// @@ -468,19 +466,19 @@ public static IObservable Timer(TimeSpan dueTime, TimeSpan period, ISequen /// Concatenates the supplied signals. /// public static IObservable Concat(params IObservable[] sources) => - FromEnumerable(sources).Concat(); + FromEnumerable(ValidateSources(sources)).Concat(); /// /// Merges the supplied signals. /// public static IObservable Merge(params IObservable[] sources) => - FromEnumerable(sources).Merge(); + FromEnumerable(ValidateSources(sources)).Merge(); /// /// Races the supplied signals and mirrors the first one to produce a value or terminal signal. /// public static IObservable Race(params IObservable[] sources) => - FromEnumerable(sources).Race(); + FromEnumerable(ValidateSources(sources)).Race(); /// /// Zips two signals with a result selector. @@ -506,6 +504,30 @@ public static IObservable ZipLatest(IObservable public static IObservable ForkJoin(IObservable left, IObservable right, Func selector) => left.ForkJoin(right, selector); + /// + /// Validates source arrays supplied to params-based factories. + /// + /// The source value type. + /// The source array. + /// The validated source array. + private static IObservable[] ValidateSources(IObservable[] sources) + { + if (sources == null) + { + throw new ArgumentNullException(nameof(sources)); + } + + for (var i = 0; i < sources.Length; i++) + { + if (sources[i] == null) + { + throw new ArgumentNullException(nameof(sources)); + } + } + + return sources; + } + #if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_0_OR_GREATER || NET5_0_OR_GREATER /// diff --git a/src/ReactiveUI.Primitives/Signals/Signal{Throw}.cs b/src/ReactiveUI.Primitives/Signals/Signal{Throw}.cs index 6cf73c5..3a5e983 100644 --- a/src/ReactiveUI.Primitives/Signals/Signal{Throw}.cs +++ b/src/ReactiveUI.Primitives/Signals/Signal{Throw}.cs @@ -20,8 +20,9 @@ public static partial class Signal /// The scheduler. /// An Signals. #pragma warning disable S4018 // Result type is intentionally explicit for Rx-style factory APIs. - public static IObservable Throw(Exception error, ISequencer scheduler) => - new ThrowSignal(error, scheduler); + public static IObservable Throw(Exception error, ISequencer scheduler) => scheduler == Sequencer.Immediate + ? new ImmediateThrowSignal(error) + : new ThrowSignal(error, scheduler); /// /// Empty Signals. Returns only onError. @@ -30,7 +31,7 @@ public static IObservable Throw(Exception error, ISequencer scheduler) => /// The error. /// An Signals. public static IObservable Throw(Exception error) => - Throw(error, Sequencer.Immediate); + new ImmediateThrowSignal(error); #pragma warning restore S4018 /// @@ -42,7 +43,7 @@ public static IObservable Throw(Exception error) => /// An Signals. #pragma warning disable RCS1163 // Unused parameter. public static IObservable Throw(Exception error, T witness) => - Throw(error, Sequencer.Immediate); + new ImmediateThrowSignal(error); /// /// Empty Signals. Returns only onError on specified scheduler. witness if for Type inference. diff --git a/src/ReactiveUI.Primitives/SubscribeMixins.cs b/src/ReactiveUI.Primitives/SubscribeMixins.cs index b72f0b3..8d77c44 100644 --- a/src/ReactiveUI.Primitives/SubscribeMixins.cs +++ b/src/ReactiveUI.Primitives/SubscribeMixins.cs @@ -51,6 +51,16 @@ public static IDisposable Subscribe(this IObservable source) /// A IDisposable. public static IDisposable Subscribe(this IObservable source, Action onNext) { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (onNext == null) + { + throw new ArgumentNullException(nameof(onNext)); + } + if (source is Signals.Signal signal) { return signal.SubscribeAction(onNext); @@ -74,7 +84,14 @@ public static IDisposable Subscribe(this IObservable source, Action onN /// The on error. /// A IDisposable. public static IDisposable Subscribe(this IObservable source, Action onNext, Action onError) - => Subscribe(source, onNext, onError, nop); + { + if (onError == null) + { + throw new ArgumentNullException(nameof(onError)); + } + + return Subscribe(source, onNext, onError, nop); + } /// /// Subscribes to the Signals providing both the and @@ -86,7 +103,14 @@ public static IDisposable Subscribe(this IObservable source, Action onN /// The on completed. /// A IDisposable. public static IDisposable Subscribe(this IObservable source, Action onNext, Action onCompleted) - => Subscribe(source, onNext, rethrow, onCompleted); + { + if (onCompleted == null) + { + throw new ArgumentNullException(nameof(onCompleted)); + } + + return Subscribe(source, onNext, rethrow, onCompleted); + } /// /// Subscribes to the Signals providing all three , @@ -100,12 +124,32 @@ public static IDisposable Subscribe(this IObservable source, Action onN /// A IDisposable. public static IDisposable Subscribe(this IObservable source, Action onNext, Action onError, Action onCompleted) { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (onNext == null) + { + throw new ArgumentNullException(nameof(onNext)); + } + + if (onError == null) + { + throw new ArgumentNullException(nameof(onError)); + } + + if (onCompleted == null) + { + throw new ArgumentNullException(nameof(onCompleted)); + } + if (source is IInlineSignal inline) { return inline.Subscribe(onNext, onError, onCompleted); } - return source?.Subscribe(new EmptyWitness(onNext, onError, onCompleted))!; + return source.Subscribe(new EmptyWitness(onNext, onError, onCompleted)); } /// diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/AsyncBridgeBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/AsyncBridgeBenchmarks.cs new file mode 100644 index 0000000..d88b635 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/AsyncBridgeBenchmarks.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; +using System.Reactive.Linq; + +using RxObservable = System.Reactive.Linq.Observable; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks for async-to-stream adapters. +/// +[MemoryDiagnoser] +public class AsyncBridgeBenchmarks +{ + private const int CompletedTaskValue = 42; + private static readonly Task CompletedTask = Task.FromResult(CompletedTaskValue); + + /// + /// Baseline conversion from completed task in primitives. + /// + /// The emitted value. + [Benchmark(Baseline = true)] + public int PrimitivesCompletedTaskBridge() + { + var observer = new IntSignalObserver(); + using var subscription = Signal.FromTask(CompletedTask).Subscribe(observer); + return observer.LastValue; + } + + /// + /// Completed task conversion in System.Reactive. + /// + /// The emitted value. + [Benchmark] + public int SystemReactiveCompletedTaskBridge() + { + var observer = new IntSignalObserver(); + using var subscription = RxObservable.FromAsync(() => CompletedTask).Subscribe(observer); + return observer.LastValue; + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/BenchmarkObservers.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/BenchmarkObservers.cs new file mode 100644 index 0000000..0bf8563 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/BenchmarkObservers.cs @@ -0,0 +1,143 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using R3; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Observer used by Signal and System.Reactive benchmark cases. +/// +internal sealed class IntSignalObserver : IObserver +{ + /// + /// Gets the total of received values. + /// + public int Total { get; private set; } + + /// + /// Gets the number of onNext calls. + /// + public int NextCount { get; private set; } + + /// + /// Gets the last value observed. + /// + public int LastValue { get; private set; } + + /// + /// Gets the number of terminal completions observed. + /// + public int CompletionCount { get; private set; } + + /// + /// Gets the number of errors observed. + /// + public int ErrorCount { get; private set; } + + /// + public void OnNext(int value) + { + NextCount++; + Total += value; + LastValue = value; + } + + /// + public void OnError(Exception error) + { + ErrorCount++; + } + + /// + public void OnCompleted() + { + CompletionCount++; + } +} + +/// +/// Observer used by R3 benchmark cases. +/// +internal sealed class IntR3Observer : Observer +{ + /// + /// Gets the total of received values. + /// + public int Total { get; private set; } + + /// + /// Gets the number of onNext calls. + /// + public int NextCount { get; private set; } + + /// + /// Gets the number of terminal completions observed. + /// + public int CompletionCount { get; private set; } + + /// + /// Gets the number of errors observed. + /// + public int ErrorCount { get; private set; } + + /// + /// Called for each emitted value. + /// + /// The emitted value. + protected override void OnNextCore(int value) + { + NextCount++; + Total += value; + } + + /// + /// Called when an error is observed. + /// + /// The observed exception. + protected override void OnErrorResumeCore(Exception error) + { + ErrorCount++; + } + + /// + /// Called when sequence completed. + /// + /// The completion result. + protected override void OnCompletedCore(Result result) + { + CompletionCount++; + } +} + +/// +/// Minimal R3 observer for benchmarks that only need subscription lifecycle handling. +/// +internal sealed class IntR3ActionObserver : Observer +{ + /// + /// Receives the next value. + /// + /// The value. + protected override void OnNextCore(int value) + { + } + + /// + /// Receives an error. + /// + /// The observed exception. + protected override void OnErrorResumeCore(Exception error) + { + } + + /// + /// Receives the completion result. + /// + /// Completion metadata. + protected override void OnCompletedCore(Result result) + { + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/BooleanSignalObserver.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/BooleanSignalObserver.cs new file mode 100644 index 0000000..38e1235 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/BooleanSignalObserver.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Observer used to capture a boolean result in benchmark cases. +/// +internal sealed class BooleanSignalObserver : IObserver +{ + /// + /// Gets a value indicating whether the latest sequence value was . + /// + public bool Value { get; private set; } + + /// + /// Gets the number of terminal completions observed. + /// + public int CompletionCount { get; private set; } + + /// + /// Gets the number of errors observed. + /// + public int ErrorCount { get; private set; } + + /// + /// Called when a value is received. + /// + /// The value. + public void OnNext(bool value) + { + Value = value; + } + + /// + /// Called when an error is observed. + /// + /// The exception. + public void OnError(Exception error) + { + ErrorCount++; + } + + /// + /// Called when sequence completed. + /// + public void OnCompleted() + { + CompletionCount++; + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/CoreRuntimeBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/CoreRuntimeBenchmarks.cs new file mode 100644 index 0000000..6787031 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/CoreRuntimeBenchmarks.cs @@ -0,0 +1,107 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Core; +using ReactiveUI.Primitives.Disposables; +using System.Reactive.Concurrency; +using RxDisposable = System.Reactive.Disposables.Disposable; +using RxCompositeDisposable = System.Reactive.Disposables.CompositeDisposable; + +using RxCurrentThreadScheduler = System.Reactive.Concurrency.CurrentThreadScheduler; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks for low-level runtime and sequencing primitives. +/// +[MemoryDiagnoser] +public class CoreRuntimeBenchmarks +{ + /// + /// Baseline multi-action dispose path for . + /// + /// The number of disposal callbacks executed. + [Benchmark(Baseline = true)] + public int PrimitivesPocketDispose() + { + var disposed = 0; + var pocket = new Pocket( + Disposable.Create(() => disposed++), + Disposable.Create(() => disposed++), + Disposable.Create(() => disposed++)); + + pocket.Dispose(); + return disposed; + } + + /// + /// Composite disposable dispose path in System.Reactive. + /// + /// The number of disposal callbacks executed. + [Benchmark] + public int SystemReactiveCompositeDispose() + { + var disposed = 0; + var pocket = new RxCompositeDisposable( + RxDisposable.Create(() => disposed++), + RxDisposable.Create(() => disposed++), + RxDisposable.Create(() => disposed++)); + + pocket.Dispose(); + return disposed; + } + + /// + /// Schedule and execute one action on current-thread sequencer. + /// + /// The executed marker value. + [Benchmark] + public int PrimitivesCurrentThreadSchedule() + { + var value = 0; + using var scheduled = Sequencer.CurrentThread.Schedule(() => value = 1); + return value; + } + + /// + /// Schedule and execute one action on System.Reactive current-thread scheduler. + /// + /// The executed marker value. + [Benchmark] + public int SystemReactiveCurrentThreadSchedule() + { + var value = 0; + using var scheduled = RxCurrentThreadScheduler.Instance.Schedule(() => value = 1); + return value; + } + + /// + /// Wrap a witness with the safe witness helper. + /// + /// The forwarded value. + [Benchmark] + public int PrimitivesSafeWitness() + { + var value = 0; + var witness = Witness.Safe(Witness.Create(x => value = x)); + witness.OnNext(42); + witness.OnCompleted(); + return value; + } + + /// + /// Allocating a completed spark should remain allocation efficient. + /// + /// An integer marker extracted from kind. + [Benchmark] + public int PrimitivesCompletedSpark() + { + var spark = Spark.CreateOnCompleted(); + return (int)spark.Kind; + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryFromEnumerableBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryFromEnumerableBenchmarks.cs new file mode 100644 index 0000000..dcbed38 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryFromEnumerableBenchmarks.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using System.Threading; +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; + +using RxObservable = System.Reactive.Linq.Observable; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks for enumerable-to-observable adapter factories. +/// +[MemoryDiagnoser] +public class FactoryFromEnumerableBenchmarks +{ + private static readonly int[] Values = + [ + 0, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 10, 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, + ]; + + /// + /// Baseline enumerable adapter using ReactiveUI.Primitives. + /// + /// The sum of emitted values. + [Benchmark(Baseline = true)] + public int PrimitivesFromEnumerableSubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = Signal.FromEnumerable(Values).Subscribe(observer); + return observer.Total; + } + + /// + /// Enumerable adapter using System.Reactive. + /// + /// The sum of emitted values. + [Benchmark] + public int SystemReactiveToObservableSubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = RxObservable.ToObservable(Values).Subscribe(observer); + return observer.Total; + } + + /// + /// Enumerable adapter using R3. + /// + /// The sum of emitted values. + [Benchmark] + public int R3ToObservableSubscribe() + { + var observer = new IntR3Observer(); + using var subscription = R3.Observable.ToObservable(Values, CancellationToken.None).Subscribe(observer); + return observer.Total; + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactorySignalBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactorySignalBenchmarks.cs new file mode 100644 index 0000000..f2a6cc1 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactorySignalBenchmarks.cs @@ -0,0 +1,170 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; +using System.Reactive.Linq; + +using RxObservable = System.Reactive.Linq.Observable; +using RxSystemActionObserver = System.Reactive.Linq.Observable; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks for factory-style signal constructors. +/// +[MemoryDiagnoser] +public class FactorySignalBenchmarks +{ + private const int RangeStart = 4; + private const int RangeCount = 32; + private const int RepeatCount = 32; + private const int ThrowValue = 42; + + /// + /// Baseline benchmark for empty completion with the primitives implementation. + /// + /// The number of completion notifications observed. + [Benchmark(Baseline = true)] + public int PrimitivesEmptySubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = Signal.Empty().Subscribe(observer); + return observer.CompletionCount; + } + + /// + /// Benchmarks System.Reactive empty completion path. + /// + /// The number of completion notifications observed. + [Benchmark] + public int SystemReactiveEmptySubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = RxObservable.Empty().Subscribe(observer); + return observer.CompletionCount; + } + + /// + /// Benchmarks R3 empty completion path. + /// + /// The number of completion notifications observed. + [Benchmark] + public int R3EmptySubscribe() + { + var observer = new IntR3Observer(); + using var subscription = R3.Observable.Empty().Subscribe(observer); + return observer.CompletionCount; + } + + /// + /// Benchmarks range generation and subscription. + /// + /// The sum of the received integer range. + [Benchmark] + public int PrimitivesRangeSubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = Signal.Range(RangeStart, RangeCount).Subscribe(observer); + return observer.Total; + } + + /// + /// Benchmarks range generation and subscription using System.Reactive. + /// + /// The sum of the received integer range. + [Benchmark] + public int SystemReactiveRangeSubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = RxObservable.Range(RangeStart, RangeCount).Subscribe(observer); + return observer.Total; + } + + /// + /// Benchmarks range generation and subscription using R3. + /// + /// The sum of the received integer range. + [Benchmark] + public int R3RangeSubscribe() + { + var observer = new IntR3Observer(); + using var subscription = R3.Observable.Range(RangeStart, RangeCount).Subscribe(observer); + return observer.Total; + } + + /// + /// Benchmarks fixed repeat sequence generation. + /// + /// The sum of the received repeated values. + [Benchmark] + public int PrimitivesRepeatSubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = Signal.Repeat(ThrowValue, RepeatCount).Subscribe(observer); + return observer.Total; + } + + /// + /// Benchmarks fixed repeat sequence generation in System.Reactive. + /// + /// The sum of the received repeated values. + [Benchmark] + public int SystemReactiveRepeatSubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = RxObservable.Repeat(ThrowValue, RepeatCount).Subscribe(observer); + return observer.Total; + } + + /// + /// Benchmarks fixed repeat sequence generation in R3. + /// + /// The sum of the received repeated values. + [Benchmark] + public int R3RepeatSubscribe() + { + var observer = new IntR3Observer(); + using var subscription = R3.Observable.Repeat(ThrowValue, RepeatCount).Subscribe(observer); + return observer.Total; + } + + /// + /// Benchmarks terminal error completion for primitives. + /// + /// The number of errors observed. + [Benchmark] + public int PrimitivesThrowSubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = Signal.Throw(new InvalidOperationException()).Subscribe(observer); + return observer.ErrorCount; + } + + /// + /// Benchmarks terminal error completion for System.Reactive. + /// + /// The number of errors observed. + [Benchmark] + public int SystemReactiveThrowSubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = RxObservable.Throw(new InvalidOperationException()).Subscribe(observer); + return observer.ErrorCount; + } + + /// + /// Benchmarks terminal error completion for R3. + /// + /// The number of errors observed. + [Benchmark] + public int R3ThrowSubscribe() + { + var observer = new IntR3Observer(); + using var subscription = R3.Observable.Throw(new InvalidOperationException()).Subscribe(observer); + return observer.ErrorCount; + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorMapKeepBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorMapKeepBenchmarks.cs new file mode 100644 index 0000000..35f2263 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorMapKeepBenchmarks.cs @@ -0,0 +1,132 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; +using System.Reactive.Linq; +using System.Threading; + +using RxObservable = System.Reactive.Linq.Observable; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Operator benchmarks for mapping, filtering, and aggregate predicates. +/// +[MemoryDiagnoser] +public class OperatorMapKeepBenchmarks +{ + private const int StartValue = 0; + private const int RangeCount = 32; + + /// + /// Baseline map/where chain using primitives. + /// + /// The aggregate total. + [Benchmark(Baseline = true)] + public int PrimitivesRangeMapKeep() + { + var observer = new IntSignalObserver(); + using var subscription = Signal.Range(StartValue, RangeCount) + .Map(static x => x + 1) + .Keep(static x => (x & 1) == 0) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Map/where chain using System.Reactive. + /// + /// The aggregate total. + [Benchmark] + public int SystemReactiveRangeSelectWhere() + { + var observer = new IntSignalObserver(); + using var subscription = RxObservable.Where( + RxObservable.Select(RxObservable.Range(StartValue, RangeCount), static x => x + 1), + static x => (x & 1) == 0) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Map/where chain using R3. + /// + /// The aggregate total. + [Benchmark] + public int R3RangeSelectWhere() + { + var observer = new IntR3Observer(); + using var subscription = R3.ObservableExtensions.Where( + R3.ObservableExtensions.Select( + R3.Observable.Range(StartValue, RangeCount), + static (int x) => x + 1), + static (int x) => (x & 1) == 0) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Baseline aggregate/count with predicate sequence. + /// + /// Negated count if predicate matched; otherwise positive count. + [Benchmark] + public int PrimitivesAggregateAnyCount() + { + var count = new IntSignalObserver(); + var any = new BooleanSignalObserver(); + using var countSubscription = Signal.Range(StartValue, RangeCount) + .DistinctBy(static x => x / 2) + .Count() + .Subscribe(count); + using var anySubscription = Signal.Range(StartValue, RangeCount) + .Any(static x => x == 31) + .Subscribe(any); + return any.Value ? count.Total : -count.Total; + } + + /// + /// Aggregate/count with predicate sequence using System.Reactive. + /// + /// Negated count if predicate matched; otherwise positive count. + [Benchmark] + public int SystemReactiveAggregateAnyCount() + { + var count = new IntSignalObserver(); + var any = new BooleanSignalObserver(); + using var countSubscription = RxObservable.Count( + RxObservable.Distinct( + RxObservable.Select(RxObservable.Range(StartValue, RangeCount), static x => x / 2))) + .Subscribe(count); + using var anySubscription = RxObservable.Any(RxObservable.Range(StartValue, RangeCount), static x => x == 31) + .Subscribe(any); + return any.Value ? count.Total : -count.Total; + } + + /// + /// Aggregate/count with predicate sequence using R3. + /// + /// Negated count if predicate matched; otherwise positive count. + [Benchmark] + public async Task R3AggregateAnyCount() + { + var count = await R3.ObservableExtensions.CountAsync( + R3.ObservableExtensions.Distinct( + R3.ObservableExtensions.Select( + R3.Observable.Range(StartValue, RangeCount), + static (int x) => x / 2)), + CancellationToken.None) + .ConfigureAwait(false); + + var any = await R3.ObservableExtensions.AnyAsync( + R3.Observable.Range(StartValue, RangeCount), + static (int x) => x == 31, + CancellationToken.None) + .ConfigureAwait(false); + + return any ? count : -count; + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorSelectManyRangeBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorSelectManyRangeBenchmarks.cs new file mode 100644 index 0000000..665ca4f --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorSelectManyRangeBenchmarks.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; +using System.Reactive.Linq; + +using RxObservable = System.Reactive.Linq.Observable; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks for flattening selectors. +/// +[MemoryDiagnoser] +public class OperatorSelectManyRangeBenchmarks +{ + /// + /// Baseline flatten and map chain using primitives. + /// + /// The sum of emitted values. + [Benchmark(Baseline = true)] + public int PrimitivesSelectManyRange() + { + var observer = new IntSignalObserver(); + using var subscription = Signal.Range(1, 8).SelectMany(static x => Signal.Range(x * 10, 2)) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Flatten and map chain using System.Reactive. + /// + /// The sum of emitted values. + [Benchmark] + public int SystemReactiveSelectManyRange() + { + var observer = new IntSignalObserver(); + using var subscription = RxObservable.SelectMany( + RxObservable.Range(1, 8), + static x => RxObservable.Range(x * 10, 2)) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Flatten and map chain using R3. + /// + /// The sum of emitted values. + [Benchmark] + public int R3SelectManyRange() + { + var observer = new IntR3Observer(); + using var subscription = R3.ObservableExtensions.SelectMany( + R3.Observable.Range(1, 8), + static (int x) => R3.Observable.Range(x * 10, 2)) + .Subscribe(observer); + return observer.Total; + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStartWithAppendDefaultIfEmptyBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStartWithAppendDefaultIfEmptyBenchmarks.cs new file mode 100644 index 0000000..f313b4c --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStartWithAppendDefaultIfEmptyBenchmarks.cs @@ -0,0 +1,72 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; +using System.Reactive.Linq; + +using RxObservable = System.Reactive.Linq.Observable; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks for default-if-empty, prepend/append, and equivalent operators. +/// +[MemoryDiagnoser] +public class OperatorStartWithAppendDefaultIfEmptyBenchmarks +{ + /// + /// Baseline start-with / default-if-empty / append chain using primitives. + /// + /// The sum of emitted values. + [Benchmark(Baseline = true)] + public int PrimitivesStartWithAppendDefaultIfEmpty() + { + var observer = new IntSignalObserver(); + using var subscription = Signal.Empty() + .DefaultIfEmpty(2) + .StartWith(1) + .Append(3) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Equivalent composition using System.Reactive. + /// + /// The sum of emitted values. + [Benchmark] + public int SystemReactiveStartWithAppendDefaultIfEmpty() + { + var observer = new IntSignalObserver(); + using var subscription = RxObservable.Append( + RxObservable.StartWith( + RxObservable.DefaultIfEmpty(RxObservable.Empty(), 2), + 1), + 3) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Equivalent chain using R3. + /// + /// The sum of emitted values. + [Benchmark] + public int R3PrependAppendDefaultIfEmpty() + { + var observer = new IntR3Observer(); + using var subscription = R3.ObservableExtensions.Append( + R3.ObservableExtensions.Prepend( + R3.ObservableExtensions.DefaultIfEmpty( + R3.Observable.Empty(), + 2), + 1), + 3) + .Subscribe(observer); + return observer.Total; + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorZipBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorZipBenchmarks.cs new file mode 100644 index 0000000..88cfa3b --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorZipBenchmarks.cs @@ -0,0 +1,71 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; +using System.Reactive.Linq; + +using RxObservable = System.Reactive.Linq.Observable; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks for pairwise zip composition. +/// +[MemoryDiagnoser] +public class OperatorZipBenchmarks +{ + private const int LeftStart = 1; + private const int RightStart = 10; + private const int Count = 16; + + /// + /// Baseline zip using ReactiveUI.Primitives. + /// + /// The sum of zipped values. + [Benchmark(Baseline = true)] + public int PrimitivesZip() + { + var observer = new IntSignalObserver(); + using var subscription = Signal.Zip( + Signal.Range(LeftStart, Count), + Signal.Range(RightStart, Count), + static (left, right) => left + right) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Zip using System.Reactive. + /// + /// The sum of zipped values. + [Benchmark] + public int SystemReactiveZip() + { + var observer = new IntSignalObserver(); + using var subscription = RxObservable.Zip( + RxObservable.Range(LeftStart, Count), + RxObservable.Range(RightStart, Count), + static (left, right) => left + right) + .Subscribe(observer); + return observer.Total; + } + + /// + /// Zip using R3. + /// + /// The sum of zipped values. + [Benchmark] + public int R3Zip() + { + var observer = new IntR3Observer(); + using var subscription = R3.Observable.Zip( + R3.Observable.Range(LeftStart, Count), + R3.Observable.Range(RightStart, Count), + static (left, right) => left + right) + .Subscribe(observer); + return observer.Total; + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/Program.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/Program.cs index 6873b33..7c99462 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/Program.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/Program.cs @@ -2,659 +2,125 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reactive.Concurrency; -using BenchmarkDotNet.Attributes; +using System; using BenchmarkDotNet.Running; -using ReactiveUI.Primitives; -using ReactiveUI.Primitives.Concurrency; -using ReactiveUI.Primitives.Core; -using ReactiveUI.Primitives.Disposables; -using ReactiveUI.Primitives.Signals; -using RxBehaviorSubject = System.Reactive.Subjects.BehaviorSubject; -using RxCompositeDisposable = System.Reactive.Disposables.CompositeDisposable; -using RxCurrentThreadScheduler = System.Reactive.Concurrency.CurrentThreadScheduler; -using RxDisposable = System.Reactive.Disposables.Disposable; -using RxReplaySubject = System.Reactive.Subjects.ReplaySubject; -using RxSubject = System.Reactive.Subjects.Subject; namespace ReactiveUI.Primitives.Benchmarks; +/// +/// Entry point for benchmark execution and smoke-test mode. +/// internal static class Program { - private static void Main(string[] args) + /// + /// Executes benchmarks, or runs a deterministic smoke check with --smoke. + /// + /// BenchmarkDotNet command-line arguments. + /// A task that completes when execution is finished. + public static async Task Main(string[] args) { if (args.Contains("--smoke", StringComparer.OrdinalIgnoreCase)) { - var scalar = new ScalarSignalBenchmarks(); - Console.WriteLine($"Primitives={scalar.PrimitivesReturnSubscribe()}"); - Console.WriteLine($"System.Reactive={scalar.SystemReactiveReturnSubscribe()}"); - Console.WriteLine($"R3={scalar.R3ReturnSubscribe()}"); - - var factories = new FactorySignalBenchmarks(); - Console.WriteLine($"PrimitivesEmptySubscribe={factories.PrimitivesEmptySubscribe()}"); - Console.WriteLine($"SystemReactiveEmptySubscribe={factories.SystemReactiveEmptySubscribe()}"); - Console.WriteLine($"R3EmptySubscribe={factories.R3EmptySubscribe()}"); - Console.WriteLine($"PrimitivesRangeSubscribe={factories.PrimitivesRangeSubscribe()}"); - Console.WriteLine($"SystemReactiveRangeSubscribe={factories.SystemReactiveRangeSubscribe()}"); - Console.WriteLine($"R3RangeSubscribe={factories.R3RangeSubscribe()}"); - Console.WriteLine($"PrimitivesRepeatSubscribe={factories.PrimitivesRepeatSubscribe()}"); - Console.WriteLine($"SystemReactiveRepeatSubscribe={factories.SystemReactiveRepeatSubscribe()}"); - Console.WriteLine($"R3RepeatSubscribe={factories.R3RepeatSubscribe()}"); - - var core = new CoreRuntimeBenchmarks(); - Console.WriteLine($"PrimitivesPocketDispose={core.PrimitivesPocketDispose()}"); - Console.WriteLine($"SystemReactiveCompositeDispose={core.SystemReactiveCompositeDispose()}"); - Console.WriteLine($"PrimitivesCurrentThreadSchedule={core.PrimitivesCurrentThreadSchedule()}"); - Console.WriteLine($"SystemReactiveCurrentThreadSchedule={core.SystemReactiveCurrentThreadSchedule()}"); - Console.WriteLine($"PrimitivesSafeWitness={core.PrimitivesSafeWitness()}"); - Console.WriteLine($"PrimitivesCompletedSpark={core.PrimitivesCompletedSpark()}"); - - var operators = new OperatorBenchmarks(); - Console.WriteLine($"PrimitivesRangeMapKeep={operators.PrimitivesRangeMapKeep()}"); - Console.WriteLine($"SystemReactiveRangeSelectWhere={operators.SystemReactiveRangeSelectWhere()}"); - Console.WriteLine($"R3RangeSelectWhere={operators.R3RangeSelectWhere()}"); - Console.WriteLine($"PrimitivesAggregateAnyCount={operators.PrimitivesAggregateAnyCount()}"); - Console.WriteLine($"SystemReactiveAggregateAnyCount={operators.SystemReactiveAggregateAnyCount()}"); - - var parityOperators = new OperatorParityBenchmarks(); - Console.WriteLine($"PrimitivesStartWithAppendDefaultIfEmpty={parityOperators.PrimitivesStartWithAppendDefaultIfEmpty()}"); - Console.WriteLine($"SystemReactiveStartWithAppendDefaultIfEmpty={parityOperators.SystemReactiveStartWithAppendDefaultIfEmpty()}"); - Console.WriteLine($"R3PrependAppendDefaultIfEmpty={parityOperators.R3PrependAppendDefaultIfEmpty()}"); - Console.WriteLine($"PrimitivesSelectManyRange={parityOperators.PrimitivesSelectManyRange()}"); - Console.WriteLine($"SystemReactiveSelectManyRange={parityOperators.SystemReactiveSelectManyRange()}"); - Console.WriteLine($"R3SelectManyRange={parityOperators.R3SelectManyRange()}"); - Console.WriteLine($"PrimitivesZip={parityOperators.PrimitivesZip()}"); - Console.WriteLine($"SystemReactiveZip={parityOperators.SystemReactiveZip()}"); - Console.WriteLine($"R3Zip={parityOperators.R3Zip()}"); - - var throughput = new SubjectThroughputBenchmarks { Count = 32 }; - Console.WriteLine($"PrimitivesSubjectEmitN={throughput.PrimitivesSubjectEmitN()}"); - Console.WriteLine($"SystemReactiveSubjectEmitN={throughput.SystemReactiveSubjectEmitN()}"); - Console.WriteLine($"R3SubjectEmitN={throughput.R3SubjectEmitN()}"); - - var subjectSubscriptions = new SubjectSubscriptionBenchmarks { Subscribers = 8 }; - Console.WriteLine($"PrimitivesSubjectSubscribeDisposeN={subjectSubscriptions.PrimitivesSubjectSubscribeDisposeN()}"); - Console.WriteLine($"SystemReactiveSubjectSubscribeDisposeN={subjectSubscriptions.SystemReactiveSubjectSubscribeDisposeN()}"); - Console.WriteLine($"R3SubjectSubscribeDisposeN={subjectSubscriptions.R3SubjectSubscribeDisposeN()}"); - - var state = new StatefulSignalBenchmarks { Count = 32 }; - Console.WriteLine($"PrimitivesBehaviourSignal={state.PrimitivesBehaviourSignal()}"); - Console.WriteLine($"SystemReactiveBehaviorSubject={state.SystemReactiveBehaviorSubject()}"); - Console.WriteLine($"R3BehaviorSubject={state.R3BehaviorSubject()}"); - - var replay = new ReplaySignalBenchmarks(); - Console.WriteLine($"PrimitivesReplaySubscribe={replay.PrimitivesReplaySubscribe()}"); - Console.WriteLine($"SystemReactiveReplaySubscribe={replay.SystemReactiveReplaySubscribe()}"); - - var taskBridge = new AsyncBridgeBenchmarks(); - Console.WriteLine($"PrimitivesCompletedTaskBridge={taskBridge.PrimitivesCompletedTaskBridge()}"); - Console.WriteLine($"SystemReactiveCompletedTaskBridge={taskBridge.SystemReactiveCompletedTaskBridge()}"); + await RunSmokeBenchmarksAsync(); return; } BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); } -} - -[MemoryDiagnoser] -public class FactorySignalBenchmarks -{ - [Benchmark(Baseline = true)] - public int PrimitivesEmptySubscribe() - { - var completed = 0; - using var subscription = Signal.Empty().Subscribe(static _ => { }, () => completed++); - return completed; - } - - [Benchmark] - public int SystemReactiveEmptySubscribe() - { - var completed = 0; - using var subscription = System.Reactive.Linq.Observable.Empty().Subscribe(static _ => { }, () => completed++); - return completed; - } - - [Benchmark] - public int R3EmptySubscribe() - { - using var observer = new R3CountingObserver(); - using var subscription = R3.Observable.Empty().Subscribe(observer); - return observer.Completed; - } - - [Benchmark] - public int PrimitivesRangeSubscribe() - { - var total = 0; - using var subscription = Signal.Range(4, 32).Subscribe(x => total += x); - return total; - } - [Benchmark] - public int SystemReactiveRangeSubscribe() - { - var total = 0; - using var subscription = System.Reactive.Linq.Observable.Range(4, 32).Subscribe(x => total += x); - return total; - } - - [Benchmark] - public int R3RangeSubscribe() - { - using var observer = new R3CountingObserver(); - using var subscription = R3.Observable.Range(4, 32).Subscribe(observer); - return observer.Total; - } - - [Benchmark] - public int PrimitivesRepeatSubscribe() - { - var total = 0; - using var subscription = Signal.Repeat(7, 32).Subscribe(x => total += x); - return total; + private static async Task RunSmokeBenchmarksAsync() + { + var scalar = new ScalarSignalBenchmarks(); + Console.WriteLine($"PrimitivesReturnSubscribe={scalar.PrimitivesReturnSubscribe()}"); + Console.WriteLine($"SystemReactiveReturnSubscribe={scalar.SystemReactiveReturnSubscribe()}"); + Console.WriteLine($"R3ReturnSubscribe={scalar.R3ReturnSubscribe()}"); + + var factory = new FactorySignalBenchmarks(); + Console.WriteLine($"PrimitivesEmptySubscribe={factory.PrimitivesEmptySubscribe()}"); + Console.WriteLine($"SystemReactiveEmptySubscribe={factory.SystemReactiveEmptySubscribe()}"); + Console.WriteLine($"R3EmptySubscribe={factory.R3EmptySubscribe()}"); + Console.WriteLine($"PrimitivesRangeSubscribe={factory.PrimitivesRangeSubscribe()}"); + Console.WriteLine($"SystemReactiveRangeSubscribe={factory.SystemReactiveRangeSubscribe()}"); + Console.WriteLine($"R3RangeSubscribe={factory.R3RangeSubscribe()}"); + Console.WriteLine($"PrimitivesRepeatSubscribe={factory.PrimitivesRepeatSubscribe()}"); + Console.WriteLine($"SystemReactiveRepeatSubscribe={factory.SystemReactiveRepeatSubscribe()}"); + Console.WriteLine($"R3RepeatSubscribe={factory.R3RepeatSubscribe()}"); + Console.WriteLine($"PrimitivesThrowSubscribe={factory.PrimitivesThrowSubscribe()}"); + Console.WriteLine($"SystemReactiveThrowSubscribe={factory.SystemReactiveThrowSubscribe()}"); + Console.WriteLine($"R3ThrowSubscribe={factory.R3ThrowSubscribe()}"); + + var fromEnumerable = new FactoryFromEnumerableBenchmarks(); + Console.WriteLine($"PrimitivesFromEnumerableSubscribe={fromEnumerable.PrimitivesFromEnumerableSubscribe()}"); + Console.WriteLine($"SystemReactiveToObservableSubscribe={fromEnumerable.SystemReactiveToObservableSubscribe()}"); + Console.WriteLine($"R3ToObservableSubscribe={fromEnumerable.R3ToObservableSubscribe()}"); + + var operators = new OperatorMapKeepBenchmarks(); + Console.WriteLine($"PrimitivesRangeMapKeep={operators.PrimitivesRangeMapKeep()}"); + Console.WriteLine($"SystemReactiveRangeSelectWhere={operators.SystemReactiveRangeSelectWhere()}"); + Console.WriteLine($"R3RangeSelectWhere={operators.R3RangeSelectWhere()}"); + Console.WriteLine($"PrimitivesAggregateAnyCount={operators.PrimitivesAggregateAnyCount()}"); + Console.WriteLine($"SystemReactiveAggregateAnyCount={operators.SystemReactiveAggregateAnyCount()}"); + Console.WriteLine($"R3AggregateAnyCount={await operators.R3AggregateAnyCount()}"); + + var startWith = new OperatorStartWithAppendDefaultIfEmptyBenchmarks(); + Console.WriteLine( + $"PrimitivesStartWithAppendDefaultIfEmpty={startWith.PrimitivesStartWithAppendDefaultIfEmpty()}"); + Console.WriteLine( + $"SystemReactiveStartWithAppendDefaultIfEmpty={startWith.SystemReactiveStartWithAppendDefaultIfEmpty()}"); + Console.WriteLine( + $"R3PrependAppendDefaultIfEmpty={startWith.R3PrependAppendDefaultIfEmpty()}"); + + var selectMany = new OperatorSelectManyRangeBenchmarks(); + Console.WriteLine($"PrimitivesSelectManyRange={selectMany.PrimitivesSelectManyRange()}"); + Console.WriteLine($"SystemReactiveSelectManyRange={selectMany.SystemReactiveSelectManyRange()}"); + Console.WriteLine($"R3SelectManyRange={selectMany.R3SelectManyRange()}"); + + var zip = new OperatorZipBenchmarks(); + Console.WriteLine($"PrimitivesZip={zip.PrimitivesZip()}"); + Console.WriteLine($"SystemReactiveZip={zip.SystemReactiveZip()}"); + Console.WriteLine($"R3Zip={zip.R3Zip()}"); + + var throughput = new SubjectThroughputBenchmarks(); + Console.WriteLine($"PrimitivesSubjectEmitN32={throughput.PrimitivesSubjectEmit32()}"); + Console.WriteLine($"SystemReactiveSubjectEmitN32={throughput.SystemReactiveSubjectEmit32()}"); + Console.WriteLine($"R3SubjectEmitN32={throughput.R3SubjectEmit32()}"); + Console.WriteLine($"PrimitivesSubjectEmitN1024={throughput.PrimitivesSubjectEmit1024()}"); + Console.WriteLine($"SystemReactiveSubjectEmitN1024={throughput.SystemReactiveSubjectEmit1024()}"); + Console.WriteLine($"R3SubjectEmitN1024={throughput.R3SubjectEmit1024()}"); + + var subscriptions = new SubjectSubscriptionBenchmarks(); + Console.WriteLine($"PrimitivesSubjectSubscribeDispose8={subscriptions.PrimitivesSubjectSubscribeDispose8()}"); + Console.WriteLine( + $"SystemReactiveSubjectSubscribeDispose8={subscriptions.SystemReactiveSubjectSubscribeDispose8()}"); + Console.WriteLine($"R3SubjectSubscribeDispose8={subscriptions.R3SubjectSubscribeDispose8()}"); + Console.WriteLine($"PrimitivesSubjectSubscribeDispose64={subscriptions.PrimitivesSubjectSubscribeDispose64()}"); + Console.WriteLine( + $"SystemReactiveSubjectSubscribeDispose64={subscriptions.SystemReactiveSubjectSubscribeDispose64()}"); + Console.WriteLine($"R3SubjectSubscribeDispose64={subscriptions.R3SubjectSubscribeDispose64()}"); + + var stateful = new StatefulSignalBenchmarks(); + Console.WriteLine($"PrimitivesBehaviourSignal32={stateful.PrimitivesBehaviourSignal32()}"); + Console.WriteLine($"SystemReactiveBehaviorSubject32={stateful.SystemReactiveBehaviorSubject32()}"); + Console.WriteLine($"R3BehaviorSubject32={stateful.R3BehaviorSubject32()}"); + Console.WriteLine($"PrimitivesBehaviourSignal1024={stateful.PrimitivesBehaviourSignal1024()}"); + Console.WriteLine($"SystemReactiveBehaviorSubject1024={stateful.SystemReactiveBehaviorSubject1024()}"); + Console.WriteLine($"R3BehaviorSubject1024={stateful.R3BehaviorSubject1024()}"); + + var replay = new ReplaySignalBenchmarks(); + Console.WriteLine($"PrimitivesReplaySubscribe={replay.PrimitivesReplaySubscribe()}"); + Console.WriteLine($"SystemReactiveReplaySubscribe={replay.SystemReactiveReplaySubscribe()}"); + + var taskBridge = new AsyncBridgeBenchmarks(); + Console.WriteLine($"PrimitivesCompletedTaskBridge={taskBridge.PrimitivesCompletedTaskBridge()}"); + Console.WriteLine($"SystemReactiveCompletedTaskBridge={taskBridge.SystemReactiveCompletedTaskBridge()}"); + + var coreRuntime = new CoreRuntimeBenchmarks(); + Console.WriteLine($"PrimitivesPocketDispose={coreRuntime.PrimitivesPocketDispose()}"); + Console.WriteLine($"SystemReactiveCompositeDispose={coreRuntime.SystemReactiveCompositeDispose()}"); + Console.WriteLine($"PrimitivesCurrentThreadSchedule={coreRuntime.PrimitivesCurrentThreadSchedule()}"); + Console.WriteLine( + $"SystemReactiveCurrentThreadSchedule={coreRuntime.SystemReactiveCurrentThreadSchedule()}"); + Console.WriteLine($"PrimitivesSafeWitness={coreRuntime.PrimitivesSafeWitness()}"); + Console.WriteLine($"PrimitivesCompletedSpark={coreRuntime.PrimitivesCompletedSpark()}"); } - - [Benchmark] - public int SystemReactiveRepeatSubscribe() - { - var total = 0; - using var subscription = System.Reactive.Linq.Observable.Repeat(7, 32).Subscribe(x => total += x); - return total; - } - - [Benchmark] - public int R3RepeatSubscribe() - { - using var observer = new R3CountingObserver(); - using var subscription = R3.Observable.Repeat(7, 32).Subscribe(observer); - return observer.Total; - } -} - -[MemoryDiagnoser] -public class ScalarSignalBenchmarks -{ - [Benchmark(Baseline = true)] - public int PrimitivesReturnSubscribe() - { - var value = 0; - using var subscription = Signal.Return(42).Subscribe(static x => { }, static () => { }); - using var capture = Signal.Return(42).Subscribe(x => value = x); - return value; - } - - [Benchmark] - public int SystemReactiveReturnSubscribe() - { - var value = 0; - using var subscription = System.Reactive.Linq.Observable.Return(42).Subscribe(x => value = x); - return value; - } - - [Benchmark] - public int R3ReturnSubscribe() - { - var value = 0; - using var subscription = R3.Observable.Return(42).Subscribe(new R3ActionObserver(x => value = x)); - return value; - } -} - -[MemoryDiagnoser] -public class OperatorBenchmarks -{ - [Benchmark(Baseline = true)] - public int PrimitivesRangeMapKeep() - { - var total = 0; - using var subscription = Signal.Range(0, 32).Map(static x => x + 1).Keep(static x => (x & 1) == 0).Subscribe(x => total += x); - return total; - } - - [Benchmark] - public int SystemReactiveRangeSelectWhere() - { - var total = 0; - using var subscription = System.Reactive.Linq.Observable.Where(System.Reactive.Linq.Observable.Select(System.Reactive.Linq.Observable.Range(0, 32), static x => x + 1), static x => (x & 1) == 0).Subscribe(x => total += x); - return total; - } - - [Benchmark] - public int R3RangeSelectWhere() - { - var total = 0; - using var subscription = R3.ObservableExtensions.Where(R3.ObservableExtensions.Select(R3.Observable.Range(0, 32), static (int x) => x + 1), static (int x) => (x & 1) == 0).Subscribe(new R3ActionObserver(x => total += x)); - return total; - } - - [Benchmark] - public int PrimitivesAggregateAnyCount() - { - var count = 0; - var any = false; - using var countSubscription = Signal.Range(0, 32).DistinctBy(static x => x / 2).Count().Subscribe(x => count = x); - using var anySubscription = Signal.Range(0, 32).Any(static x => x == 31).Subscribe(x => any = x); - return any ? count : -count; - } - - [Benchmark] - public int SystemReactiveAggregateAnyCount() - { - var count = 0; - var any = false; - using var countSubscription = System.Reactive.Linq.Observable.Count(System.Reactive.Linq.Observable.Distinct(System.Reactive.Linq.Observable.Select(System.Reactive.Linq.Observable.Range(0, 32), static x => x / 2))).Subscribe(x => count = x); - using var anySubscription = System.Reactive.Linq.Observable.Any(System.Reactive.Linq.Observable.Range(0, 32), static x => x == 31).Subscribe(x => any = x); - return any ? count : -count; - } -} - -[MemoryDiagnoser] -public class OperatorParityBenchmarks -{ - [Benchmark(Baseline = true)] - public int PrimitivesStartWithAppendDefaultIfEmpty() - { - var total = 0; - using var subscription = Signal.Empty().DefaultIfEmpty(2).StartWith(1).Append(3).Subscribe(x => total += x); - return total; - } - - [Benchmark] - public int SystemReactiveStartWithAppendDefaultIfEmpty() - { - var total = 0; - using var subscription = System.Reactive.Linq.Observable.Append( - System.Reactive.Linq.Observable.StartWith( - System.Reactive.Linq.Observable.DefaultIfEmpty(System.Reactive.Linq.Observable.Empty(), 2), - 1), - 3).Subscribe(x => total += x); - return total; - } - - [Benchmark] - public int R3PrependAppendDefaultIfEmpty() - { - using var observer = new R3CountingObserver(); - using var subscription = R3.ObservableExtensions.Append( - R3.ObservableExtensions.Prepend( - R3.ObservableExtensions.DefaultIfEmpty(R3.Observable.Empty(), 2), - 1), - 3).Subscribe(observer); - return observer.Total; - } - - [Benchmark] - public int PrimitivesSelectManyRange() - { - var total = 0; - using var subscription = Signal.Range(1, 8).SelectMany(static x => Signal.Range(x * 10, 2)).Subscribe(x => total += x); - return total; - } - - [Benchmark] - public int SystemReactiveSelectManyRange() - { - var total = 0; - using var subscription = System.Reactive.Linq.Observable.SelectMany( - System.Reactive.Linq.Observable.Range(1, 8), - static x => System.Reactive.Linq.Observable.Range(x * 10, 2)).Subscribe(x => total += x); - return total; - } - - [Benchmark] - public int R3SelectManyRange() - { - using var observer = new R3CountingObserver(); - using var subscription = R3.ObservableExtensions.SelectMany( - R3.Observable.Range(1, 8), - static (int x) => R3.Observable.Range(x * 10, 2)).Subscribe(observer); - return observer.Total; - } - - [Benchmark] - public int PrimitivesZip() - { - var total = 0; - using var subscription = Signal.Zip(Signal.Range(1, 16), Signal.Range(10, 16), static (left, right) => left + right).Subscribe(x => total += x); - return total; - } - - [Benchmark] - public int SystemReactiveZip() - { - var total = 0; - using var subscription = System.Reactive.Linq.Observable.Zip( - System.Reactive.Linq.Observable.Range(1, 16), - System.Reactive.Linq.Observable.Range(10, 16), - static (left, right) => left + right).Subscribe(x => total += x); - return total; - } - - [Benchmark] - public int R3Zip() - { - using var observer = new R3CountingObserver(); - using var subscription = R3.Observable.Zip( - R3.Observable.Range(1, 16), - R3.Observable.Range(10, 16), - static (left, right) => left + right).Subscribe(observer); - return observer.Total; - } -} - -[MemoryDiagnoser] -public class SubjectThroughputBenchmarks -{ - [Params(32, 1024)] - public int Count { get; set; } - - [Benchmark(Baseline = true)] - public int PrimitivesSubjectEmitN() - { - var total = 0; - using var subject = new Signal(); - using var subscription = subject.Subscribe(x => total += x); - for (var i = 0; i < Count; i++) - { - subject.OnNext(i); - } - - return total; - } - - [Benchmark] - public int SystemReactiveSubjectEmitN() - { - var total = 0; - using var subject = new RxSubject(); - using var subscription = subject.Subscribe(x => total += x); - for (var i = 0; i < Count; i++) - { - subject.OnNext(i); - } - - return total; - } - - [Benchmark] - public int R3SubjectEmitN() - { - var total = 0; - using var subject = new R3.Subject(); - using var subscription = subject.Subscribe(new R3ActionObserver(x => total += x)); - for (var i = 0; i < Count; i++) - { - subject.OnNext(i); - } - - return total; - } -} - -[MemoryDiagnoser] -public class SubjectSubscriptionBenchmarks -{ - [Params(8, 64)] - public int Subscribers { get; set; } - - [Benchmark(Baseline = true)] - public int PrimitivesSubjectSubscribeDisposeN() - { - using var subject = new Signal(); - var subscriptions = new IDisposable[Subscribers]; - for (var i = 0; i < subscriptions.Length; i++) - { - subscriptions[i] = subject.Subscribe(static _ => { }); - } - - var hasObservers = subject.HasObservers ? 1 : 0; - for (var i = 0; i < subscriptions.Length; i++) - { - subscriptions[i].Dispose(); - } - - return hasObservers + (subject.HasObservers ? 1 : 0); - } - - [Benchmark] - public int SystemReactiveSubjectSubscribeDisposeN() - { - using var subject = new RxSubject(); - var subscriptions = new IDisposable[Subscribers]; - for (var i = 0; i < subscriptions.Length; i++) - { - subscriptions[i] = subject.Subscribe(static _ => { }); - } - - for (var i = 0; i < subscriptions.Length; i++) - { - subscriptions[i].Dispose(); - } - - return subscriptions.Length; - } - - [Benchmark] - public int R3SubjectSubscribeDisposeN() - { - using var subject = new R3.Subject(); - var subscriptions = new IDisposable[Subscribers]; - for (var i = 0; i < subscriptions.Length; i++) - { - subscriptions[i] = subject.Subscribe(new R3ActionObserver(static _ => { })); - } - - for (var i = 0; i < subscriptions.Length; i++) - { - subscriptions[i].Dispose(); - } - - return subscriptions.Length; - } -} - -[MemoryDiagnoser] -public class StatefulSignalBenchmarks -{ - [Params(32, 1024)] - public int Count { get; set; } - - [Benchmark(Baseline = true)] - public int PrimitivesBehaviourSignal() - { - var total = 0; - using var subject = new BehaviourSignal(0); - using var subscription = subject.Subscribe(x => total += x); - for (var i = 1; i <= Count; i++) - { - subject.OnNext(i); - } - - return total + subject.Value; - } - - [Benchmark] - public int SystemReactiveBehaviorSubject() - { - var total = 0; - using var subject = new RxBehaviorSubject(0); - using var subscription = subject.Subscribe(x => total += x); - for (var i = 1; i <= Count; i++) - { - subject.OnNext(i); - } - - return total + subject.Value; - } - - [Benchmark] - public int R3BehaviorSubject() - { - var total = 0; - using var subject = new R3.BehaviorSubject(0); - using var subscription = subject.Subscribe(new R3ActionObserver(x => total += x)); - for (var i = 1; i <= Count; i++) - { - subject.OnNext(i); - } - - return total + subject.Value; - } -} - -[MemoryDiagnoser] -public class ReplaySignalBenchmarks -{ - [Benchmark(Baseline = true)] - public int PrimitivesReplaySubscribe() - { - using var subject = new ReplaySignal(16); - for (var i = 0; i < 16; i++) - { - subject.OnNext(i); - } - - var total = 0; - using var subscription = subject.Subscribe(x => total += x); - return total; - } - - [Benchmark] - public int SystemReactiveReplaySubscribe() - { - using var subject = new RxReplaySubject(16); - for (var i = 0; i < 16; i++) - { - subject.OnNext(i); - } - - var total = 0; - using var subscription = subject.Subscribe(x => total += x); - return total; - } -} - -[MemoryDiagnoser] -public class AsyncBridgeBenchmarks -{ - private static readonly Task CompletedTask = Task.FromResult(42); - - [Benchmark(Baseline = true)] - public int PrimitivesCompletedTaskBridge() - { - var value = 0; - using var subscription = Signal.FromTask(CompletedTask).Subscribe(x => value = x); - return value; - } - - [Benchmark] - public int SystemReactiveCompletedTaskBridge() - { - var value = 0; - using var subscription = System.Reactive.Linq.Observable.FromAsync(() => CompletedTask).Subscribe(x => value = x); - return value; - } -} - -[MemoryDiagnoser] -public class CoreRuntimeBenchmarks -{ - [Benchmark(Baseline = true)] - public int PrimitivesPocketDispose() - { - var disposed = 0; - var pocket = new Pocket( - Disposable.Create(() => disposed++), - Disposable.Create(() => disposed++), - Disposable.Create(() => disposed++)); - - pocket.Dispose(); - return disposed; - } - - [Benchmark] - public int SystemReactiveCompositeDispose() - { - var disposed = 0; - var pocket = new RxCompositeDisposable( - RxDisposable.Create(() => disposed++), - RxDisposable.Create(() => disposed++), - RxDisposable.Create(() => disposed++)); - - pocket.Dispose(); - return disposed; - } - - [Benchmark] - public int PrimitivesCurrentThreadSchedule() - { - var value = 0; - using var scheduled = ReactiveUI.Primitives.Concurrency.Sequencer.CurrentThread.Schedule(() => value = 1); - return value; - } - - [Benchmark] - public int SystemReactiveCurrentThreadSchedule() - { - var value = 0; - using var scheduled = RxCurrentThreadScheduler.Instance.Schedule(() => value = 1); - return value; - } - - [Benchmark] - public int PrimitivesSafeWitness() - { - var value = 0; - var witness = Witness.Safe(Witness.Create(x => value = x)); - witness.OnNext(42); - witness.OnCompleted(); - return value; - } - - [Benchmark] - public int PrimitivesCompletedSpark() - { - var spark = Spark.CreateOnCompleted(); - return (int)spark.Kind; - } -} - -internal sealed class R3ActionObserver : R3.Observer -{ - private readonly Action _onNext; - - public R3ActionObserver(Action onNext) => _onNext = onNext; - - protected override void OnNextCore(T value) => _onNext(value); - - protected override void OnErrorResumeCore(Exception error) => throw error; - - protected override void OnCompletedCore(R3.Result result) - { - } -} - -internal sealed class R3CountingObserver : R3.Observer -{ - public int Completed { get; private set; } - - public int Count { get; private set; } - - public int Total { get; private set; } - - protected override void OnNextCore(T value) - { - Count++; - if (value is not int intValue) - { - return; - } - - Total += intValue; - } - - protected override void OnErrorResumeCore(Exception error) => throw error; - - protected override void OnCompletedCore(R3.Result result) => Completed++; } diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj index 164d6fc..0ebd45b 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj @@ -7,8 +7,10 @@ enable preview false - false - false + true + true + false + $(NoWarn);SA1600;SA1649;SA1402;SA1518;SA1208;SA1210;SA1211;S109;S3257;CA1822;S1128;SA1200 diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReplaySignalBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReplaySignalBenchmarks.cs new file mode 100644 index 0000000..e201289 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReplaySignalBenchmarks.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; +using R3; + +using RxReplaySubject = System.Reactive.Subjects.ReplaySubject; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks replay/snapshot behavior for bounded replay buffers. +/// +[MemoryDiagnoser] +public class ReplaySignalBenchmarks +{ + /// + /// Baseline bounded replay subscription benchmark for primitives. + /// + /// The sum replayed to a late subscriber. + [Benchmark(Baseline = true)] + public int PrimitivesReplaySubscribe() + { + var observer = new IntSignalObserver(); + using var subject = new ReplaySignal(16); + PopulateReplaySubject(subject); + using var subscription = subject.Subscribe(observer); + return observer.Total; + } + + /// + /// Bounded replay subscription benchmark for System.Reactive. + /// + /// The sum replayed to a late subscriber. + [Benchmark] + public int SystemReactiveReplaySubscribe() + { + var observer = new IntSignalObserver(); + using var subject = new RxReplaySubject(16); + PopulateReplaySubject(subject); + using var subscription = subject.Subscribe(observer); + return observer.Total; + } + + private static void PopulateReplaySubject(ReplaySignal subject) + { + for (var i = 0; i < 16; i++) + { + subject.OnNext(i); + } + } + + private static void PopulateReplaySubject(RxReplaySubject subject) + { + for (var i = 0; i < 16; i++) + { + subject.OnNext(i); + } + } +} + diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ScalarSignalBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ScalarSignalBenchmarks.cs new file mode 100644 index 0000000..fed024c --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ScalarSignalBenchmarks.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; +using System.Reactive.Linq; +using R3; + +using RxObservable = System.Reactive.Linq.Observable; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks for single value signal construction and observation. +/// +[MemoryDiagnoser] +public class ScalarSignalBenchmarks +{ + private const int ScalarValue = 42; + + /// + /// Baseline single-value sequence with ReactiveUI.Primitives. + /// + /// The observed value. + [Benchmark(Baseline = true)] + public int PrimitivesReturnSubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = Signal.Return(ScalarValue).Subscribe(observer); + return observer.LastValue; + } + + /// + /// Single-value sequence using System.Reactive. + /// + /// The observed value. + [Benchmark] + public int SystemReactiveReturnSubscribe() + { + var observer = new IntSignalObserver(); + using var subscription = RxObservable.Return(ScalarValue).Subscribe(observer); + return observer.LastValue; + } + + /// + /// Single-value sequence using R3. + /// + /// The observed value. + [Benchmark] + public int R3ReturnSubscribe() + { + var observer = new IntR3Observer(); + using var subscription = R3.Observable.Return(ScalarValue).Subscribe(observer); + return observer.Total; + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/StatefulSignalBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/StatefulSignalBenchmarks.cs new file mode 100644 index 0000000..fbccb26 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/StatefulSignalBenchmarks.cs @@ -0,0 +1,122 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; +using R3; + +using RxBehaviorSubject = System.Reactive.Subjects.BehaviorSubject; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks for stateful behaviour/replay-like signal subscriptions. +/// +[MemoryDiagnoser] +public class StatefulSignalBenchmarks +{ + private const int Count32 = 32; + private const int Count1024 = 1024; + + /// + /// Baseline behavior-like stream updates with 32 notifications. + /// + /// The final sum plus latest value. + [Benchmark(Baseline = true)] + public int PrimitivesBehaviourSignal32() + { + return EmitAndReadBehaviourSignal(Count32); + } + + /// + /// Behavior subject updates with 32 notifications using System.Reactive. + /// + /// The final sum plus latest value. + [Benchmark] + public int SystemReactiveBehaviorSubject32() + { + return EmitAndReadSystemBehaviorSubject(Count32); + } + + /// + /// Behavior subject updates with 32 notifications using R3. + /// + /// The final sum plus latest value. + [Benchmark] + public int R3BehaviorSubject32() + { + return EmitAndReadR3BehaviorSubject(Count32); + } + + /// + /// Baseline behavior-like stream updates with 1024 notifications. + /// + /// The final sum plus latest value. + [Benchmark] + public int PrimitivesBehaviourSignal1024() + { + return EmitAndReadBehaviourSignal(Count1024); + } + + /// + /// Behavior subject updates with 1024 notifications using System.Reactive. + /// + /// The final sum plus latest value. + [Benchmark] + public int SystemReactiveBehaviorSubject1024() + { + return EmitAndReadSystemBehaviorSubject(Count1024); + } + + /// + /// Behavior subject updates with 1024 notifications using R3. + /// + /// The final sum plus latest value. + [Benchmark] + public int R3BehaviorSubject1024() + { + return EmitAndReadR3BehaviorSubject(Count1024); + } + + private static int EmitAndReadBehaviourSignal(int count) + { + var observer = new IntSignalObserver(); + using var subject = new BehaviourSignal(0); + using var subscription = subject.Subscribe(observer); + for (var i = 1; i <= count; i++) + { + subject.OnNext(i); + } + + return observer.Total + subject.Value; + } + + private static int EmitAndReadSystemBehaviorSubject(int count) + { + var observer = new IntSignalObserver(); + using var subject = new RxBehaviorSubject(0); + using var subscription = subject.Subscribe(observer); + for (var i = 1; i <= count; i++) + { + subject.OnNext(i); + } + + return observer.Total + subject.Value; + } + + private static int EmitAndReadR3BehaviorSubject(int count) + { + var observer = new IntR3Observer(); + using var subject = new R3.BehaviorSubject(0); + using var subscription = subject.Subscribe(observer); + for (var i = 1; i <= count; i++) + { + subject.OnNext(i); + } + + return observer.Total + subject.Value; + } +} + diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectSubscriptionBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectSubscriptionBenchmarks.cs new file mode 100644 index 0000000..26030b8 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectSubscriptionBenchmarks.cs @@ -0,0 +1,136 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; +using R3; + +using RxSubject = System.Reactive.Subjects.Subject; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks for subscription fan-in and disposal operations. +/// +[MemoryDiagnoser] +public class SubjectSubscriptionBenchmarks +{ + private const int SubscriberCount8 = 8; + private const int SubscriberCount64 = 64; + + /// + /// Subscribes and disposes 8 observers from primitives . + /// + /// The final observer count after disposal. + [Benchmark(Baseline = true)] + public int PrimitivesSubjectSubscribeDispose8() + { + return SubscribeDisposeCountSignal(SubscriberCount8); + } + + /// + /// Subscribes and disposes 8 observers from System.Reactive Subject. + /// + /// The final observer count after disposal. + [Benchmark] + public int SystemReactiveSubjectSubscribeDispose8() + { + return SubscribeDisposeCountSystemSubject(SubscriberCount8); + } + + /// + /// Subscribes and disposes 8 observers from . + /// + /// The final observer count after disposal. + [Benchmark] + public int R3SubjectSubscribeDispose8() + { + return SubscribeDisposeCountR3Subject(SubscriberCount8); + } + + /// + /// Subscribes and disposes 64 observers from primitives . + /// + /// The final observer count after disposal. + [Benchmark] + public int PrimitivesSubjectSubscribeDispose64() + { + return SubscribeDisposeCountSignal(SubscriberCount64); + } + + /// + /// Subscribes and disposes 64 observers from System.Reactive Subject. + /// + /// The final observer count after disposal. + [Benchmark] + public int SystemReactiveSubjectSubscribeDispose64() + { + return SubscribeDisposeCountSystemSubject(SubscriberCount64); + } + + /// + /// Subscribes and disposes 64 observers from . + /// + /// The final observer count after disposal. + [Benchmark] + public int R3SubjectSubscribeDispose64() + { + return SubscribeDisposeCountR3Subject(SubscriberCount64); + } + + private static int SubscribeDisposeCountSignal(int subscribers) + { + var observer = new IntSignalObserver(); + using var subject = new Signal(); + var disposables = new IDisposable[subscribers]; + for (var i = 0; i < subscribers; i++) + { + disposables[i] = subject.Subscribe(observer); + } + + var before = subject.HasObservers ? 1 : 0; + for (var i = 0; i < subscribers; i++) + { + disposables[i].Dispose(); + } + + return before + (subject.HasObservers ? 1 : 0); + } + + private static int SubscribeDisposeCountSystemSubject(int subscribers) + { + var observer = new IntSignalObserver(); + using var subject = new RxSubject(); + var disposables = new System.IDisposable[subscribers]; + for (var i = 0; i < subscribers; i++) + { + disposables[i] = subject.Subscribe(observer); + } + + for (var i = 0; i < subscribers; i++) + { + disposables[i].Dispose(); + } + + return disposables.Length; + } + + private static int SubscribeDisposeCountR3Subject(int subscribers) + { + using var subject = new R3.Subject(); + var disposables = new IDisposable[subscribers]; + for (var i = 0; i < subscribers; i++) + { + disposables[i] = subject.Subscribe(new IntR3ActionObserver()); + } + + for (var i = 0; i < subscribers; i++) + { + disposables[i].Dispose(); + } + + return disposables.Length; + } +} diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectThroughputBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectThroughputBenchmarks.cs new file mode 100644 index 0000000..013700d --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectThroughputBenchmarks.cs @@ -0,0 +1,121 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using BenchmarkDotNet.Attributes; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Signals; +using R3; + +using RxSubject = System.Reactive.Subjects.Subject; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// +/// Benchmarks for hot path subject-like emission throughput. +/// +[MemoryDiagnoser] +public class SubjectThroughputBenchmarks +{ + private const int EmitCount32 = 32; + private const int EmitCount1024 = 1024; + + /// + /// Emits 32 values into primitives . + /// + /// The sum of observed values. + [Benchmark(Baseline = true)] + public int PrimitivesSubjectEmit32() + { + return EmitThroughSignal(EmitCount32); + } + + /// + /// Emits 32 values into System.Reactive Subject. + /// + /// The sum of observed values. + [Benchmark] + public int SystemReactiveSubjectEmit32() + { + return EmitThroughSystemSubject(EmitCount32); + } + + /// + /// Emits 32 values into . + /// + /// The sum of observed values. + [Benchmark] + public int R3SubjectEmit32() + { + return EmitThroughR3Subject(EmitCount32); + } + + /// + /// Emits 1024 values into primitives . + /// + /// The sum of observed values. + [Benchmark] + public int PrimitivesSubjectEmit1024() + { + return EmitThroughSignal(EmitCount1024); + } + + /// + /// Emits 1024 values into System.Reactive Subject. + /// + /// The sum of observed values. + [Benchmark] + public int SystemReactiveSubjectEmit1024() + { + return EmitThroughSystemSubject(EmitCount1024); + } + + /// + /// Emits 1024 values into . + /// + /// The sum of observed values. + [Benchmark] + public int R3SubjectEmit1024() + { + return EmitThroughR3Subject(EmitCount1024); + } + + private static int EmitThroughSignal(int count) + { + var observer = new IntSignalObserver(); + using var subject = new Signal(); + using var subscription = subject.Subscribe(observer); + for (var i = 0; i < count; i++) + { + subject.OnNext(i); + } + + return observer.Total; + } + + private static int EmitThroughSystemSubject(int count) + { + var observer = new IntSignalObserver(); + using var subject = new RxSubject(); + using var subscription = subject.Subscribe(observer); + for (var i = 0; i < count; i++) + { + subject.OnNext(i); + } + + return observer.Total; + } + + private static int EmitThroughR3Subject(int count) + { + var observer = new IntR3Observer(); + using var subject = new R3.Subject(); + using var subscription = subject.Subscribe(observer); + for (var i = 0; i < count; i++) + { + subject.OnNext(i); + } + + return observer.Total; + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/Assert.cs b/src/tests/ReactiveUI.Primitives.Tests/Assert.cs index d8141b7..076673b 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Assert.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Assert.cs @@ -5,12 +5,18 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Linq; namespace ReactiveUI.Primitives.Tests; +/// +/// Minimal assertion helpers used by the TUnit tests. +/// internal static class Assert { + /// + /// Verifies that a condition is true. + /// + /// The condition to verify. public static void True(bool condition) { if (condition) @@ -18,11 +24,19 @@ public static void True(bool condition) return; } - throw new InvalidOperationException("Expected condition to be true."); + throw new InvalidOperationException($"Expected {nameof(condition)} to be true."); } - public static void True(bool? condition) => True(condition == true); + /// + /// Verifies that a nullable condition is true. + /// + /// The nullable condition to verify. + public static void True(bool? condition) => True(condition.GetValueOrDefault()); + /// + /// Verifies that a condition is false. + /// + /// The condition to verify. public static void False(bool condition) { if (!condition) @@ -30,21 +44,46 @@ public static void False(bool condition) return; } - throw new InvalidOperationException("Expected condition to be false."); + throw new InvalidOperationException($"Expected {nameof(condition)} to be false."); } - public static void False(bool? condition) => False(condition == true); + /// + /// Verifies that a nullable condition is false. + /// + /// The nullable condition to verify. + public static void False(bool? condition) => False(condition.GetValueOrDefault()); + /// + /// Verifies that two sequences contain equal values in order. + /// + /// The element type. + /// The expected sequence. + /// The actual sequence. public static void Equal(IEnumerable expected, IEnumerable actual) { - if (expected.SequenceEqual(actual)) + if (expected is null) + { + throw new ArgumentNullException(nameof(expected)); + } + + if (actual is null) + { + throw new ArgumentNullException(nameof(actual)); + } + + if (AreSequencesEqual(expected, actual)) { return; } - throw new InvalidOperationException($"Expected {Format(expected)}, actual {Format(actual)}."); + throw new InvalidOperationException($"Expected {Format(expected)}, {nameof(actual)} {Format(actual)}."); } + /// + /// Verifies that two boxed values are equal. + /// + /// The expected value. + /// The actual value. public static void Equal(object? expected, object? actual) { if (Equals(expected, actual)) @@ -52,9 +91,15 @@ public static void Equal(object? expected, object? actual) return; } - throw new InvalidOperationException($"Expected {Format(expected)}, actual {Format(actual)}."); + throw new InvalidOperationException($"Expected {Format(expected)}, {nameof(actual)} {Format(actual)}."); } + /// + /// Verifies that two values are equal. + /// + /// The value type. + /// The expected value. + /// The actual value. public static void Equal(T expected, T actual) { if (EqualityComparer.Default.Equals(expected, actual)) @@ -62,9 +107,15 @@ public static void Equal(T expected, T actual) return; } - throw new InvalidOperationException($"Expected {Format(expected)}, actual {Format(actual)}."); + throw new InvalidOperationException($"Expected {Format(expected)}, {nameof(actual)} {Format(actual)}."); } + /// + /// Verifies that two values are not equal. + /// + /// The value type. + /// The value that should not be produced. + /// The actual value. public static void NotEqual(T notExpected, T actual) { if (!EqualityComparer.Default.Equals(notExpected, actual)) @@ -75,6 +126,12 @@ public static void NotEqual(T notExpected, T actual) throw new InvalidOperationException($"Did not expect {Format(actual)}."); } + /// + /// Verifies that two references point to the same instance. + /// + /// The reference type. + /// The expected instance. + /// The actual instance. public static void Same(T expected, T actual) where T : class { @@ -86,6 +143,10 @@ public static void Same(T expected, T actual) throw new InvalidOperationException("Expected both references to point to the same instance."); } + /// + /// Verifies that a value is not null. + /// + /// The value to verify. public static void NotNull(object? value) { if (value is not null) @@ -93,32 +154,68 @@ public static void NotNull(object? value) return; } - throw new InvalidOperationException("Expected value not to be null."); + throw new InvalidOperationException($"Expected {nameof(value)} not to be null."); } + /// + /// Verifies that a collection contains a value. + /// + /// The collection element type. + /// The expected value. + /// The collection to inspect. public static void Contains(T expected, IEnumerable collection) { - if (collection.Contains(expected)) + if (collection is null) + { + throw new ArgumentNullException(nameof(collection)); + } + + if (ContainsValue(expected, collection)) { return; } - throw new InvalidOperationException($"Expected collection to contain {Format(expected)}."); + throw new InvalidOperationException($"Expected {nameof(collection)} to contain {Format(expected)}."); } + /// + /// Verifies that a collection does not contain a value. + /// + /// The collection element type. + /// The value that should not be present. + /// The collection to inspect. public static void DoesNotContain(T expected, IEnumerable collection) { - if (!collection.Contains(expected)) + if (collection is null) + { + throw new ArgumentNullException(nameof(collection)); + } + + if (!ContainsValue(expected, collection)) { return; } - throw new InvalidOperationException($"Expected collection not to contain {Format(expected)}."); + throw new InvalidOperationException($"Expected {nameof(collection)} not to contain {Format(expected)}."); } - public static TException Throws(Action action) + /// + /// Verifies that an action throws the expected exception type. + /// + /// The expected exception type. + /// The action to execute. + /// An optional marker used to infer the expected exception type. + /// The thrown exception. + public static TException Throws(Action action, TException? expectedException = null) where TException : Exception { + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + var expectedExceptionType = expectedException?.GetType() ?? typeof(TException); + try { action(); @@ -130,13 +227,19 @@ public static TException Throws(Action action) catch (Exception exception) { throw new InvalidOperationException( - $"Expected exception {typeof(TException).FullName}, actual {exception.GetType().FullName}.", + $"Expected exception {expectedExceptionType.FullName}, caught {exception.GetType().FullName}.", exception); } - throw new InvalidOperationException($"Expected exception {typeof(TException).FullName}, but no exception was thrown."); + throw new InvalidOperationException($"Expected exception {expectedExceptionType.FullName}, but no exception was thrown."); } + /// + /// Formats a value for assertion failure messages. + /// + /// The value type. + /// The value to format. + /// The formatted value. private static string Format(T value) { if (value is null) @@ -151,9 +254,74 @@ private static string Format(T value) if (value is IEnumerable enumerable) { - return "[" + string.Join(", ", enumerable.Cast()) + "]"; + return FormatEnumerable(enumerable); } return value.ToString() ?? ""; } + + /// + /// Determines whether two sequences contain equal values in order. + /// + /// The element type. + /// The expected sequence. + /// The actual sequence. + /// when both sequences contain the same values; otherwise, . + private static bool AreSequencesEqual(IEnumerable expected, IEnumerable actual) + { + using var expectedEnumerator = expected.GetEnumerator(); + using var actualEnumerator = actual.GetEnumerator(); + + while (expectedEnumerator.MoveNext()) + { + if (!actualEnumerator.MoveNext()) + { + return false; + } + + if (!EqualityComparer.Default.Equals(expectedEnumerator.Current, actualEnumerator.Current)) + { + return false; + } + } + + return !actualEnumerator.MoveNext(); + } + + /// + /// Determines whether a sequence contains a value. + /// + /// The element type. + /// The expected value. + /// The collection to inspect. + /// when the collection contains the value; otherwise, . + private static bool ContainsValue(T expected, IEnumerable collection) + { + foreach (var item in collection) + { + if (EqualityComparer.Default.Equals(expected, item)) + { + return true; + } + } + + return false; + } + + /// + /// Formats a sequence for assertion failure messages. + /// + /// The sequence to format. + /// The formatted sequence. + private static string FormatEnumerable(IEnumerable enumerable) + { + var values = new List(); + + foreach (var item in enumerable) + { + values.Add(item?.ToString() ?? ""); + } + + return "[" + string.Join(", ", values) + "]"; + } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/AsyncSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/AsyncSignalTests.cs index 9828c89..e94eb4b 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/AsyncSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/AsyncSignalTests.cs @@ -4,17 +4,26 @@ using System; using System.Threading; -using System.Threading.Tasks; using ReactiveUI.Primitives.Signals; using TUnit.Core; namespace ReactiveUI.Primitives.Tests; /// -/// AsyncSignalTests. +/// Tests asynchronous signal behavior. /// public class AsyncSignalTests { + /// + /// Defines the integer value observed by asynchronous tests. + /// + private const int ExpectedValue = 42; + + /// + /// Defines the maximum time to wait for cross-thread test work. + /// + private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); + /// /// Subscribes the argument checking. /// @@ -37,6 +46,8 @@ public void Await_Blocking() { var s = new AsyncSignal(); GetResult_BlockingImpl(s.GetAwaiter()); + + Assert.True(s.IsCompleted); } /// @@ -47,6 +58,8 @@ public void Await_Throw() { var s = new AsyncSignal(); GetResult_Blocking_ThrowImpl(s.GetAwaiter()); + + Assert.True(s.IsCompleted); } /// @@ -64,13 +77,25 @@ public void GetResult_Empty() /// Gets the result blocking. /// [Test] - public void GetResult_Blocking() => GetResult_BlockingImpl(new AsyncSignal()); + public void GetResult_Blocking() + { + var s = new AsyncSignal(); + GetResult_BlockingImpl(s); + + Assert.True(s.IsCompleted); + } /// /// Gets the result blocking throw. /// [Test] - public void GetResult_Blocking_Throw() => GetResult_Blocking_ThrowImpl(new AsyncSignal()); + public void GetResult_Blocking_Throw() + { + var s = new AsyncSignal(); + GetResult_Blocking_ThrowImpl(s); + + Assert.True(s.IsCompleted); + } /// /// Gets the result context. @@ -81,20 +106,27 @@ public void GetResult_Context() var x = new AsyncSignal(); var ctx = new MyContext(); - var e = new ManualResetEvent(false); + using var registered = new ManualResetEventSlim(); + using var completed = new ManualResetEventSlim(); - Task.Run(() => + var registrationThread = new Thread(() => { SynchronizationContext.SetSynchronizationContext(ctx); var a = x.GetAwaiter(); - a.OnCompleted(() => e.Set()); + a.OnCompleted(() => completed.Set()); + registered.Set(); }); - x.OnNext(42); + registrationThread.Start(); + + Assert.True(registered.Wait(WaitTimeout)); + Assert.True(registrationThread.Join(WaitTimeout)); + + x.OnNext(ExpectedValue); x.OnCompleted(); - e.WaitOne(); + Assert.True(completed.Wait(WaitTimeout)); Assert.True(ctx.Ran); } @@ -200,11 +232,13 @@ public void HasObservers_OnCompleted() var d = s.Subscribe(_ => { }); Assert.True(s.HasObservers); - s.OnNext(42); + s.OnNext(ExpectedValue); Assert.True(s.HasObservers); s.OnCompleted(); Assert.False(s.HasObservers); + + d.Dispose(); } /// @@ -219,11 +253,13 @@ public void HasObservers_OnError() var d = s.Subscribe(_ => { }, _ => { }); Assert.True(s.HasObservers); - s.OnNext(42); + s.OnNext(ExpectedValue); Assert.True(s.HasObservers); - s.OnError(new Exception()); + s.OnError(new InvalidOperationException()); Assert.False(s.HasObservers); + + d.Dispose(); } /// @@ -234,27 +270,36 @@ private static void GetResult_BlockingImpl(IAwaitSignal s) { Assert.False(s.IsCompleted); - var e = new ManualResetEvent(false); + using var release = new ManualResetEventSlim(); + using var started = new ManualResetEventSlim(); - new Thread(() => + var producer = new Thread(() => { - e.WaitOne(); - s.OnNext(42); + if (!release.Wait(WaitTimeout)) + { + return; + } + + s.OnNext(ExpectedValue); s.OnCompleted(); - }).Start(); + }); var y = default(int); - var t = new Thread(() => y = s.GetResult()); - t.Start(); - - while (t.ThreadState != ThreadState.WaitSleepJoin) + var consumer = new Thread(() => { - } + started.Set(); + y = s.GetResult(); + }); + + producer.Start(); + consumer.Start(); - e.Set(); - t.Join(); + Assert.True(started.Wait(WaitTimeout)); + release.Set(); + Assert.True(consumer.Join(WaitTimeout)); + Assert.True(producer.Join(WaitTimeout)); - Assert.Equal(42, y); + Assert.Equal(ExpectedValue, y); Assert.True(s.IsCompleted); } @@ -266,47 +311,67 @@ private static void GetResult_Blocking_ThrowImpl(IAwaitSignal s) { Assert.False(s.IsCompleted); - var e = new ManualResetEvent(false); + using var release = new ManualResetEventSlim(); + using var started = new ManualResetEventSlim(); - var ex = new Exception(); + var expectedException = new InvalidOperationException(); - new Thread(() => + var producer = new Thread(() => { - e.WaitOne(); - s.OnError(ex); - }).Start(); + if (!release.Wait(WaitTimeout)) + { + return; + } + + s.OnError(expectedException); + }); - var y = default(Exception); - var t = new Thread(() => + Exception? caughtException = null; + var consumer = new Thread(() => { + started.Set(); + try { s.GetResult(); } - catch (Exception ex_) + catch (Exception exception) { - y = ex_; + caughtException = exception; } }); - t.Start(); - while (t.ThreadState != ThreadState.WaitSleepJoin) - { - } + producer.Start(); + consumer.Start(); - e.Set(); - t.Join(); + Assert.True(started.Wait(WaitTimeout)); + release.Set(); + Assert.True(consumer.Join(WaitTimeout)); + Assert.True(producer.Join(WaitTimeout)); - Assert.Same(ex, y); + Assert.NotNull(caughtException); + Assert.Same(expectedException, caughtException!); Assert.True(s.IsCompleted); } - private class MyContext : SynchronizationContext + /// + /// Captures whether a continuation was posted through the synchronization context. + /// + private sealed class MyContext : SynchronizationContext { - public bool Ran { get; set; } + /// + /// Gets a value indicating whether a continuation was posted. + /// + public bool Ran { get; private set; } + /// public override void Post(SendOrPostCallback d, object? state) { + if (d is null) + { + throw new ArgumentNullException(nameof(d)); + } + Ran = true; d(state); } diff --git a/src/tests/ReactiveUI.Primitives.Tests/BehaviourSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/BehaviourSignalTests.cs index 5cdbf76..6a494f1 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/BehaviourSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/BehaviourSignalTests.cs @@ -13,6 +13,26 @@ namespace ReactiveUI.Primitives.Tests; /// public class BehaviourSignalTests { + /// + /// Initial value used by behavior signal value tests. + /// + private const int InitialValue = 42; + + /// + /// First updated value used by behavior signal value tests. + /// + private const int FirstUpdatedValue = 43; + + /// + /// Second updated value used by behavior signal value tests. + /// + private const int SecondUpdatedValue = 44; + + /// + /// Value that should be ignored after completion. + /// + private const int IgnoredAfterCompletionValue = 1234; + /// /// Subscribes the argument checking. /// @@ -125,10 +145,10 @@ public void HasObservers_OnCompleted() var s = new BehaviourSignal(42); Assert.False(s.HasObservers); - var d = s.Subscribe(_ => { }); + using var subscription = s.Subscribe(_ => { }); Assert.True(s.HasObservers); - s.OnNext(42); + s.OnNext(InitialValue); Assert.True(s.HasObservers); s.OnCompleted(); @@ -144,13 +164,13 @@ public void HasObservers_OnError() var s = new BehaviourSignal(42); Assert.False(s.HasObservers); - var d = s.Subscribe(_ => { }, _ => { }); + using var subscription = s.Subscribe(_ => { }, _ => { }); Assert.True(s.HasObservers); - s.OnNext(42); + s.OnNext(InitialValue); Assert.True(s.HasObservers); - s.OnError(new Exception()); + s.OnError(new InvalidOperationException()); Assert.False(s.HasObservers); } @@ -160,11 +180,11 @@ public void HasObservers_OnError() [Test] public void Value_Initial() { - var s = new BehaviourSignal(42); - Assert.Equal(42, s.Value); + var s = new BehaviourSignal(InitialValue); + Assert.Equal(InitialValue, s.Value); Assert.True(s.TryGetValue(out var x)); - Assert.Equal(42, x); + Assert.Equal(InitialValue, x); } /// @@ -173,17 +193,17 @@ public void Value_Initial() [Test] public void Value_First() { - var s = new BehaviourSignal(42); - Assert.Equal(42, s.Value); + var s = new BehaviourSignal(InitialValue); + Assert.Equal(InitialValue, s.Value); Assert.True(s.TryGetValue(out var x)); - Assert.Equal(42, x); + Assert.Equal(InitialValue, x); - s.OnNext(43); - Assert.Equal(43, s.Value); + s.OnNext(FirstUpdatedValue); + Assert.Equal(FirstUpdatedValue, s.Value); Assert.True(s.TryGetValue(out x)); - Assert.Equal(43, x); + Assert.Equal(FirstUpdatedValue, x); } /// @@ -192,23 +212,23 @@ public void Value_First() [Test] public void Value_Second() { - var s = new BehaviourSignal(42); - Assert.Equal(42, s.Value); + var s = new BehaviourSignal(InitialValue); + Assert.Equal(InitialValue, s.Value); Assert.True(s.TryGetValue(out var x)); - Assert.Equal(42, x); + Assert.Equal(InitialValue, x); - s.OnNext(43); - Assert.Equal(43, s.Value); + s.OnNext(FirstUpdatedValue); + Assert.Equal(FirstUpdatedValue, s.Value); Assert.True(s.TryGetValue(out x)); - Assert.Equal(43, x); + Assert.Equal(FirstUpdatedValue, x); - s.OnNext(44); - Assert.Equal(44, s.Value); + s.OnNext(SecondUpdatedValue); + Assert.Equal(SecondUpdatedValue, s.Value); Assert.True(s.TryGetValue(out x)); - Assert.Equal(44, x); + Assert.Equal(SecondUpdatedValue, x); } /// @@ -217,35 +237,35 @@ public void Value_Second() [Test] public void Value_FrozenAfterOnCompleted() { - var s = new BehaviourSignal(42); - Assert.Equal(42, s.Value); + var s = new BehaviourSignal(InitialValue); + Assert.Equal(InitialValue, s.Value); Assert.True(s.TryGetValue(out var x)); - Assert.Equal(42, x); + Assert.Equal(InitialValue, x); - s.OnNext(43); - Assert.Equal(43, s.Value); + s.OnNext(FirstUpdatedValue); + Assert.Equal(FirstUpdatedValue, s.Value); Assert.True(s.TryGetValue(out x)); - Assert.Equal(43, x); + Assert.Equal(FirstUpdatedValue, x); - s.OnNext(44); - Assert.Equal(44, s.Value); + s.OnNext(SecondUpdatedValue); + Assert.Equal(SecondUpdatedValue, s.Value); Assert.True(s.TryGetValue(out x)); - Assert.Equal(44, x); + Assert.Equal(SecondUpdatedValue, x); s.OnCompleted(); - Assert.Equal(44, s.Value); + Assert.Equal(SecondUpdatedValue, s.Value); Assert.True(s.TryGetValue(out x)); - Assert.Equal(44, x); + Assert.Equal(SecondUpdatedValue, x); - s.OnNext(1234); - Assert.Equal(44, s.Value); + s.OnNext(IgnoredAfterCompletionValue); + Assert.Equal(SecondUpdatedValue, s.Value); Assert.True(s.TryGetValue(out x)); - Assert.Equal(44, x); + Assert.Equal(SecondUpdatedValue, x); } /// @@ -254,17 +274,14 @@ public void Value_FrozenAfterOnCompleted() [Test] public void Value_ThrowsAfterOnError() { - var s = new BehaviourSignal(42); - Assert.Equal(42, s.Value); + var s = new BehaviourSignal(InitialValue); + Assert.Equal(InitialValue, s.Value); s.OnError(new InvalidOperationException()); - Assert.Throws(() => - { - var ignored = s.Value; - }); + Assert.Throws(() => _ = s.Value); - Assert.Throws(() => s.TryGetValue(out var x)); + Assert.Throws(() => s.TryGetValue(out _)); } /// @@ -273,16 +290,13 @@ public void Value_ThrowsAfterOnError() [Test] public void Value_ThrowsOnDispose() { - var s = new BehaviourSignal(42); - Assert.Equal(42, s.Value); + var s = new BehaviourSignal(InitialValue); + Assert.Equal(InitialValue, s.Value); s.Dispose(); - Assert.Throws(() => - { - var ignored = s.Value; - }); + Assert.Throws(() => _ = s.Value); - Assert.False(s.TryGetValue(out var x)); + Assert.False(s.TryGetValue(out _)); } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/ConcurencyTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ConcurencyTests.cs index fab23ea..d2a8534 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ConcurencyTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ConcurencyTests.cs @@ -11,109 +11,143 @@ namespace ReactiveUI.Primitives.Tests; /// -/// ConcurencyTests. +/// Tests task-pool sequencer behavior. /// public class ConcurencyTests { /// - /// Tests this instance. + /// Defines the maximum time to wait for scheduled work in tests. + /// + private static readonly TimeSpan ScheduleTimeout = TimeSpan.FromSeconds(5); + + /// + /// Defines the maximum tolerated difference between sequencer and system time. + /// + private static readonly TimeSpan ClockTolerance = TimeSpan.FromSeconds(1); + + /// + /// Defines the short due time used by delayed scheduling tests. + /// + private static readonly TimeSpan ShortDueTime = TimeSpan.FromMilliseconds(10); + + /// + /// Defines the due time used by cancellation tests. + /// + private static readonly TimeSpan CancelDueTime = TimeSpan.FromMilliseconds(200); + + /// + /// Defines the observation window used after canceling scheduled work. + /// + private static readonly TimeSpan CancelObservationWindow = TimeSpan.FromMilliseconds(400); + + /// + /// Verifies that scheduling state returns a disposable. /// [Test] public void TestCreate() { var scheduler = TaskPoolSequencer.Instance; - var disposable = scheduler.Schedule(0, (__, _) => Disposable.Empty); + var disposable = scheduler.Schedule(0, (_, _) => Disposable.Empty); Assert.NotNull(disposable); disposable.Dispose(); } /// - /// Tasks the pool now. + /// Verifies that the task-pool sequencer reports current UTC time. /// [Test] public void TaskPoolNow() { - var res = TaskPoolSequencer.Instance.Now - DateTime.Now; - Assert.True(res.Seconds < 1); + var delta = TaskPoolSequencer.Instance.Now - TimeProvider.System.GetUtcNow(); + + Assert.True(delta.Duration() < ClockTolerance); } /// - /// Tasks the pool schedule action. + /// Verifies that immediate work is scheduled on a different thread. /// [Test] public void TaskPoolScheduleAction() { var id = Environment.CurrentManagedThreadId; var nt = TaskPoolSequencer.Instance; - var evt = new ManualResetEvent(false); - nt.Schedule(() => + using var completed = new ManualResetEventSlim(); + using var scheduled = nt.Schedule(() => { Assert.NotEqual(id, Environment.CurrentManagedThreadId); - evt.Set(); + completed.Set(); }); - evt.WaitOne(); + + Assert.True(completed.Wait(ScheduleTimeout)); } /// - /// Tasks the pool schedule action due now. + /// Verifies that work due immediately is scheduled on a different thread. /// [Test] public void TaskPoolScheduleActionDueNow() { var id = Environment.CurrentManagedThreadId; var nt = TaskPoolSequencer.Instance; - var evt = new ManualResetEvent(false); - nt.Schedule(TimeSpan.Zero, () => + using var completed = new ManualResetEventSlim(); + using var scheduled = nt.Schedule(TimeSpan.Zero, () => { Assert.NotEqual(id, Environment.CurrentManagedThreadId); - evt.Set(); + completed.Set(); }); - evt.WaitOne(); + + Assert.True(completed.Wait(ScheduleTimeout)); } /// - /// Tasks the pool schedule action due. + /// Verifies that delayed work is scheduled on a different thread. /// [Test] public void TaskPoolScheduleActionDue() { var id = Environment.CurrentManagedThreadId; var nt = TaskPoolSequencer.Instance; - var evt = new ManualResetEvent(false); - nt.Schedule(TimeSpan.FromMilliseconds(1), () => + using var completed = new ManualResetEventSlim(); + using var scheduled = nt.Schedule(ShortDueTime, () => { Assert.NotEqual(id, Environment.CurrentManagedThreadId); - evt.Set(); + completed.Set(); }); - evt.WaitOne(); + + Assert.True(completed.Wait(ScheduleTimeout)); } /// - /// Tasks the pool schedule action cancel. + /// Verifies that canceled delayed work does not run. /// [Test] public void TaskPoolScheduleActionCancel() { - var id = Environment.CurrentManagedThreadId; var nt = TaskPoolSequencer.Instance; - var set = false; - var d = nt.Schedule(TimeSpan.FromSeconds(0.2), () => set = true); - d.Dispose(); - Thread.Sleep(400); - Assert.False(set); + var runCount = 0; + using var completed = new ManualResetEventSlim(); + using var scheduled = nt.Schedule(CancelDueTime, () => + { + Volatile.Write(ref runCount, 1); + completed.Set(); + }); + + scheduled.Dispose(); + + Assert.False(completed.Wait(CancelObservationWindow)); + Assert.Equal(0, Volatile.Read(ref runCount)); } /// - /// Tasks the pool delay larger than int maximum value. + /// Verifies that delays larger than milliseconds are accepted. /// [Test] public void TaskPoolDelayLargerThanIntMaxValue() { var dueTime = TimeSpan.FromMilliseconds((double)int.MaxValue + 1); - // Just ensuring the call to Schedule does not throw. - var d = TaskPoolSequencer.Instance.Schedule(dueTime, () => { }); + using var scheduled = TaskPoolSequencer.Instance.Schedule(dueTime, () => { }); - d.Dispose(); + Assert.NotNull(scheduled); } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/CoreRuntimeContractTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CoreRuntimeContractTests.cs index 95d06c9..5d2aaf4 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/CoreRuntimeContractTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/CoreRuntimeContractTests.cs @@ -12,8 +12,14 @@ namespace ReactiveUI.Primitives.Tests; +/// +/// Verifies core runtime contracts for sparks, witnesses, disposables, and sequencers. +/// public class CoreRuntimeContractTests { + /// + /// Verifies completed spark instances are cached for each value type. + /// [Test] public void CompletedSparksAreCachedPerValueType() { @@ -24,9 +30,13 @@ public void CompletedSparksAreCachedPerValueType() Assert.True(first == second); } + /// + /// Verifies delegate witnesses route next, error, and completion callbacks. + /// [Test] public void WitnessCreateRoutesCallbacks() { + const int observedValue = 7; var calls = new List(); var error = new InvalidOperationException("boom"); var witness = Witness.Create( @@ -34,16 +44,22 @@ public void WitnessCreateRoutesCallbacks() ex => calls.Add("E" + ex.Message), () => calls.Add("C")); - witness.OnNext(7); + witness.OnNext(observedValue); witness.OnError(error); witness.OnCompleted(); - Assert.Equal(new[] { "N7", "Eboom", "C" }, calls); + var expected = new[] { "N" + observedValue, "Eboom", "C" }; + Assert.Equal(expected, calls); } + /// + /// Verifies safe witnesses ignore notifications after termination and dispose once. + /// [Test] public void SafeWitnessIgnoresSignalsAfterTerminalAndDisposesOnce() { + const int firstValue = 1; + const int lateValue = 2; var calls = new List(); var disposed = 0; var witness = Witness.Safe( @@ -53,16 +69,20 @@ public void SafeWitnessIgnoresSignalsAfterTerminalAndDisposesOnce() () => calls.Add("C")), Disposable.Create(() => disposed++)); - witness.OnNext(1); + witness.OnNext(firstValue); witness.OnCompleted(); - witness.OnNext(2); + witness.OnNext(lateValue); witness.OnError(new InvalidOperationException("late")); witness.OnCompleted(); - Assert.Equal(new[] { "N1", "C" }, calls); + var expected = new[] { "N" + firstValue, "C" }; + Assert.Equal(expected, calls); Assert.Equal(1, disposed); } + /// + /// Verifies a null disposable action uses the shared empty disposable. + /// [Test] public void DisposableCreateNullActionReturnsEmptyDisposable() { @@ -72,6 +92,9 @@ public void DisposableCreateNullActionReturnsEmptyDisposable() Assert.Same(Disposable.Empty, disposable); } + /// + /// Verifies removing one disposable leaves the others attached until disposal. + /// [Test] public void MultipleDisposableRemoveDisposesOnlyTheRequestedItem() { @@ -92,6 +115,9 @@ public void MultipleDisposableRemoveDisposesOnlyTheRequestedItem() Assert.Equal(1, second); } + /// + /// Verifies assigning a disposed single slot disposes the incoming disposable immediately. + /// [Test] public void SingleDisposableCreateAfterDisposeDisposesIncomingDisposableImmediately() { @@ -105,6 +131,9 @@ public void SingleDisposableCreateAfterDisposeDisposesIncomingDisposableImmediat Assert.Equal(1, disposed); } + /// + /// Verifies a replaceable disposable invokes its disposal action only once. + /// [Test] public void SingleReplaceableDisposableRunsActionOnlyOnce() { @@ -117,21 +146,31 @@ public void SingleReplaceableDisposableRunsActionOnlyOnce() Assert.Equal(1, actionCount); } + /// + /// Verifies nested current-thread work is queued until the current action finishes. + /// [Test] public void CurrentThreadSequencerQueuesNestedWorkUntilCurrentActionCompletes() { + const int firstCall = 1; + const int secondCall = 2; + const int thirdCall = 3; var calls = new List(); Sequencer.CurrentThread.Schedule(() => { - calls.Add(1); - Sequencer.CurrentThread.Schedule(() => calls.Add(3)); - calls.Add(2); + calls.Add(firstCall); + Sequencer.CurrentThread.Schedule(() => calls.Add(thirdCall)); + calls.Add(secondCall); }); - Assert.Equal(new[] { 1, 2, 3 }, calls); + var expected = new[] { firstCall, secondCall, thirdCall }; + Assert.Equal(expected, calls); } + /// + /// Verifies the immediate sequencer waits until an absolute due time. + /// [Test] public void ImmediateSequencerHonorsAbsoluteDueTime() { @@ -143,21 +182,30 @@ public void ImmediateSequencerHonorsAbsoluteDueTime() Assert.True(elapsed.Elapsed >= TimeSpan.FromMilliseconds(20)); } + /// + /// Verifies virtual-clock work runs only after the clock reaches the due time. + /// [Test] public void VirtualClockRunsScheduledWorkOnlyWhenAdvancedPastDueTime() { + const long dueTicks = 10; + const long beforeDueTicks = 9; var clock = new VirtualClock(); var calls = new List(); - clock.Schedule(TimeSpan.FromTicks(10), () => calls.Add(clock.Clock.Ticks)); + clock.Schedule(TimeSpan.FromTicks(dueTicks), () => calls.Add(clock.Clock.Ticks)); - clock.AdvanceBy(TimeSpan.FromTicks(9)); + clock.AdvanceBy(TimeSpan.FromTicks(beforeDueTicks)); Assert.Equal(0, calls.Count); clock.AdvanceBy(TimeSpan.FromTicks(1)); - Assert.Equal(new[] { 10L }, calls); + var expected = new[] { dueTicks }; + Assert.Equal(expected, calls); } + /// + /// Verifies default sequencer aliases expose migration-friendly names. + /// [Test] public void SchedulerDefaultAliasesExposeMigrationFriendlyNames() { @@ -166,25 +214,40 @@ public void SchedulerDefaultAliasesExposeMigrationFriendlyNames() Assert.Same(ThreadPoolSequencer.Instance, ThreadPoolSequencer.Instance); } + /// + /// Verifies nullable time value structs use deterministic null hash codes. + /// [Test] public void NullableValueTimeStructsUseDeterministicNullHashCodes() { + const int nullHashSeed = 1963; var timestamp = new DateTimeOffset(2026, 5, 24, 22, 52, 0, TimeSpan.Zero); var moment = new Moment(null, timestamp); var interval = TimeSpan.FromMilliseconds(123); var timeInterval = new TimeInterval(null, interval); - Assert.Equal(timestamp.GetHashCode() ^ 1963, moment.GetHashCode()); - Assert.Equal(interval.GetHashCode() ^ 1963, timeInterval.GetHashCode()); + Assert.Equal(timestamp.GetHashCode() ^ nullHashSeed, moment.GetHashCode()); + Assert.Equal(interval.GetHashCode() ^ nullHashSeed, timeInterval.GetHashCode()); } + /// + /// Verifies scheduled-item constructor argument validation. + /// [Test] public void ScheduledItemConstructorValidatesSchedulerAndAction() { + const int state = 42; + Assert.Throws(() => - new ScheduledItem(null!, 42, (_, _) => Disposable.Empty, DateTimeOffset.UnixEpoch)); + CreateScheduledItem(null!, state, (_, _) => Disposable.Empty)); Assert.Throws(() => - new ScheduledItem(Sequencer.Immediate, 42, null!, DateTimeOffset.UnixEpoch)); + CreateScheduledItem(Sequencer.Immediate, state, null!)); + + static void CreateScheduledItem( + ISequencer scheduler, + int state, + Func action) => + GC.KeepAlive(new ScheduledItem(scheduler, state, action, DateTimeOffset.UnixEpoch)); } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/CoverageCompletionTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CoverageCompletionTests.cs index f8d79fd..9f11bdd 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/CoverageCompletionTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/CoverageCompletionTests.cs @@ -19,117 +19,269 @@ namespace ReactiveUI.Primitives.Tests; #pragma warning disable CS8602 // Tests intentionally exercise nullable edge cases and Spark null payload contracts. +/// +/// Completes branch and contract coverage for primitive signals and support types. +/// public class CoverageCompletionTests { + /// + /// Two as a named value for analyzer-friendly coverage assertions. + /// + private const int Two = 2; + + /// + /// Three as a named value for analyzer-friendly coverage assertions. + /// + private const int Three = 3; + + /// + /// Four as a named value for analyzer-friendly coverage assertions. + /// + private const int Four = 4; + + /// + /// Five as a named value for analyzer-friendly coverage assertions. + /// + private const int Five = 5; + + /// + /// Six as a named value for analyzer-friendly coverage assertions. + /// + private const int Six = 6; + + /// + /// Seven as a named value for analyzer-friendly coverage assertions. + /// + private const int Seven = 7; + + /// + /// Eight as a named value for analyzer-friendly coverage assertions. + /// + private const int Eight = 8; + + /// + /// Nine as a named value for analyzer-friendly coverage assertions. + /// + private const int Nine = 9; + + /// + /// Ten as a named value for analyzer-friendly coverage assertions. + /// + private const int Ten = 10; + + /// + /// Eleven as a named value for analyzer-friendly coverage assertions. + /// + private const int Eleven = 11; + + /// + /// Twenty as a named value for analyzer-friendly coverage assertions. + /// + private const int Twenty = 20; + + /// + /// Twenty-one as a named value for analyzer-friendly coverage assertions. + /// + private const int TwentyOne = 21; + + /// + /// Forty-two as a named value for analyzer-friendly coverage assertions. + /// + private const int FortyTwo = 42; + + /// + /// Forty-three as a named value for analyzer-friendly coverage assertions. + /// + private const int FortyThree = 43; + + /// + /// Calendar year used by value-type timestamp coverage. + /// + private const int CalendarYear = 2024; + + /// + /// Ninety-nine as a named value for analyzer-friendly coverage assertions. + /// + private const int NinetyNine = 99; + + /// + /// Polling delay used by asynchronous spin assertions. + /// + private const int PollDelayMilliseconds = 10; + + /// + /// Reflection field name for throw delegates. + /// + private const string ThrowMemberName = "Throw"; + + /// + /// Shared completed result text. + /// + private const string CompletedText = "completed"; + + /// + /// Shared completed function result text. + /// + private const string FunctionCompletedText = "fn-completed"; + + /// + /// Expected operator values. + /// + private static readonly string[] ExpectedOperatorValues = ["bbb!", "cc!"]; + + /// + /// Expected side-effect values. + /// + private static readonly string[] ExpectedSideEffects = ["a!", "bbb!", "cc!"]; + + /// + /// Expected non-null values. + /// + private static readonly string[] ExpectedKeepNotNull = ["x", "y"]; + + /// + /// Expected false scalar sequence. + /// + private static readonly bool[] ExpectedFalse = [false]; + + /// + /// Expected long-count result. + /// + private static readonly long[] ExpectedLongCount = [2L]; + + /// + /// Expected select-many result. + /// + private static readonly string[] ExpectedSelectMany = ["1:1", "1:11", "2:2", "2:12"]; + + /// + /// Expected spark kind sequence. + /// + private static readonly SparkKind[] ExpectedSparkKinds = [SparkKind.OnError]; + + /// + /// Expected spark error messages. + /// + private static readonly string[] ExpectedSparkErrors = ["spark"]; + + /// + /// Expected unspark values. + /// + private static readonly int[] ExpectedUnsparkValues = [1]; + + /// + /// Expected unspark errors. + /// + private static readonly string[] ExpectedUnsparkErrors = ["unspark"]; + + /// + /// Expected rescue values. + /// + private static readonly int[] ExpectedRescueValues = [Seven]; + + /// + /// Expected resume values. + /// + private static readonly int[] ExpectedResumeValues = [Four, Five]; + + /// + /// Expected final errors. + /// + private static readonly string[] ExpectedFinalErrors = ["stop"]; + + /// + /// Expected concat values. + /// + private static readonly int[] ExpectedConcatValues = [1, Two, TwentyOne]; + + /// + /// Expected merge values. + /// + private static readonly int[] ExpectedMergeValues = [1, Two, Three]; + + /// + /// Expected race values. + /// + private static readonly int[] ExpectedRaceValues = [Seven]; + + /// + /// Expected switch values. + /// + private static readonly int[] ExpectedSwitchValues = [1, Three]; + + /// + /// Expected latest-combination values. + /// + private static readonly string[] ExpectedWithLatestValues = ["2a", "3b"]; + + /// + /// Expected zip values. + /// + private static readonly int[] ExpectedZipShortValues = [Eleven]; + + /// + /// Expected delayed scalar values. + /// + private static readonly int[] ExpectedDelayedValues = [Three, Four]; + + /// + /// Expected delay-start scalar values. + /// + private static readonly int[] ExpectedDelayStartValues = [Two]; + + /// + /// Expected timer values. + /// + private static readonly long[] ExpectedTimerValues = [0L, 1L, 2L]; + + /// + /// Expected timeout error names. + /// + private static readonly string[] ExpectedTimeoutErrors = [nameof(TimeoutException)]; + + /// + /// Expected use-factory errors. + /// + private static readonly string[] ExpectedUseErrors = ["The signal factory returned null.", "resource"]; + + /// + /// Expected task error names. + /// + private static readonly string[] ExpectedTaskErrors = [nameof(TaskCanceledException), nameof(InvalidOperationException)]; + + /// + /// Expected async error messages. + /// + private static readonly string[] ExpectedAsyncErrors = ["async"]; + + /// + /// Expected observable values. + /// + private static readonly int[] ExpectedObservableValues = [FortyTwo]; + + /// + /// Expected timestamp values. + /// + private static readonly int[] ExpectedTimestampValues = [Eight, Nine]; + + /// + /// Validates null guard coverage across public factories, operators, and observers. + /// [Test] public void NullGuardsCoverPublicFactoryOperatorAndObserverContracts() { IObservable source = Signal.Return(1); IObservable objects = Signal.Return("value"); - Assert.Throws(() => ((IObservable)null!).Map(value => value)); - Assert.Throws(() => source.Map(null!)); - Assert.Throws(() => source.MapWith(1, null!)); - Assert.Throws(() => ((IObservable)null!).Keep(value => true)); - Assert.Throws(() => source.Keep(null!)); - Assert.Throws(() => source.KeepWith(1, null!)); - Assert.Throws(() => ((IObservable)null!).KeepNotNull()); - Assert.Throws(() => ((IObservable)null!).OfType()); - Assert.Throws(() => ((IObservable)null!).Cast()); - Assert.Throws(() => source.Tap(null!)); - Assert.Throws(() => source.TapWith(1, null!)); - Assert.Throws(() => ((IObservable)null!).Scan(0, (sum, value) => sum + value)); - Assert.Throws(() => source.Scan(0, null!)); - Assert.Throws(() => ((IObservable)null!).Fold(0, (sum, value) => sum + value)); - Assert.Throws(() => source.Fold(0, null!)); - Assert.Throws(() => ((IObservable)null!).Take(1)); - Assert.Throws(() => source.Take(-1)); - Assert.Throws(() => ((IObservable)null!).Skip(1)); - Assert.Throws(() => source.Skip(-1)); - Assert.Throws(() => ((IObservable)null!).Distinct()); - Assert.Throws(() => ((IObservable)null!).DistinctUntilChanged()); - Assert.Throws(() => ((IObservable)null!).Sparkify()); - Assert.Throws(() => ((IObservable>)null!).Unspark()); - Assert.Throws(() => ((IObservable>)null!).Concat()); - Assert.Throws(() => ((IObservable>)null!).Merge()); - Assert.Throws(() => ((IObservable>)null!).Race()); - Assert.Throws(() => ((IObservable)null!).Zip(Signal.Return(1), (left, right) => left + right)); - Assert.Throws(() => source.Zip(null!, (left, right) => left + right)); - Assert.Throws(() => source.Zip(Signal.Return(1), null!)); - Assert.Throws(() => ((IObservable)null!).CombineLatest(Signal.Return(1), (left, right) => left + right)); - Assert.Throws(() => source.CombineLatest(null!, (left, right) => left + right)); - Assert.Throws(() => source.CombineLatest(Signal.Return(1), null!)); - Assert.Throws(() => ((IObservable)null!).WithLatest(Signal.Return(1), (left, right) => left + right)); - Assert.Throws(() => source.WithLatest(null!, (left, right) => left + right)); - Assert.Throws(() => source.WithLatest(Signal.Return(1), null!)); - Assert.Throws(() => ((IObservable>)null!).Switch()); - Assert.Throws(() => ((IObservable)null!).Retry(1)); - Assert.Throws(() => source.Retry(-1)); - Assert.Throws(() => source.Resume(null!)); - Assert.Throws(() => ((IObservable)null!).Delay(TimeSpan.Zero)); - Assert.Throws(() => ((IObservable)null!).Timeout(TimeSpan.Zero)); - Assert.Throws(() => ((IObservable)null!).CollectList()); - Assert.Throws(() => ((IEnumerable)null!).ToSignal()); - Assert.Throws(() => ((IObservable)null!).ToSignal()); - - Assert.Throws(() => ((IObservable)null!).Prepend(1)); - Assert.Throws(() => ((IObservable)null!).Append(1)); - Assert.Throws(() => ((IObservable)null!).IgnoreValues()); - Assert.Throws(() => ((IObservable)null!).DefaultIfEmpty()); - Assert.Throws(() => ((IObservable)null!).DistinctBy(value => value)); - Assert.Throws(() => source.DistinctBy(null!)); - Assert.Throws(() => ((IObservable)null!).DistinctUntilChangedBy(value => value)); - Assert.Throws(() => source.DistinctUntilChangedBy(null!)); - Assert.Throws(() => ((IObservable)null!).TakeWhile(value => true)); - Assert.Throws(() => source.TakeWhile(null!)); - Assert.Throws(() => ((IObservable)null!).SkipWhile(value => true)); - Assert.Throws(() => source.SkipWhile(null!)); - Assert.Throws(() => ((IObservable)null!).SelectMany(value => Signal.Return(value))); - Assert.Throws(() => source.SelectMany(null!)); - Assert.Throws(() => source.SelectMany(null!, (outer, inner) => outer + inner)); - Assert.Throws(() => source.SelectMany(value => Signal.Return(value), null!)); - Assert.Throws(() => source.Count(null!)); - Assert.Throws(() => source.LongCount(null!)); - Assert.Throws(() => ((IObservable)null!).Any(value => true)); - Assert.Throws(() => source.Any(null!)); - Assert.Throws(() => ((IObservable)null!).All(value => true)); - Assert.Throws(() => source.All(null!)); - Assert.Throws(() => ((IObservable)null!).DelayStart(TimeSpan.Zero)); - Assert.Throws(() => ((IObservable)null!).Throttle(TimeSpan.Zero)); - Assert.Throws(() => ((IObservable)null!).Sample(TimeSpan.FromTicks(1))); - Assert.Throws(() => source.Sample(TimeSpan.FromTicks(-1))); - Assert.Throws(() => ((IObservable)null!).Timestamp()); - Assert.Throws(() => ((IObservable)null!).TimeInterval()); - Assert.Throws(() => ((IObservable)null!).ForkJoin(Signal.Return(1), (left, right) => left + right)); - Assert.Throws(() => source.ForkJoin(null!, (left, right) => left + right)); - Assert.Throws(() => source.ForkJoin(Signal.Return(1), null!)); - Assert.Throws(() => ((IObservable)null!).CollectArrayAsync()); - Assert.Throws(() => ((IObservable)null!).CollectListAsync()); - - Assert.Throws(() => Signal.Range(0, -1)); - Assert.Throws(() => Signal.Range(0, 1, null!)); - Assert.Throws(() => Signal.Repeat(1, -1)); - Assert.Throws(() => Signal.Unfold(0, null!, value => value + 1, value => value)); - Assert.Throws(() => Signal.Unfold(0, value => true, null!, value => value)); - Assert.Throws(() => Signal.Unfold(0, value => true, value => value + 1, null!)); - Assert.Throws(() => Signal.Use(null!, _ => Signal.Return(1))); - Assert.Throws(() => Signal.Use(() => Disposable.Empty, null!)); - Assert.Throws(() => Signal.FromEnumerable(null!)); - Assert.Throws(() => Signal.FromTask((Task)null!)); - Assert.Throws(() => Signal.FromAsyncEnumerable(null!)); - Assert.Throws(() => Signal.Every(TimeSpan.FromTicks(-1))); - - Assert.Throws(() => objects.Subscribe(null!)); - Assert.Throws(() => Witness.Create(null!)); - Assert.Throws(() => Witness.Create(_ => { }, (Action)null!)); - Assert.Throws(() => Witness.Create(_ => { }, (Action)null!)); - Assert.Throws(() => Witness.Create(_ => { }, _ => { }, null!)); - Assert.Throws(() => Witness.Safe(null!)); - Assert.Throws(() => Witness.Safe(Witness.Create(_ => { }), null!)); - Assert.Throws(() => Witness.Create(_ => { }).OnError(null!)); - Assert.Throws(() => new CancellationDisposable(null!)); + CoverUnaryOperatorNullGuards(source); + CoverHigherOrderOperatorNullGuards(source); + CoverParityOperatorNullGuards(source); + CoverFactoryAndObserverNullGuards(objects); } + /// + /// Exercises successful operator paths and early-termination branches. + /// [Test] public void OperatorSurfaceCoversSuccessErrorAndEarlyTerminationBranches() { @@ -137,10 +289,10 @@ public void OperatorSurfaceCoversSuccessErrorAndEarlyTerminationBranches() var sideEffects = new List(); var terminal = 0; - Signal.FromEnumerable(new object?[] { "a", null, 2, "bbb", "cc", 3 }) + Signal.FromEnumerable(new object?[] { "a", null, Two, "bbb", "cc", Three }) .OfType() .MapWith("!", (suffix, value) => value + suffix) - .KeepWith(2, (min, value) => value.Length >= min) + .KeepWith(Two, (min, value) => value.Length >= min) .TapWith(sideEffects, (sink, value) => sink.Add(value)) .Cast() .Skip(1) @@ -148,44 +300,47 @@ public void OperatorSurfaceCoversSuccessErrorAndEarlyTerminationBranches() .DistinctUntilChanged(StringComparer.OrdinalIgnoreCase) .Subscribe(values.Add, ex => throw ex, () => terminal++); - Assert.Equal(new[] { "bbb!", "cc!" }, values); - Assert.Equal(new[] { "a!", "bbb!", "cc!" }, sideEffects); + Assert.Equal(ExpectedOperatorValues, values); + Assert.Equal(ExpectedSideEffects, sideEffects); Assert.Equal(1, terminal); var keepNotNull = new List(); Signal.FromEnumerable([null, "x", null, "y"]).KeepNotNull().Subscribe(keepNotNull.Add); - Assert.Equal(new[] { "x", "y" }, keepNotNull); + Assert.Equal(ExpectedKeepNotNull, keepNotNull); var emptyTake = new List(); var emptyTakeCompleted = 0; - Signal.Range(1, 3).Take(0).Subscribe(emptyTake.Add, ex => throw ex, () => emptyTakeCompleted++); + Signal.Range(1, Three).Take(0).Subscribe(emptyTake.Add, ex => throw ex, () => emptyTakeCompleted++); Assert.Equal(0, emptyTake.Count); Assert.Equal(1, emptyTakeCompleted); var skipAll = new List(); - Signal.Range(1, 3).Skip(10).Subscribe(skipAll.Add); + Signal.Range(1, Three).Skip(Ten).Subscribe(skipAll.Add); Assert.Equal(0, skipAll.Count); var anyFalse = new List(); var allFalse = new List(); var containsFalse = new List(); var longCount = new List(); - Signal.FromEnumerable([1, 2, 3]).Any(value => value > 9).Subscribe(anyFalse.Add); - Signal.FromEnumerable([2, 4, 5]).All(value => value % 2 == 0).Subscribe(allFalse.Add); - Signal.FromEnumerable([2, 4, 6]).Contains(7).Subscribe(containsFalse.Add); - Signal.FromEnumerable([1, 2, 3, 4]).LongCount(value => value % 2 == 0).Subscribe(longCount.Add); - Assert.Equal(new[] { false }, anyFalse); - Assert.Equal(new[] { false }, allFalse); - Assert.Equal(new[] { false }, containsFalse); - Assert.Equal(new[] { 2L }, longCount); + Signal.FromEnumerable([1, Two, Three]).Any(value => value > Nine).Subscribe(anyFalse.Add); + Signal.FromEnumerable([Two, Four, Five]).All(value => value % Two == 0).Subscribe(allFalse.Add); + Signal.FromEnumerable([Two, Four, Six]).Contains(Seven).Subscribe(containsFalse.Add); + Signal.FromEnumerable([1, Two, Three, Four]).LongCount(value => value % Two == 0).Subscribe(longCount.Add); + Assert.Equal(ExpectedFalse, anyFalse); + Assert.Equal(ExpectedFalse, allFalse); + Assert.Equal(ExpectedFalse, containsFalse); + Assert.Equal(ExpectedLongCount, longCount); var selectMany = new List(); - Signal.FromEnumerable([1, 2]) - .SelectMany(value => Signal.FromEnumerable([value, value + 10]), (outer, inner) => outer + ":" + inner) + Signal.FromEnumerable([1, Two]) + .SelectMany(value => Signal.FromEnumerable([value, value + Ten]), (outer, inner) => outer + ":" + inner) .Subscribe(selectMany.Add); - Assert.Equal(new[] { "1:1", "1:11", "2:2", "2:12" }, selectMany); + Assert.Equal(ExpectedSelectMany, selectMany); } + /// + /// Exercises error materialization, recovery, resume, and retry branches. + /// [Test] public void ErrorOperatorsMaterializeRecoverAndResumeDeterministically() { @@ -224,22 +379,25 @@ public void ErrorOperatorsMaterializeRecoverAndResumeDeterministically() .Subscribe(rescueValues.Add); Signal.Throw(new InvalidOperationException("resume")) - .Resume(Signal.FromEnumerable([4, 5])) + .Resume(Signal.FromEnumerable([Four, Five])) .Subscribe(resumeValues.Add); Signal.Defer(() => Signal.Throw(new InvalidOperationException("stop"))) .Retry(1) .Subscribe(_ => { }, ex => finalErrors.Add(ex.Message)); - Assert.Equal(new[] { SparkKind.OnError }, sparkKinds); - Assert.Equal(new[] { "spark" }, sparkErrors); - Assert.Equal(new[] { 1 }, unsparkValues); - Assert.Equal(new[] { "unspark" }, unsparkErrors); - Assert.Equal(new[] { 7 }, rescueValues); - Assert.Equal(new[] { 4, 5 }, resumeValues); - Assert.Equal(new[] { "stop" }, finalErrors); + Assert.Equal(ExpectedSparkKinds, sparkKinds); + Assert.Equal(ExpectedSparkErrors, sparkErrors); + Assert.Equal(ExpectedUnsparkValues, unsparkValues); + Assert.Equal(ExpectedUnsparkErrors, unsparkErrors); + Assert.Equal(ExpectedRescueValues, rescueValues); + Assert.Equal(ExpectedResumeValues, resumeValues); + Assert.Equal(ExpectedFinalErrors, finalErrors); } + /// + /// Exercises higher-order ordering, racing, switching, and latest-value behavior. + /// [Test] public void HigherOrderOperatorsHandleAsyncOrderingRacesSwitchingAndLatestValues() { @@ -259,20 +417,21 @@ public void HigherOrderOperatorsHandleAsyncOrderingRacesSwitchingAndLatestValues outer.OnNext(first); outer.OnNext(second); first.OnNext(1); - second.OnNext(20); - first.OnNext(2); + second.OnNext(Twenty); + first.OnNext(Two); first.OnCompleted(); - second.OnNext(21); + second.OnNext(TwentyOne); second.OnCompleted(); outer.OnCompleted(); - Signal.Merge(Signal.FromEnumerable([1, 2]), Signal.FromEnumerable([3])).Subscribe(mergeValues.Add, ex => throw ex, () => completed["merge"] = 1); + Signal.Merge(Signal.FromEnumerable([1, Two]), Signal.FromEnumerable([Three])) + .Subscribe(mergeValues.Add, ex => throw ex, () => completed["merge"] = 1); var raceLoser = new Signal(); var raceWinner = new Signal(); Signal.Race(raceLoser, raceWinner).Subscribe(raceValues.Add, ex => throw ex, () => completed["race"] = 1); - raceWinner.OnNext(7); - raceLoser.OnNext(99); + raceWinner.OnNext(Seven); + raceLoser.OnNext(NinetyNine); raceWinner.OnCompleted(); var switchOuter = new Signal>(); @@ -282,8 +441,8 @@ public void HigherOrderOperatorsHandleAsyncOrderingRacesSwitchingAndLatestValues switchOuter.OnNext(oldInner); oldInner.OnNext(1); switchOuter.OnNext(newInner); - oldInner.OnNext(2); - newInner.OnNext(3); + oldInner.OnNext(Two); + newInner.OnNext(Three); switchOuter.OnCompleted(); newInner.OnCompleted(); @@ -292,20 +451,24 @@ public void HigherOrderOperatorsHandleAsyncOrderingRacesSwitchingAndLatestValues left.WithLatest(right, (l, r) => l + r).Subscribe(withLatestValues.Add); left.OnNext(1); right.OnNext("a"); - left.OnNext(2); + left.OnNext(Two); right.OnNext("b"); - left.OnNext(3); + left.OnNext(Three); left.OnCompleted(); - Signal.FromEnumerable([1, 2, 3]).Zip(Signal.Return(10), (l, r) => l + r).Subscribe(zipShortValues.Add, ex => throw ex, () => completed["zip"] = 1); - Signal.Empty().ForkJoin(Signal.Return(1), (l, r) => l + r).Subscribe(forkJoinEmpty.Add, ex => throw ex, () => completed["forkJoinEmpty"] = 1); - - Assert.Equal(new[] { 1, 2, 21 }, concatValues); - Assert.Equal([1, 2, 3], mergeValues.Order()); - Assert.Equal(new[] { 7 }, raceValues); - Assert.Equal(new[] { 1, 3 }, switchValues); - Assert.Equal(new[] { "2a", "3b" }, withLatestValues); - Assert.Equal(new[] { 11 }, zipShortValues); + Signal.FromEnumerable([1, Two, Three]) + .Zip(Signal.Return(Ten), (l, r) => l + r) + .Subscribe(zipShortValues.Add, ex => throw ex, () => completed["zip"] = 1); + Signal.Empty() + .ForkJoin(Signal.Return(1), (l, r) => l + r) + .Subscribe(forkJoinEmpty.Add, ex => throw ex, () => completed["forkJoinEmpty"] = 1); + + Assert.Equal(ExpectedConcatValues, concatValues); + Assert.Equal(ExpectedMergeValues, mergeValues.Order()); + Assert.Equal(ExpectedRaceValues, raceValues); + Assert.Equal(ExpectedSwitchValues, switchValues); + Assert.Equal(ExpectedWithLatestValues, withLatestValues); + Assert.Equal(ExpectedZipShortValues, zipShortValues); Assert.Equal(0, forkJoinEmpty.Count); Assert.Equal(1, completed["concat"]); Assert.Equal(1, completed["merge"]); @@ -315,6 +478,9 @@ public void HigherOrderOperatorsHandleAsyncOrderingRacesSwitchingAndLatestValues Assert.Equal(1, completed["forkJoinEmpty"]); } + /// + /// Exercises virtual-time operators and aliases. + /// [Test] public void VirtualTimeOperatorsCoverDelayTimeoutSampleTimerAndTimestampAliases() { @@ -328,50 +494,54 @@ public void VirtualTimeOperatorsCoverDelayTimeoutSampleTimerAndTimestampAliases( var timestamps = new List>(); var manual = new Signal(); - manual.DelayStart(TimeSpan.FromTicks(5), clock).Subscribe(delayStartValues.Add); + manual.DelayStart(TimeSpan.FromTicks(Five), clock).Subscribe(delayStartValues.Add); manual.OnNext(1); - clock.AdvanceBy(TimeSpan.FromTicks(4)); + clock.AdvanceBy(TimeSpan.FromTicks(Four)); Assert.Equal(0, delayStartValues.Count); clock.AdvanceBy(TimeSpan.FromTicks(1)); - manual.OnNext(2); - Assert.Equal(new[] { 2 }, delayStartValues); + manual.OnNext(Two); + Assert.Equal(ExpectedDelayStartValues, delayStartValues); - Signal.FromEnumerable([3, 4]).Delay(TimeSpan.FromTicks(3), clock).Subscribe(delayedValues.Add); - clock.AdvanceBy(TimeSpan.FromTicks(2)); + Signal.FromEnumerable([Three, Four]).Delay(TimeSpan.FromTicks(Three), clock).Subscribe(delayedValues.Add); + clock.AdvanceBy(TimeSpan.FromTicks(Two)); Assert.Equal(0, delayedValues.Count); clock.AdvanceBy(TimeSpan.FromTicks(1)); - Assert.Equal(new[] { 3, 4 }, delayedValues); + Assert.Equal(ExpectedDelayedValues, delayedValues); var never = new Signal(); - never.Timeout(TimeSpan.FromTicks(4), clock).Subscribe(timeoutValues.Add, ex => timeoutErrors.Add(ex.GetType().Name)); - clock.AdvanceBy(TimeSpan.FromTicks(4)); - never.OnNext(42); + never.Timeout(TimeSpan.FromTicks(Four), clock).Subscribe(timeoutValues.Add, ex => timeoutErrors.Add(ex.GetType().Name)); + clock.AdvanceBy(TimeSpan.FromTicks(Four)); + never.OnNext(FortyTwo); Assert.Equal(0, timeoutValues.Count); - Assert.Equal(new[] { nameof(TimeoutException) }, timeoutErrors); + Assert.Equal(ExpectedTimeoutErrors, timeoutErrors); var completed = new Signal(); - completed.Timeout(TimeSpan.FromTicks(10), clock).Subscribe(timeoutValues.Add); - completed.OnNext(7); + completed.Timeout(TimeSpan.FromTicks(Ten), clock).Subscribe(timeoutValues.Add); + completed.OnNext(Seven); completed.OnCompleted(); - clock.AdvanceBy(TimeSpan.FromTicks(10)); - Assert.Equal(new[] { 7 }, timeoutValues); + clock.AdvanceBy(TimeSpan.FromTicks(Ten)); + Assert.Equal(ExpectedRaceValues, timeoutValues); - var pulse = Signal.Pulse(TimeSpan.FromTicks(2), clock).Subscribe(pulseValues.Add); - clock.AdvanceBy(TimeSpan.FromTicks(6)); + var pulse = Signal.Pulse(TimeSpan.FromTicks(Two), clock).Subscribe(pulseValues.Add); + clock.AdvanceBy(TimeSpan.FromTicks(Six)); pulse.Dispose(); - Assert.Equal(new[] { 0L, 1L, 2L }, pulseValues); + Assert.Equal(ExpectedTimerValues, pulseValues); - var timer = Signal.Timer(TimeSpan.FromTicks(3), TimeSpan.FromTicks(2), clock).Subscribe(timerValues.Add); - clock.AdvanceBy(TimeSpan.FromTicks(3)); - clock.AdvanceBy(TimeSpan.FromTicks(4)); + var timer = Signal.Timer(TimeSpan.FromTicks(Three), TimeSpan.FromTicks(Two), clock).Subscribe(timerValues.Add); + clock.AdvanceBy(TimeSpan.FromTicks(Three)); + clock.AdvanceBy(TimeSpan.FromTicks(Four)); timer.Dispose(); - Assert.Equal(new[] { 0L, 1L, 2L }, timerValues); + Assert.Equal(ExpectedTimerValues, timerValues); - Signal.FromEnumerable([8, 9]).Timestamp(clock).Subscribe(timestamps.Add); - Assert.Equal([8, 9], timestamps.Select(item => item.Value)); - Assert.True(timestamps.All(item => item.Timestamp == clock.Now)); + Signal.FromEnumerable([Eight, Nine]).Timestamp(clock).Subscribe(timestamps.Add); + Assert.Equal(ExpectedTimestampValues, timestamps.Select(item => item.Value)); + Assert.True(timestamps.TrueForAll(item => item.Timestamp == clock.Now)); } + /// + /// Exercises task, async-enumerable, and terminal task branches. + /// + /// A task that completes when asynchronous coverage has run. [Test] public async Task FactoriesTasksAndTerminalTasksCoverCancellationFaultAndEmptyBranches() { @@ -396,30 +566,37 @@ static async IAsyncEnumerable ThrowingAsyncEnumerable() Signal.FromAsyncEnumerable(ThrowingAsyncEnumerable()).Subscribe(asyncValues.Add, ex => asyncErrors.Add(ex.Message)); await SpinUntil(() => asyncErrors.Count == 1, TimeSpan.FromSeconds(2)); - var firstFailure = await AssertTaskFault(() => Signal.Empty().FirstAsync()); - var collectFailure = await AssertTaskFault(() => Signal.Throw(new InvalidOperationException("collect")).CollectArrayAsync()); - var listFailure = await AssertTaskFault(() => Signal.Throw(new InvalidOperationException("list")).CollectListAsync()); - - Assert.Equal(new[] { "The signal factory returned null.", "resource" }, useErrors); - Assert.Equal(new[] { nameof(TaskCanceledException), nameof(InvalidOperationException) }, taskErrors); - Assert.Equal(new[] { 1 }, asyncValues); - Assert.Equal(new[] { "async" }, asyncErrors); + var firstFailure = await AssertTaskFault(() => Signal.Empty().FirstAsync(), typeof(InvalidOperationException)); + var collectFailure = await AssertTaskFault( + () => Signal.Throw(new InvalidOperationException("collect")).CollectArrayAsync(), + typeof(InvalidOperationException)); + var listFailure = await AssertTaskFault( + () => Signal.Throw(new InvalidOperationException("list")).CollectListAsync(), + typeof(InvalidOperationException)); + + Assert.Equal(ExpectedUseErrors, useErrors); + Assert.Equal(ExpectedTaskErrors, taskErrors); + Assert.Equal(ExpectedUnsparkValues, asyncValues); + Assert.Equal(ExpectedAsyncErrors, asyncErrors); Assert.Equal("The source completed without producing a value.", firstFailure.Message); Assert.Equal("collect", collectFailure.Message); Assert.Equal("list", listFailure.Message); } + /// + /// Exercises value types, disposables, and reflection-covered handle delegates. + /// [Test] [RequiresUnreferencedCode("The test exercises reflection and dynamic-code coverage branches.")] [RequiresDynamicCode("The test exercises reflection and dynamic-code coverage branches.")] public void CoreValueTypesDisposablesAndHandlesCoverEqualityAndLifecycleBranches() { - var moment = new Moment(7, new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); - var sameMoment = new Moment(7, moment.Timestamp); - var differentMoment = new Moment(8, moment.Timestamp.AddTicks(1)); - var interval = new TimeInterval(7, TimeSpan.FromTicks(3)); - var sameInterval = new TimeInterval(7, TimeSpan.FromTicks(3)); - var differentInterval = new TimeInterval(8, TimeSpan.FromTicks(4)); + var moment = new Moment(Seven, new DateTimeOffset(CalendarYear, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var sameMoment = new Moment(Seven, moment.Timestamp); + var differentMoment = new Moment(Eight, moment.Timestamp.AddTicks(1)); + var interval = new TimeInterval(Seven, TimeSpan.FromTicks(Three)); + var sameInterval = new TimeInterval(Seven, TimeSpan.FromTicks(Three)); + var differentInterval = new TimeInterval(Eight, TimeSpan.FromTicks(Four)); var rxVoid = default(RxVoid); var ignored = 0; var thrown = new InvalidOperationException("throw-me"); @@ -440,15 +617,13 @@ public void CoreValueTypesDisposablesAndHandlesCoverEqualityAndLifecycleBranches Assert.Equal(interval.GetHashCode(), sameInterval.GetHashCode()); Assert.True(interval.ToString().Contains("7", StringComparison.Ordinal)); - Assert.True(rxVoid == default); - Assert.False(rxVoid != default); Assert.True(rxVoid.Equals(default)); Assert.True(rxVoid.Equals((object)default(RxVoid))); Assert.Equal(0, rxVoid.GetHashCode()); Assert.Equal("()", rxVoid.ToString()); InvokeInternalHandleMembers(thrown); - InvokeInternalCatchIgnore(new InvalidOperationException("ignored")).Subscribe(_ => ignored++); + InvokeInternalCatchIgnore(new InvalidOperationException("ignored"), 0).Subscribe(_ => ignored++); Assert.Equal(0, ignored); var boolean = new BooleanDisposable(); @@ -462,8 +637,8 @@ public void CoreValueTypesDisposablesAndHandlesCoverEqualityAndLifecycleBranches new Slot(Disposable.Create(() => slotDisposed++), () => slotDisposed++).Dispose(); new AssignmentSlot(Disposable.Create(() => assignmentDisposed++), () => assignmentDisposed++).Dispose(); new Pocket(Disposable.Create(() => pocketDisposed++)).Dispose(); - Assert.Equal(2, slotDisposed); - Assert.Equal(2, assignmentDisposed); + Assert.Equal(Two, slotDisposed); + Assert.Equal(Two, assignmentDisposed); Assert.Equal(1, pocketDisposed); var single = new SingleDisposable(Disposable.Create(() => { }), () => { }); @@ -485,12 +660,15 @@ public void CoreValueTypesDisposablesAndHandlesCoverEqualityAndLifecycleBranches Assert.True(multiple.IsDisposed); } + /// + /// Exercises spark value, error, completion, equality, and accept overloads. + /// [Test] public void SparksCoverValueErrorCompletionEqualityAndAcceptOverloads() { - var next = Spark.CreateOnNext(42); - var sameNext = Spark.CreateOnNext(42); - var differentNext = Spark.CreateOnNext(43); + var next = Spark.CreateOnNext(FortyTwo); + var sameNext = Spark.CreateOnNext(FortyTwo); + var differentNext = Spark.CreateOnNext(FortyThree); var error = new InvalidOperationException("spark-error"); var errorSpark = Spark.CreateOnError(error); var sameError = Spark.CreateOnError(error); @@ -502,17 +680,16 @@ public void SparksCoverValueErrorCompletionEqualityAndAcceptOverloads() Assert.True(next == sameNext); Assert.True(next != differentNext); - Assert.False(next.Equals((Spark?)null)); Assert.False(next.Equals(completed)); Assert.True(next.HasValue); - Assert.Equal(42, next.Value); + Assert.Equal(FortyTwo, next.Value); Assert.Equal(SparkKind.OnNext, next.Kind); - Assert.True(next.ToString().Contains("42", StringComparison.Ordinal)); + Assert.True(next.ToString().Contains(FortyTwo.ToString(), StringComparison.Ordinal)); Assert.Equal(next.GetHashCode(), sameNext.GetHashCode()); next.Accept((IObserver)observer); Assert.Equal("next:42", next.Accept((IObserver)observer)); next.Accept(value => observer.Events.Add("delegate-next:" + value), ex => observer.Events.Add(ex.Message), () => observer.Events.Add("delegate-completed")); - Assert.Equal("fn-next:42", next.Accept(value => "fn-next:" + value, ex => ex.Message, () => "fn-completed")); + Assert.Equal("fn-next:42", next.Accept(value => "fn-next:" + value, ex => ex.Message, () => FunctionCompletedText)); Assert.Throws(() => next.Accept((IObserver)null!)); Assert.Throws(() => next.Accept((IObserver)null!)); Assert.Throws(() => next.Accept(null!, ex => { }, () => { })); @@ -524,7 +701,6 @@ public void SparksCoverValueErrorCompletionEqualityAndAcceptOverloads() Assert.True(errorSpark == sameError); Assert.True(errorSpark != next); - Assert.False(errorSpark.Equals((Spark?)null)); Assert.False(errorSpark.HasValue); Assert.Equal(error, errorSpark.Exception); Assert.Equal(SparkKind.OnError, errorSpark.Kind); @@ -534,7 +710,7 @@ public void SparksCoverValueErrorCompletionEqualityAndAcceptOverloads() errorSpark.Accept((IObserver)observer); Assert.Equal("error:spark-error", errorSpark.Accept((IObserver)observer)); errorSpark.Accept(value => observer.Events.Add(value.ToString()), ex => observer.Events.Add("delegate-error:" + ex.Message), () => observer.Events.Add("delegate-completed")); - Assert.Equal("fn-error:spark-error", errorSpark.Accept(value => value.ToString(), ex => "fn-error:" + ex.Message, () => "fn-completed")); + Assert.Equal("fn-error:spark-error", errorSpark.Accept(value => value.ToString(), ex => "fn-error:" + ex.Message, () => FunctionCompletedText)); Assert.Throws(() => Spark.CreateOnError(null!)); Assert.Throws(() => errorSpark.Accept((IObserver)null!)); Assert.Throws(() => errorSpark.Accept((IObserver)null!)); @@ -547,15 +723,14 @@ public void SparksCoverValueErrorCompletionEqualityAndAcceptOverloads() Assert.Same(completed, completedAgain); Assert.True(completed.Equals(completedAgain)); - Assert.False(completed.Equals((Spark?)null)); Assert.False(completed.HasValue); Assert.Equal(SparkKind.OnCompleted, completed.Kind); Assert.Throws(() => _ = completed.Value); Assert.Equal("OnCompleted()", completed.ToString()); completed.Accept((IObserver)observer); - Assert.Equal("completed", completed.Accept((IObserver)observer)); + Assert.Equal(CompletedText, completed.Accept((IObserver)observer)); completed.Accept(value => observer.Events.Add(value.ToString()), ex => observer.Events.Add(ex.Message), () => observer.Events.Add("delegate-completed")); - Assert.Equal("fn-completed", completed.Accept(value => value.ToString(), ex => ex.Message, () => "fn-completed")); + Assert.Equal(FunctionCompletedText, completed.Accept(value => value.ToString(), ex => ex.Message, () => FunctionCompletedText)); Assert.Throws(() => completed.Accept((IObserver)null!)); Assert.Throws(() => completed.Accept((IObserver)null!)); Assert.Throws(() => completed.Accept(null!, ex => { }, () => { })); @@ -567,54 +742,243 @@ public void SparksCoverValueErrorCompletionEqualityAndAcceptOverloads() Assert.Throws(() => completed.ToObservable(null!)); next.ToObservable().Subscribe(observableValues.Add, ex => throw ex, () => observableCompleted++); - Assert.Equal(new[] { 42 }, observableValues); + Assert.Equal(ExpectedObservableValues, observableValues); Assert.Equal(1, observableCompleted); Assert.Contains("next:42", observer.Events); Assert.Contains("error:spark-error", observer.Events); - Assert.Contains("completed", observer.Events); + Assert.Contains(CompletedText, observer.Events); } + /// + /// Covers null guards for unary operators. + /// + /// The non-null source used for null argument checks. + private static void CoverUnaryOperatorNullGuards(IObservable source) + { + Assert.Throws(() => ((IObservable)null!).Map(value => value)); + Assert.Throws(() => source.Map(null!)); + Assert.Throws(() => ((IObservable)null!).MapWith(1, (_, value) => value)); + Assert.Throws(() => source.MapWith(1, null!)); + Assert.Throws(() => ((IObservable)null!).Keep(value => true)); + Assert.Throws(() => source.Keep(null!)); + Assert.Throws(() => ((IObservable)null!).KeepWith(1, (_, _) => true)); + Assert.Throws(() => source.KeepWith(1, null!)); + Assert.Throws(() => ((IObservable)null!).KeepNotNull()); + Assert.Throws(() => ((IObservable)null!).OfType()); + Assert.Throws(() => ((IObservable)null!).Cast()); + Assert.Throws(() => ((IObservable)null!).Tap(value => { })); + Assert.Throws(() => source.Tap(null!)); + Assert.Throws(() => ((IObservable)null!).TapWith(1, (_, _) => { })); + Assert.Throws(() => source.TapWith(1, null!)); + Assert.Throws(() => ((IObservable)null!).Scan(0, (left, right) => left + right)); + Assert.Throws(() => source.Scan(0, null!)); + Assert.Throws(() => ((IObservable)null!).Fold(0, (left, right) => left + right)); + Assert.Throws(() => source.Fold(0, null!)); + Assert.Throws(() => ((IObservable)null!).Take(1)); + Assert.Throws(() => ((IObservable)null!).Skip(1)); + Assert.Throws(() => ((IObservable)null!).Distinct()); + Assert.Throws(() => ((IObservable)null!).DistinctUntilChanged()); + Assert.Throws(() => ((IObservable)null!).Sparkify()); + Assert.Throws(() => ((IObservable>)null!).Unspark()); + Assert.Throws(() => ((IObservable)null!).Delay(TimeSpan.Zero, Sequencer.Immediate)); + Assert.Throws(() => ((IObservable)null!).Timeout(TimeSpan.Zero, Sequencer.Immediate)); + Assert.Throws(() => ((IObservable)null!).CollectList()); + Assert.Throws(() => ((IObservable)null!).ToSignal()); + } + + /// + /// Covers null guards for higher-order operators. + /// + /// The non-null source used for null argument checks. + private static void CoverHigherOrderOperatorNullGuards(IObservable source) + { + Assert.Throws(() => ((IObservable>)null!).Concat()); + Assert.Throws(() => Signal.Concat(null!)); + Assert.Throws(() => Signal.Concat(source, null!)); + Assert.Throws(() => Signal.Merge(null!)); + Assert.Throws(() => Signal.Merge(source, null!)); + Assert.Throws(() => Signal.Race(null!)); + Assert.Throws(() => Signal.Race(source, null!)); + Assert.Throws(() => ((IObservable)null!).Zip(source, (left, _) => left)); + Assert.Throws(() => source.Zip(null!, (left, _) => left)); + Assert.Throws(() => source.Zip(source, null!)); + Assert.Throws(() => ((IObservable)null!).CombineLatest(source, (left, _) => left)); + Assert.Throws(() => source.CombineLatest(null!, (left, _) => left)); + Assert.Throws(() => source.CombineLatest(source, null!)); + Assert.Throws(() => ((IObservable)null!).WithLatest(source, (left, _) => left)); + Assert.Throws(() => source.WithLatest(null!, (left, _) => left)); + Assert.Throws(() => source.WithLatest(source, null!)); + Assert.Throws(() => ((IObservable>)null!).Switch()); + Assert.Throws(() => ((IObservable)null!).Retry(1)); + Assert.Throws(() => ((IObservable)null!).Resume(source)); + Assert.Throws(() => source.Resume(null!)); + } + + /// + /// Covers null guards for parity operators. + /// + /// The non-null source used for null argument checks. + private static void CoverParityOperatorNullGuards(IObservable source) + { + Assert.Throws(() => ((IObservable)null!).Prepend(1)); + Assert.Throws(() => ((IObservable)null!).Append(1)); + Assert.Throws(() => ((IObservable)null!).IgnoreValues()); + Assert.Throws(() => ((IObservable)null!).DefaultIfEmpty()); + Assert.Throws(() => ((IObservable)null!).DistinctBy(value => value)); + Assert.Throws(() => source.DistinctBy(null!)); + Assert.Throws(() => ((IObservable)null!).DistinctUntilChangedBy(value => value)); + Assert.Throws(() => source.DistinctUntilChangedBy(null!)); + Assert.Throws(() => ((IObservable)null!).TakeWhile(value => true)); + Assert.Throws(() => source.TakeWhile(null!)); + Assert.Throws(() => ((IObservable)null!).SkipWhile(value => true)); + Assert.Throws(() => source.SkipWhile(null!)); + Assert.Throws(() => ((IObservable)null!).SelectMany(value => source)); + Assert.Throws(() => source.SelectMany(null!)); + Assert.Throws(() => ((IObservable)null!).Count()); + Assert.Throws(() => ((IObservable)null!).LongCount()); + Assert.Throws(() => ((IObservable)null!).Any()); + Assert.Throws(() => ((IObservable)null!).All(value => true)); + Assert.Throws(() => source.All(null!)); + Assert.Throws(() => ((IObservable)null!).DelayStart(TimeSpan.Zero, Sequencer.Immediate)); + Assert.Throws(() => ((IObservable)null!).Throttle(TimeSpan.Zero, Sequencer.Immediate)); + Assert.Throws(() => ((IObservable)null!).Sample(TimeSpan.Zero, Sequencer.Immediate)); + Assert.Throws(() => ((IObservable)null!).Timestamp(Sequencer.Immediate)); + Assert.Throws(() => ((IObservable)null!).TimeInterval(Sequencer.Immediate)); + Assert.Throws(() => ((IObservable)null!).ForkJoin(source, (left, _) => left)); + Assert.Throws(() => source.ForkJoin(null!, (left, _) => left)); + Assert.Throws(() => source.ForkJoin(source, null!)); + Assert.Throws(() => ((IObservable)null!).CollectArrayAsync()); + Assert.Throws(() => ((IObservable)null!).CollectListAsync()); + } + + /// + /// Covers null guards for factories and observers. + /// + /// The non-null object source used for null argument checks. + private static void CoverFactoryAndObserverNullGuards(IObservable objects) + { + Assert.Throws(() => Signal.Create(null!)); + Assert.Throws(() => Signal.Defer(null!)); + Assert.Throws(() => Signal.FromEnumerable(null!)); + Assert.Throws(() => Signal.FromTask((Task)null!)); + Assert.Throws(() => Signal.FromAsyncEnumerable(null!)); + Assert.Throws(() => Signal.Using(null!, resource => Signal.Return(1))); + Assert.Throws(() => Signal.Using(() => Disposable.Empty, (Func>)null!)); + Assert.Throws(() => Signal.Use(null!, resource => Signal.Return(1))); + Assert.Throws(() => Signal.Use(() => Disposable.Empty, (Func>)null!)); + Assert.Throws(() => ((IObservable)null!).Subscribe(value => { })); + Assert.Throws(() => Signal.Return(1).Subscribe((Action)null!)); + Assert.Throws(() => Signal.Return(1).Subscribe(value => { }, (Action)null!)); + Assert.Throws(() => Signal.Return(1).Subscribe(value => { }, ex => { }, null!)); + Assert.Throws(() => objects.Cast().Subscribe((IObserver)null!)); + } + + /// + /// Observes the error produced by a task-backed signal. + /// + /// The source task. + /// The error name sink. + /// A task that completes when the error has been observed. private static async Task ObserveTaskError(Task task, List errors) { Signal.FromTask(task).Subscribe(_ => { }, ex => errors.Add(ex.GetType().Name)); await SpinUntil(() => errors.Count > 0, TimeSpan.FromSeconds(2)); } + /// + /// Invokes internal handle members through reflection. + /// + /// The exception expected from throwing delegates. [RequiresDynamicCode("Calls System.Type.MakeGenericType(params Type[])")] [RequiresUnreferencedCode("Calls System.Reflection.Assembly.GetType(String)")] private static void InvokeInternalHandleMembers(Exception exception) { var assembly = typeof(RxVoid).Assembly; - InvokeAction(assembly.GetType("ReactiveUI.Primitives.Handle")!.GetField("Nop", BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!); - InvokeAction(assembly.GetType("ReactiveUI.Primitives.Handle`1")!.MakeGenericType(typeof(int)).GetField("Ignore", BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!, 1); - InvokeAction(assembly.GetType("ReactiveUI.Primitives.Handle`2")!.MakeGenericType(typeof(int), typeof(int)).GetField("Ignore", BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!, 1, 2); - InvokeAction(assembly.GetType("ReactiveUI.Primitives.Handle`3")!.MakeGenericType(typeof(int), typeof(int), typeof(int)).GetField("Ignore", BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!, 1, 2, 3); - - var identity = (Delegate)assembly.GetType("ReactiveUI.Primitives.Handle`1")!.MakeGenericType(typeof(string)).GetField("Identity", BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!; + InvokeAction(GetHandleField(assembly, "ReactiveUI.Primitives.Handle", "Nop")); + InvokeAction(GetGenericHandleField(assembly, "ReactiveUI.Primitives.Handle`1", "Ignore", typeof(int)), 1); + InvokeAction(GetGenericHandleField(assembly, "ReactiveUI.Primitives.Handle`2", "Ignore", typeof(int), typeof(int)), 1, Two); + InvokeAction( + GetGenericHandleField(assembly, "ReactiveUI.Primitives.Handle`3", "Ignore", typeof(int), typeof(int), typeof(int)), + 1, + Two, + Three); + + var identity = (Delegate)GetGenericHandleField(assembly, "ReactiveUI.Primitives.Handle`1", "Identity", typeof(string)); Assert.Equal("x", identity.DynamicInvoke("x")); - InvokeThrows(assembly.GetType("ReactiveUI.Primitives.Handle")!.GetField("Throw", BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!, exception); - InvokeThrows(assembly.GetType("ReactiveUI.Primitives.Handle`1")!.MakeGenericType(typeof(int)).GetField("Throw", BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!, exception, 1); - InvokeThrows(assembly.GetType("ReactiveUI.Primitives.Handle`2")!.MakeGenericType(typeof(int), typeof(int)).GetField("Throw", BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!, exception, 1, 2); - InvokeThrows(assembly.GetType("ReactiveUI.Primitives.Handle`3")!.MakeGenericType(typeof(int), typeof(int), typeof(int)).GetField("Throw", BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!, exception, 1, 2, 3); + InvokeThrows(GetHandleField(assembly, "ReactiveUI.Primitives.Handle", ThrowMemberName), exception); + InvokeThrows(GetGenericHandleField(assembly, "ReactiveUI.Primitives.Handle`1", ThrowMemberName, typeof(int)), exception, 1); + InvokeThrows( + GetGenericHandleField(assembly, "ReactiveUI.Primitives.Handle`2", ThrowMemberName, typeof(int), typeof(int)), + exception, + 1, + Two); + InvokeThrows( + GetGenericHandleField(assembly, "ReactiveUI.Primitives.Handle`3", ThrowMemberName, typeof(int), typeof(int), typeof(int)), + exception, + 1, + Two, + Three); } + /// + /// Invokes the internal catch-ignore helper. + /// + /// The observable value type. + /// The ignored exception. + /// A value used for generic type inference. + /// The ignored observable. [RequiresUnreferencedCode("Calls System.Reflection.Assembly.GetType(String)")] [RequiresDynamicCode("Calls System.Reflection.MethodInfo.MakeGenericMethod(params Type[])")] - private static IObservable InvokeInternalCatchIgnore(Exception exception) + private static IObservable InvokeInternalCatchIgnore(Exception exception, T witness) { + GC.KeepAlive(witness); var handle = typeof(RxVoid).Assembly.GetType("ReactiveUI.Primitives.Handle")!; var method = handle.GetMethod("CatchIgnore", BindingFlags.Public | BindingFlags.Static)!.MakeGenericMethod(typeof(T)); return (IObservable)method.Invoke(null, [exception])!; } - private static void InvokeAction(object action, params object[] args) => ((Delegate)action).DynamicInvoke(args); - + /// + /// Gets a non-generic handle field value. + /// + /// The source assembly. + /// The handle type name. + /// The field name. + /// The field value. + [RequiresUnreferencedCode("Calls System.Reflection.Assembly.GetType(String)")] + private static object GetHandleField(Assembly assembly, string typeName, string fieldName) => + assembly.GetType(typeName)!.GetField(fieldName, BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!; + + /// + /// Gets a generic handle field value. + /// + /// The source assembly. + /// The generic handle type name. + /// The field name. + /// The generic type arguments. + /// The field value. + [RequiresDynamicCode("Calls System.Type.MakeGenericType(params Type[])")] + [RequiresUnreferencedCode("Calls System.Reflection.Assembly.GetType(String)")] + private static object GetGenericHandleField(Assembly assembly, string typeName, string fieldName, params Type[] typeArguments) => + assembly.GetType(typeName)!.MakeGenericType(typeArguments).GetField(fieldName, BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!; + + /// + /// Invokes a delegate action through reflection. + /// + /// The delegate object. + /// The invocation arguments. + private static void InvokeAction(object action, params object[] args) => _ = ((Delegate)action).DynamicInvoke(args); + + /// + /// Invokes a delegate expected to throw. + /// + /// The delegate object. + /// The invocation arguments. private static void InvokeThrows(object action, params object[] args) { try { - ((Delegate)action).DynamicInvoke(args); + _ = ((Delegate)action).DynamicInvoke(args); } catch (TargetInvocationException ex) when (ex.InnerException is InvalidOperationException) { @@ -624,57 +988,100 @@ private static void InvokeThrows(object action, params object[] args) throw new InvalidOperationException("Expected internal handle throw delegate to throw InvalidOperationException."); } - private static async Task AssertTaskFault(Func taskFactory) - where TException : Exception + /// + /// Asserts that a task factory faults with the expected exception type. + /// + /// The task factory. + /// The expected exception type. + /// The captured exception. + private static async Task AssertTaskFault(Func taskFactory, Type expectedExceptionType) { try { await taskFactory(); } - catch (TException exception) + catch (Exception exception) when (exception.GetType() == expectedExceptionType) { return exception; } - throw new InvalidOperationException($"Expected task fault {typeof(TException).Name}."); + throw new InvalidOperationException("Expected task fault " + expectedExceptionType.Name + "."); } + /// + /// Spins asynchronously until the condition is true or the timeout elapses. + /// + /// The completion condition. + /// The maximum wait duration. + /// A task that completes when the condition is reached. private static async Task SpinUntil(Func condition, TimeSpan timeout) { - var deadline = DateTimeOffset.UtcNow + timeout; + var timeoutTask = Task.Delay(timeout); while (!condition()) { - if (DateTimeOffset.UtcNow >= deadline) + if (timeoutTask.IsCompleted) { - throw new TimeoutException("Condition was not reached before timeout."); + throw new TimeoutException("Condition was not reached before " + nameof(timeout) + "."); } - await Task.Delay(10); + await Task.Delay(PollDelayMilliseconds); } } + /// + /// Records observer events and result values. + /// + /// The observed value type. private sealed class RecordingResultObserver : IObserver, IObserver { + /// + /// Gets the recorded events. + /// public List Events { get; } = []; - public void OnCompleted() => Events.Add("completed"); + /// + /// Records completion. + /// + public void OnCompleted() => Events.Add(CompletedText); + /// + /// Records an error. + /// + /// The observed error. public void OnError(Exception error) => Events.Add("error:" + error.Message); + /// + /// Records a next value. + /// + /// The observed value. public void OnNext(T value) => Events.Add("next:" + value); + /// + /// Records completion and returns a result. + /// + /// The completion result. string IObserver.OnCompleted() { - Events.Add("completed"); - return "completed"; + Events.Add(CompletedText); + return CompletedText; } + /// + /// Records an error and returns a result. + /// + /// The observed error. + /// The error result. string IObserver.OnError(Exception exception) { Events.Add("error:" + exception.Message); return "error:" + exception.Message; } + /// + /// Records a next value and returns a result. + /// + /// The observed value. + /// The next result. string IObserver.OnNext(T value) { Events.Add("next:" + value); diff --git a/src/tests/ReactiveUI.Primitives.Tests/DummyDisposable.cs b/src/tests/ReactiveUI.Primitives.Tests/DummyDisposable.cs index e3b0db9..aa3acf1 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/DummyDisposable.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/DummyDisposable.cs @@ -3,14 +3,21 @@ // See the LICENSE file in the project root for full license information. using System; -#if NET48 -#endif namespace ReactiveUI.Primitives.Tests; -internal class DummyDisposable : IDisposable +/// +/// Provides a reusable disposable test instance. +/// +internal sealed class DummyDisposable : IDisposable { - public static readonly DummyDisposable Instance = new(); + /// + /// Gets the shared disposable instance. + /// + public static DummyDisposable Instance { get; } = new(); - public void Dispose() => throw new NotImplementedException(); + /// + public void Dispose() + { + } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs b/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs index dac39bd..61cfaa7 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs @@ -15,8 +15,272 @@ namespace ReactiveUI.Primitives.Tests; +/// +/// Verifies factory and operator contract behavior for the primitives surface. +/// public class FactoryOperatorContractTests { + /// + /// The first integer used by parity sequences. + /// + private const int FirstValue = 1; + + /// + /// The second integer used by parity sequences. + /// + private const int SecondValue = 2; + + /// + /// The third integer used by parity sequences. + /// + private const int RetrySuccessAttempt = 3; + + /// + /// The fourth integer used by parity sequences. + /// + private const int FourthValue = 4; + + /// + /// A representative even value used by predicate tests. + /// + private const int SixthValue = 6; + + /// + /// A resource-scoped sequence value. + /// + private const int ResourceFirstValue = 7; + + /// + /// A resource-scoped sequence value. + /// + private const int ResourceSecondValue = 8; + + /// + /// The repeated value used by finite factory tests. + /// + private const int RepeatValue = 9; + + /// + /// The multiplier used by unfold and projection tests. + /// + private const int ProjectionMultiplier = 10; + + /// + /// The first projected value after applying the projection multiplier. + /// + private const int ProjectedFirstValue = 10; + + /// + /// The second projected value after applying the projection multiplier. + /// + private const int ProjectedSecondValue = 11; + + /// + /// The third projected value after applying the projection multiplier. + /// + private const int ProjectedThirdValue = 20; + + /// + /// The fourth projected value after applying the projection multiplier. + /// + private const int ProjectedFourthValue = 21; + + /// + /// A peer value used to verify distinct-by bucketing. + /// + private const int ProjectedSecondBucketPeerValue = 12; + + /// + /// The zip result expected from the first pair. + /// + private const int FirstZipResult = 11; + + /// + /// The zip or fork-join result expected from the second pair. + /// + private const int SecondZipResult = 22; + + /// + /// The third unfolded value. + /// + private const int ThirdUnfoldedValue = 30; + + /// + /// The terminal value used by default and recovery tests. + /// + private const int RetryResult = 42; + + /// + /// Delay used by the async enumerable cancellation test. + /// + private const int AsyncEnumeratorDelayMilliseconds = 5000; + + /// + /// Settle delay used by the async enumerable cancellation test. + /// + private const int AsyncEnumeratorSettleMilliseconds = 50; + + /// + /// Virtual clock due time for one-shot timers. + /// + private const int AfterTicks = 5; + + /// + /// Virtual clock period for recurring timers. + /// + private const int EveryTicks = 3; + + /// + /// Virtual clock advance used before a boundary tick. + /// + private const int InitialAdvanceTicks = 4; + + /// + /// Virtual clock advance used after disposing recurring work. + /// + private const int FinalAdvanceTicks = 10; + + /// + /// Index of the third interval captured in the interval test. + /// + private const int ThirdIntervalIndex = 2; + + /// + /// Expected values for finite factory composition. + /// + private static readonly int[] FiniteFactoryExpected = + [ + SecondValue, + RetrySuccessAttempt, + FourthValue, + RepeatValue, + RepeatValue, + ProjectedFirstValue, + ProjectedThirdValue, + ThirdUnfoldedValue, + ResourceFirstValue, + ResourceSecondValue, + ]; + + /// + /// Expected values from the unary materialization test. + /// + private static readonly int[] UnaryExpected = [FourthValue, ProjectedFirstValue, 18]; + + /// + /// Expected source values from a four-item sequence. + /// + private static readonly int[] FourItemExpected = [FirstValue, SecondValue, RetrySuccessAttempt, FourthValue]; + + /// + /// Expected selected values after source disposal. + /// + private static readonly int[] SelectedAfterDisposeExpected = [SecondValue, RetrySuccessAttempt]; + + /// + /// Expected values from a single-filter pass. + /// + private static readonly int[] SingleSecondValueExpected = [SecondValue]; + + /// + /// Expected values from the zip test. + /// + private static readonly int[] ZippedExpected = [FirstZipResult, SecondZipResult]; + + /// + /// Expected values from combine-latest style operators. + /// + private static readonly string[] LatestExpected = ["2a", "2b"]; + + /// + /// Expected values from virtual recurring timers. + /// + private static readonly long[] EveryExpected = [0L, 1L, 2L]; + + /// + /// Expected values from lead, append, and prepend. + /// + private static readonly int[] LeadAppendExpected = [0, FirstValue, SecondValue, RetrySuccessAttempt, FourthValue]; + + /// + /// Expected values from the System.Reactive named alias migration test. + /// + private static readonly int[] SystemReactiveNamedAliasExpected = [0, FirstValue, SecondValue, RetrySuccessAttempt]; + + /// + /// Expected values after distinct-by bucketing. + /// + private static readonly int[] DistinctByExpected = [ProjectedSecondValue, ProjectedFourthValue]; + + /// + /// Expected values from a take-while sequence. + /// + private static readonly int[] TakeWhileExpected = [FirstValue, SecondValue]; + + /// + /// Expected values from a skip-while sequence. + /// + private static readonly int[] SkipWhileExpected = [RetrySuccessAttempt, FirstValue]; + + /// + /// Expected values from bind selection. + /// + private static readonly int[] SelectedProjectionExpected = + [ + ProjectedFirstValue, + ProjectedSecondValue, + ProjectedThirdValue, + ProjectedFourthValue, + ]; + + /// + /// Expected true result for boolean terminal operators. + /// + private static readonly bool[] TrueExpected = [true]; + + /// + /// Expected one-shot timer result before repeated timer advancement. + /// + private static readonly long[] OneShotTimerExpected = [0L]; + + /// + /// Expected retry recovery value. + /// + private static readonly int[] RetryResultExpected = [RetryResult]; + + /// + /// Expected async enumerable value before disposal. + /// + private static readonly int[] AsyncEnumerableBeforeDisposeExpected = [FirstValue]; + + /// + /// Expected observed value after virtual clock processing. + /// + private static readonly int[] ObservedResourceExpected = [ResourceFirstValue]; + + /// + /// Expected throttle output after the quiet period. + /// + private static readonly int[] ThrottleExpected = [RetrySuccessAttempt]; + + /// + /// Expected sample output over the virtual clock ticks. + /// + private static readonly int[] SampleExpected = [SecondValue, RetrySuccessAttempt]; + + /// + /// Expected fork-join output. + /// + private static readonly int[] ForkJoinExpected = [SecondZipResult]; + + /// + /// Expected collected task output. + /// + private static readonly int[] CollectedExpected = [FirstValue, SecondValue, RetrySuccessAttempt]; + + /// + /// Verifies finite factory composition and resource disposal. + /// [Test] public void FactoriesEmitExpectedFiniteSequencesAndDisposeResources() { @@ -24,19 +288,22 @@ public void FactoriesEmitExpectedFiniteSequencesAndDisposeResources() var completed = 0; var disposed = 0; - Signal.Range(2, 3) - .Concat(Signal.Repeat(9, 2)) - .Concat(Signal.Unfold(1, state => state <= 3, state => state + 1, state => state * 10)) + Signal.Range(SecondValue, RetrySuccessAttempt) + .Concat(Signal.Repeat(RepeatValue, SecondValue)) + .Concat(Signal.Unfold(FirstValue, state => state <= RetrySuccessAttempt, state => state + FirstValue, state => state * ProjectionMultiplier)) .Concat(Signal.Use( () => Disposable.Create(() => disposed++), - _ => Signal.FromEnumerable([7, 8]))) + _ => Signal.FromEnumerable([ResourceFirstValue, ResourceSecondValue]))) .Subscribe(values.Add, ex => throw ex, () => completed++); - Assert.Equal(new[] { 2, 3, 4, 9, 9, 10, 20, 30, 7, 8 }, values); + Assert.Equal(FiniteFactoryExpected, values); Assert.Equal(1, completed); Assert.Equal(1, disposed); } + /// + /// Verifies unary transformation, filtering, aggregation, and materialization operators. + /// [Test] public void UnaryOperatorsTransformFilterAggregateAndMaterialize() { @@ -45,25 +312,28 @@ public void UnaryOperatorsTransformFilterAggregateAndMaterialize() var terminal = new List(); var taps = 0; - Signal.FromEnumerable([1, 2, 2, 3, 4]) - .Map(value => value * 2) - .Keep(value => value >= 4) + Signal.FromEnumerable([FirstValue, SecondValue, SecondValue, RetrySuccessAttempt, FourthValue]) + .Map(value => value * SecondValue) + .Keep(value => value >= FourthValue) .DistinctUntilChanged() .Tap(_ => taps++) .Scan(0, (sum, value) => sum + value) - .Take(3) + .Take(RetrySuccessAttempt) .Sparkify() .Subscribe(sparks.Add); Signal.FromEnumerable(sparks).Unspark().Subscribe(values.Add); - Signal.FromEnumerable([1, 2, 3, 4]).Fold(0, (sum, value) => sum + value).Subscribe(terminal.Add); + Signal.FromEnumerable(FourItemExpected).Fold(0, (sum, value) => sum + value).Subscribe(terminal.Add); - Assert.Equal(new[] { 4, 10, 18 }, values); - Assert.Equal(new[] { 10 }, terminal); - Assert.Equal(3, taps); + Assert.Equal(UnaryExpected, values); + Assert.Equal(new[] { ProjectedFirstValue }, terminal); + Assert.Equal(RetrySuccessAttempt, taps); Assert.Equal(SparkKind.OnCompleted, sparks[^1].Kind); } + /// + /// Verifies cold select and where operators detach from their source when disposed. + /// [Test] public void SelectAndWhereStayColdUntilSubscribedAndDetachOnDispose() { @@ -79,18 +349,21 @@ public void SelectAndWhereStayColdUntilSubscribedAndDetachOnDispose() var filteredSubscription = filtered.Subscribe(filteredValues.Add); Assert.True(source.HasObservers); - source.OnNext(1); - source.OnNext(2); + source.OnNext(FirstValue); + source.OnNext(SecondValue); selectedSubscription.Dispose(); filteredSubscription.Dispose(); Assert.False(source.HasObservers); - source.OnNext(3); + source.OnNext(RetrySuccessAttempt); - Assert.Equal(new[] { 2, 3 }, selectedValues); - Assert.Equal(new[] { 2 }, filteredValues); + Assert.Equal(SelectedAfterDisposeExpected, selectedValues); + Assert.Equal(SingleSecondValueExpected, filteredValues); } + /// + /// Verifies merge, concat, zip, and combine-latest ordering semantics. + /// [Test] public void CombiningOperatorsPreserveCoreOrderingSemantics() { @@ -99,17 +372,20 @@ public void CombiningOperatorsPreserveCoreOrderingSemantics() var zipped = new List(); var latest = new List(); - Signal.Merge(Signal.FromEnumerable([1, 2]), Signal.FromEnumerable([3, 4])).Subscribe(merged.Add); - Signal.Concat(Signal.FromEnumerable([1, 2]), Signal.FromEnumerable([3, 4])).Subscribe(concatenated.Add); - Signal.Zip(Signal.FromEnumerable([1, 2]), Signal.FromEnumerable([10, 20]), (left, right) => left + right).Subscribe(zipped.Add); - Signal.CombineLatest(Signal.FromEnumerable([1, 2]), Signal.FromEnumerable(["a", "b"]), (left, right) => left + right).Subscribe(latest.Add); + Signal.Merge(Signal.FromEnumerable(TakeWhileExpected), Signal.FromEnumerable([RetrySuccessAttempt, FourthValue])).Subscribe(merged.Add); + Signal.Concat(Signal.FromEnumerable(TakeWhileExpected), Signal.FromEnumerable([RetrySuccessAttempt, FourthValue])).Subscribe(concatenated.Add); + Signal.Zip(Signal.FromEnumerable(TakeWhileExpected), Signal.FromEnumerable([ProjectedFirstValue, ProjectedThirdValue]), (left, right) => left + right).Subscribe(zipped.Add); + Signal.CombineLatest(Signal.FromEnumerable(TakeWhileExpected), Signal.FromEnumerable(["a", "b"]), (left, right) => left + right).Subscribe(latest.Add); - Assert.Equal(new[] { 1, 2, 3, 4 }, merged); - Assert.Equal(new[] { 1, 2, 3, 4 }, concatenated); - Assert.Equal(new[] { 11, 22 }, zipped); - Assert.Equal(new[] { "2a", "2b" }, latest); + Assert.Equal(FourItemExpected, merged); + Assert.Equal(FourItemExpected, concatenated); + Assert.Equal(ZippedExpected, zipped); + Assert.Equal(LatestExpected, latest); } + /// + /// Verifies retry resubscribes until a deferred source succeeds. + /// [Test] public void RetryResubscribesUntilSuccess() { @@ -119,15 +395,21 @@ public void RetryResubscribesUntilSuccess() Signal.Defer(() => { attempts++; - return attempts < 3 ? Signal.Throw(new InvalidOperationException("try again")) : Signal.Return(42); + return attempts < RetrySuccessAttempt + ? Signal.Throw(new InvalidOperationException("try again")) + : Signal.Return(RetryResult); }) - .Retry(3) + .Retry(RetrySuccessAttempt) .Subscribe(values.Add); - Assert.Equal(3, attempts); - Assert.Equal(new[] { 42 }, values); + Assert.Equal(RetrySuccessAttempt, attempts); + Assert.Equal(RetryResultExpected, values); } + /// + /// Verifies async enumerable subscriptions cancel and dispose the enumerator. + /// + /// A task that completes when the asynchronous assertions have run. [Test] public async Task AsyncEnumerableFactoryCancelsEnumeratorOnDispose() { @@ -138,9 +420,9 @@ async IAsyncEnumerable Values([EnumeratorCancellation] CancellationToken to { try { - yield return 1; - await Task.Delay(5000, token); - yield return 2; + yield return FirstValue; + await Task.Delay(AsyncEnumeratorDelayMilliseconds, token); + yield return SecondValue; } finally { @@ -149,14 +431,17 @@ async IAsyncEnumerable Values([EnumeratorCancellation] CancellationToken to } var subscription = Signal.FromAsyncEnumerable(Values()).Subscribe(values.Add, _ => { }, () => { }); - await Task.Delay(50); + await Task.Delay(AsyncEnumeratorSettleMilliseconds); subscription.Dispose(); - await Task.Delay(50); + await Task.Delay(AsyncEnumeratorSettleMilliseconds); - Assert.Equal(new[] { 1 }, values); + Assert.Equal(AsyncEnumerableBeforeDisposeExpected, values); Assert.True(disposed); } + /// + /// Verifies timer factories use an injected virtual sequencer. + /// [Test] public void TimeFactoriesUseInjectedScheduler() { @@ -164,65 +449,37 @@ public void TimeFactoriesUseInjectedScheduler() var after = new List(); var every = new List(); - Signal.After(TimeSpan.FromTicks(5), clock).Subscribe(after.Add); - var subscription = Signal.Every(TimeSpan.FromTicks(3), clock).Subscribe(every.Add); + Signal.After(TimeSpan.FromTicks(AfterTicks), clock).Subscribe(after.Add); + var subscription = Signal.Every(TimeSpan.FromTicks(EveryTicks), clock).Subscribe(every.Add); - clock.AdvanceBy(TimeSpan.FromTicks(4)); + clock.AdvanceBy(TimeSpan.FromTicks(InitialAdvanceTicks)); Assert.Equal(0, after.Count); - Assert.Equal(new[] { 0L }, every); + Assert.Equal(OneShotTimerExpected, every); - clock.AdvanceBy(TimeSpan.FromTicks(1)); - Assert.Equal(new[] { 0L }, after); + clock.AdvanceBy(TimeSpan.FromTicks(FirstValue)); + Assert.Equal(OneShotTimerExpected, after); - clock.AdvanceBy(TimeSpan.FromTicks(4)); + clock.AdvanceBy(TimeSpan.FromTicks(InitialAdvanceTicks)); subscription.Dispose(); - clock.AdvanceBy(TimeSpan.FromTicks(10)); - Assert.Equal(new[] { 0L, 1L, 2L }, every); + clock.AdvanceBy(TimeSpan.FromTicks(FinalAdvanceTicks)); + Assert.Equal(EveryExpected, every); } + /// + /// Verifies additional factory and unary operator parity helpers. + /// [Test] public void AdditionalFactoriesAndUnaryOperatorsCoverCommonParitySurface() { - var leadAppend = new List(); - var ignored = new List(); - var distinctBy = new List(); - var takeWhile = new List(); - var skipWhile = new List(); - var defaulted = new List(); - var count = new List(); - var any = new List(); - var all = new List(); - var contains = new List(); - var isEmpty = new List(); - var selected = new List(); - - Signal.FromEnumerable([2, 3]).Lead(1).Append(4).Prepend(0).Subscribe(leadAppend.Add); - Signal.FromEnumerable([1, 2, 3]).IgnoreValues().Subscribe(ignored.Add); - Signal.FromEnumerable([11, 12, 21, 22]).DistinctBy(value => value / 10).Subscribe(distinctBy.Add); - Signal.FromEnumerable([1, 2, 3, 1]).TakeWhile(value => value < 3).Subscribe(takeWhile.Add); - Signal.FromEnumerable([1, 2, 3, 1]).SkipWhile(value => value < 3).Subscribe(skipWhile.Add); - Signal.Empty().DefaultIfEmpty(42).Subscribe(defaulted.Add); - Signal.FromEnumerable([1, 2, 3]).Count().Subscribe(count.Add); - Signal.FromEnumerable([1, 2, 3]).Any(value => value == 2).Subscribe(any.Add); - Signal.FromEnumerable([2, 4, 6]).All(value => value % 2 == 0).Subscribe(all.Add); - Signal.FromEnumerable([2, 4, 6]).Contains(4).Subscribe(contains.Add); - Signal.Empty().IsEmpty().Subscribe(isEmpty.Add); - Signal.FromEnumerable([1, 2]).Bind(value => Signal.Range(value * 10, 2)).Subscribe(selected.Add); - - Assert.Equal(new[] { 0, 1, 2, 3, 4 }, leadAppend); - Assert.Equal(0, ignored.Count); - Assert.Equal(new[] { 11, 21 }, distinctBy); - Assert.Equal(new[] { 1, 2 }, takeWhile); - Assert.Equal(new[] { 3, 1 }, skipWhile); - Assert.Equal(new[] { 42 }, defaulted); - Assert.Equal(new[] { 3 }, count); - Assert.Equal(new[] { true }, any); - Assert.Equal(new[] { true }, all); - Assert.Equal(new[] { true }, contains); - Assert.Equal(new[] { true }, isEmpty); - Assert.Equal(new[] { 10, 11, 20, 21 }, selected); + VerifySequenceBoundaryOperators(); + VerifyBooleanTerminalOperators(); + VerifySelectionAndProjectionOperators(); } + /// + /// Verifies System.Reactive-style aliases intended to ease migration. + /// + /// A task that completes when the asynchronous assertions have run. [Test] public async Task SystemReactiveNamedAliasesCoverMigrationConvenienceSurface() { @@ -233,38 +490,34 @@ public async Task SystemReactiveNamedAliasesCoverMigrationConvenienceSurface() var clock = new TestClock(); var source = new Signal(); - Signal.FromEnumerable([2, 3]) - .StartWith([0, 1]) + Signal.FromEnumerable([SecondValue, RetrySuccessAttempt]) + .StartWith(0, FirstValue) .Do(sideEffects.Add) .AsObservable() .Subscribe(values.Add); Signal.Throw(new InvalidOperationException("recover")) - .Catch(_ => Signal.Return(42)) + .Catch(_ => Signal.Return(RetryResult)) .Subscribe(recovered.Add); source.ObserveOn(clock).Subscribe(observed.Add); - source.OnNext(7); + source.OnNext(ResourceFirstValue); - Assert.Equal(new[] { 0, 1, 2, 3 }, values); + Assert.Equal(SystemReactiveNamedAliasExpected, values); Assert.Equal((IEnumerable)values, sideEffects); - Assert.Equal(new[] { 42 }, recovered); + Assert.Equal(RetryResultExpected, recovered); Assert.Equal(0, observed.Count); clock.Start(); - Assert.Equal(new[] { 7 }, observed); + Assert.Equal(ObservedResourceExpected, observed); - var converted = new[] { 4, 5 }.ToObservable(); - var last = await converted.ToTask(); - var first = await Signal.FromEnumerable([9, 10]).FirstAsync().ToTask(); - var started = await Signal.Start(() => 11, Sequencer.CurrentThread).ToTask(); - - Assert.Equal(5, last); - Assert.Equal(9, first); - Assert.Equal(11, started); + await VerifyTaskAliasOperators(); } + /// + /// Verifies boundary and latest-value operators with virtual time. + /// [Test] public void BoundaryAndLatestOperatorsUseVirtualTimeAndCompletionSemantics() { @@ -276,40 +529,125 @@ public void BoundaryAndLatestOperatorsUseVirtualTimeAndCompletionSemantics() var latest = new List(); var forkJoined = new List(); - source.Throttle(TimeSpan.FromTicks(5), clock).Subscribe(throttled.Add); - source.Sample(TimeSpan.FromTicks(4), clock).Subscribe(sampled.Add); + source.Throttle(TimeSpan.FromTicks(AfterTicks), clock).Subscribe(throttled.Add); + source.Sample(TimeSpan.FromTicks(InitialAdvanceTicks), clock).Subscribe(sampled.Add); source.TimeInterval(clock).Subscribe(intervals.Add); - source.OnNext(1); - clock.AdvanceBy(TimeSpan.FromTicks(2)); - source.OnNext(2); - clock.AdvanceBy(TimeSpan.FromTicks(4)); - source.OnNext(3); - clock.AdvanceBy(TimeSpan.FromTicks(5)); + source.OnNext(FirstValue); + clock.AdvanceBy(TimeSpan.FromTicks(SecondValue)); + source.OnNext(SecondValue); + clock.AdvanceBy(TimeSpan.FromTicks(InitialAdvanceTicks)); + source.OnNext(RetrySuccessAttempt); + clock.AdvanceBy(TimeSpan.FromTicks(AfterTicks)); source.OnCompleted(); - clock.AdvanceBy(TimeSpan.FromTicks(4)); + clock.AdvanceBy(TimeSpan.FromTicks(InitialAdvanceTicks)); - Signal.FromEnumerable([1, 2]).ZipLatest(Signal.FromEnumerable(["a", "b"]), (left, right) => left + right).Subscribe(latest.Add); - Signal.ForkJoin(Signal.FromEnumerable([1, 2]), Signal.FromEnumerable([10, 20]), (left, right) => left + right).Subscribe(forkJoined.Add); + Signal.FromEnumerable(TakeWhileExpected).ZipLatest(Signal.FromEnumerable(["a", "b"]), (left, right) => left + right).Subscribe(latest.Add); + Signal.ForkJoin(Signal.FromEnumerable(TakeWhileExpected), Signal.FromEnumerable([ProjectedFirstValue, ProjectedThirdValue]), (left, right) => left + right).Subscribe(forkJoined.Add); - Assert.Equal(new[] { 3 }, throttled); - Assert.Equal(new[] { 2, 3 }, sampled); + Assert.Equal(ThrottleExpected, throttled); + Assert.Equal(SampleExpected, sampled); Assert.Equal(TimeSpan.Zero, intervals[0].Interval); - Assert.Equal(TimeSpan.FromTicks(2), intervals[1].Interval); - Assert.Equal(TimeSpan.FromTicks(4), intervals[2].Interval); - Assert.Equal(new[] { "2a", "2b" }, latest); - Assert.Equal(new[] { 22 }, forkJoined); + Assert.Equal(TimeSpan.FromTicks(SecondValue), intervals[1].Interval); + Assert.Equal(TimeSpan.FromTicks(InitialAdvanceTicks), intervals[ThirdIntervalIndex].Interval); + Assert.Equal(LatestExpected, latest); + Assert.Equal(ForkJoinExpected, forkJoined); } + /// + /// Verifies terminal task operators complete with their expected values. + /// + /// A task that completes when the asynchronous assertions have run. [Test] public async Task TerminalTaskOperatorsCompleteWithExpectedSemantics() { - var first = await Signal.FromEnumerable([3, 4]).FirstAsync(); - var collected = await Signal.FromEnumerable([1, 2, 3]).CollectArrayAsync(); - var none = await Signal.Empty().FirstOrDefaultAsync(42); + var first = await Signal.FromEnumerable([RetrySuccessAttempt, FourthValue]).FirstAsync(); + var collected = await Signal.FromEnumerable([FirstValue, SecondValue, RetrySuccessAttempt]).CollectArrayAsync(); + var none = await Signal.Empty().FirstOrDefaultAsync(RetryResult); + + Assert.Equal(RetrySuccessAttempt, first); + Assert.Equal(CollectedExpected, (IEnumerable)collected); + Assert.Equal(RetryResult, none); + } + + /// + /// Verifies sequence boundary operators. + /// + private static void VerifySequenceBoundaryOperators() + { + var leadAppend = new List(); + var ignored = new List(); + var distinctBy = new List(); + var takeWhile = new List(); + var skipWhile = new List(); + var defaulted = new List(); + + Signal.FromEnumerable([SecondValue, RetrySuccessAttempt]).Lead(FirstValue).Append(FourthValue).Prepend(0).Subscribe(leadAppend.Add); + Signal.FromEnumerable([FirstValue, SecondValue, RetrySuccessAttempt]).IgnoreValues().Subscribe(ignored.Add); + Signal.FromEnumerable([ProjectedSecondValue, ProjectedSecondBucketPeerValue, ProjectedFourthValue, SecondZipResult]) + .DistinctBy(value => value / ProjectionMultiplier) + .Subscribe(distinctBy.Add); + Signal.FromEnumerable([FirstValue, SecondValue, RetrySuccessAttempt, FirstValue]).TakeWhile(value => value < RetrySuccessAttempt).Subscribe(takeWhile.Add); + Signal.FromEnumerable([FirstValue, SecondValue, RetrySuccessAttempt, FirstValue]).SkipWhile(value => value < RetrySuccessAttempt).Subscribe(skipWhile.Add); + Signal.Empty().DefaultIfEmpty(RetryResult).Subscribe(defaulted.Add); + + Assert.Equal(LeadAppendExpected, leadAppend); + Assert.Equal(0, ignored.Count); + Assert.Equal(DistinctByExpected, distinctBy); + Assert.Equal(TakeWhileExpected, takeWhile); + Assert.Equal(SkipWhileExpected, skipWhile); + Assert.Equal(RetryResultExpected, defaulted); + } + + /// + /// Verifies boolean terminal operators. + /// + private static void VerifyBooleanTerminalOperators() + { + var count = new List(); + var any = new List(); + var all = new List(); + var contains = new List(); + var isEmpty = new List(); + + Signal.FromEnumerable([FirstValue, SecondValue, RetrySuccessAttempt]).Count().Subscribe(count.Add); + Signal.FromEnumerable([FirstValue, SecondValue, RetrySuccessAttempt]).Any(value => value == SecondValue).Subscribe(any.Add); + Signal.FromEnumerable([SecondValue, FourthValue, SixthValue]).All(value => value % SecondValue == 0).Subscribe(all.Add); + Signal.FromEnumerable([SecondValue, FourthValue, SixthValue]).Contains(FourthValue).Subscribe(contains.Add); + Signal.Empty().IsEmpty().Subscribe(isEmpty.Add); + + Assert.Equal(new[] { RetrySuccessAttempt }, count); + Assert.Equal(TrueExpected, any); + Assert.Equal(TrueExpected, all); + Assert.Equal(TrueExpected, contains); + Assert.Equal(TrueExpected, isEmpty); + } + + /// + /// Verifies selection and projection operators. + /// + private static void VerifySelectionAndProjectionOperators() + { + var selected = new List(); + + Signal.FromEnumerable(TakeWhileExpected).Bind(value => Signal.Range(value * ProjectionMultiplier, SecondValue)).Subscribe(selected.Add); + + Assert.Equal(SelectedProjectionExpected, selected); + } + + /// + /// Verifies task-based alias operators. + /// + /// A task that completes when assertions have run. + private static async Task VerifyTaskAliasOperators() + { + var converted = new[] { 4, AfterTicks }.ToObservable(); + var last = await converted.ToTask(); + var first = await Signal.FromEnumerable([RepeatValue, ProjectionMultiplier]).FirstAsync().ToTask(); + var started = await Signal.Start(() => ProjectedSecondValue, Sequencer.CurrentThread).ToTask(); - Assert.Equal(3, first); - Assert.Equal([1, 2, 3], (IEnumerable)collected); - Assert.Equal(42, none); + Assert.Equal(AfterTicks, last); + Assert.Equal(RepeatValue, first); + Assert.Equal(ProjectedSecondValue, started); } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/ReactiveUI.Primitives.Tests.csproj b/src/tests/ReactiveUI.Primitives.Tests/ReactiveUI.Primitives.Tests.csproj index 7f2329e..da37c0c 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ReactiveUI.Primitives.Tests.csproj +++ b/src/tests/ReactiveUI.Primitives.Tests/ReactiveUI.Primitives.Tests.csproj @@ -6,8 +6,6 @@ false preview Exe - false - false $(NoWarn);CS8625;CS8634 false $(MSBuildProjectDirectory)\TestResults\coverage\ diff --git a/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs index d48ca97..0f82591 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs @@ -14,34 +14,39 @@ namespace ReactiveUI.Primitives.Tests; /// public class ReplaySignalTests { + /// + /// Value emitted while checking observer state. + /// + private const int ReplayValue = 42; + /// /// Constructors the argument checking. /// [Test] public void Constructor_ArgumentChecking() { - Assert.Throws(() => new ReplaySignal(-1)); - Assert.Throws(() => new ReplaySignal(-1, EmptySequencer.Instance)); - Assert.Throws(() => new ReplaySignal(-1, TimeSpan.Zero)); - Assert.Throws(() => new ReplaySignal(-1, TimeSpan.Zero, EmptySequencer.Instance)); + Assert.Throws(() => CreateAndDispose(() => new ReplaySignal(-1))); + Assert.Throws(() => CreateAndDispose(() => new ReplaySignal(-1, EmptySequencer.Instance))); + Assert.Throws(() => CreateAndDispose(() => new ReplaySignal(-1, TimeSpan.Zero))); + Assert.Throws(() => CreateAndDispose(() => new ReplaySignal(-1, TimeSpan.Zero, EmptySequencer.Instance))); - Assert.Throws(() => new ReplaySignal(TimeSpan.FromTicks(-1))); - Assert.Throws(() => new ReplaySignal(TimeSpan.FromTicks(-1), EmptySequencer.Instance)); - Assert.Throws(() => new ReplaySignal(0, TimeSpan.FromTicks(-1))); - Assert.Throws(() => new ReplaySignal(0, TimeSpan.FromTicks(-1), EmptySequencer.Instance)); + Assert.Throws(() => CreateAndDispose(() => new ReplaySignal(TimeSpan.FromTicks(-1)))); + Assert.Throws(() => CreateAndDispose(() => new ReplaySignal(TimeSpan.FromTicks(-1), EmptySequencer.Instance))); + Assert.Throws(() => CreateAndDispose(() => new ReplaySignal(0, TimeSpan.FromTicks(-1)))); + Assert.Throws(() => CreateAndDispose(() => new ReplaySignal(0, TimeSpan.FromTicks(-1), EmptySequencer.Instance))); - Assert.Throws(() => new ReplaySignal(null!)); - Assert.Throws(() => new ReplaySignal(0, null!)); - Assert.Throws(() => new ReplaySignal(TimeSpan.Zero, null!)); - Assert.Throws(() => new ReplaySignal(0, TimeSpan.Zero, null!)); + Assert.Throws(() => CreateAndDispose(() => new ReplaySignal(null!))); + Assert.Throws(() => CreateAndDispose(() => new ReplaySignal(0, null!))); + Assert.Throws(() => CreateAndDispose(() => new ReplaySignal(TimeSpan.Zero, null!))); + Assert.Throws(() => CreateAndDispose(() => new ReplaySignal(0, TimeSpan.Zero, null!))); // zero allowed - new ReplaySignal(0); - new ReplaySignal(TimeSpan.Zero); - new ReplaySignal(0, TimeSpan.Zero); - new ReplaySignal(0, EmptySequencer.Instance); - new ReplaySignal(TimeSpan.Zero, EmptySequencer.Instance); - new ReplaySignal(0, TimeSpan.Zero, EmptySequencer.Instance); + CreateAndDispose(() => new ReplaySignal(0)); + CreateAndDispose(() => new ReplaySignal(TimeSpan.Zero)); + CreateAndDispose(() => new ReplaySignal(0, TimeSpan.Zero)); + CreateAndDispose(() => new ReplaySignal(0, EmptySequencer.Instance)); + CreateAndDispose(() => new ReplaySignal(TimeSpan.Zero, EmptySequencer.Instance)); + CreateAndDispose(() => new ReplaySignal(0, TimeSpan.Zero, EmptySequencer.Instance)); } /// @@ -140,6 +145,19 @@ public void Subscribe_ArgumentChecking() Assert.Throws(() => new ReplaySignal(EmptySequencer.Instance).Subscribe(null!)); } + /// + /// Creates a replay signal and disposes it immediately. + /// + /// Factory used to create the signal. + private static void CreateAndDispose(Func> factory) + { + using var signal = factory(); + } + + /// + /// Verifies observer state when the source is disposed before subscription disposal. + /// + /// Signal to test. private static void HasObservers_Dispose1Impl(ReplaySignal s) { Assert.False(s.HasObservers); @@ -158,6 +176,10 @@ private static void HasObservers_Dispose1Impl(ReplaySignal s) Assert.True(s.IsDisposed); } + /// + /// Verifies observer state when the subscription is disposed before the source. + /// + /// Signal to test. private static void HasObservers_Dispose2Impl(ReplaySignal s) { Assert.False(s.HasObservers); @@ -176,6 +198,10 @@ private static void HasObservers_Dispose2Impl(ReplaySignal s) Assert.True(s.IsDisposed); } + /// + /// Verifies observer state when the source is disposed without subscribers. + /// + /// Signal to test. private static void HasObservers_Dispose3Impl(ReplaySignal s) { Assert.False(s.HasObservers); @@ -186,34 +212,46 @@ private static void HasObservers_Dispose3Impl(ReplaySignal s) Assert.True(s.IsDisposed); } + /// + /// Verifies observer state after completion. + /// + /// Signal to test. private static void HasObservers_OnCompletedImpl(ReplaySignal s) { Assert.False(s.HasObservers); - var d = s.Subscribe(_ => { }); + using var subscription = s.Subscribe(_ => { }); Assert.True(s.HasObservers); - s.OnNext(42); + s.OnNext(ReplayValue); Assert.True(s.HasObservers); s.OnCompleted(); Assert.False(s.HasObservers); } + /// + /// Verifies observer state after error. + /// + /// Signal to test. private static void HasObservers_OnErrorImpl(ReplaySignal s) { Assert.False(s.HasObservers); - var d = s.Subscribe(_ => { }, _ => { }); + using var subscription = s.Subscribe(_ => { }, _ => { }); Assert.True(s.HasObservers); - s.OnNext(42); + s.OnNext(ReplayValue); Assert.True(s.HasObservers); - s.OnError(new Exception()); + s.OnError(new InvalidOperationException()); Assert.False(s.HasObservers); } + /// + /// Verifies observer state as subscriptions are added and removed. + /// + /// Signal to test. private static void HasObserversImpl(ReplaySignal s) { Assert.False(s.HasObservers); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalCreateTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalCreateTests.cs index a57fd51..2926e83 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalCreateTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalCreateTests.cs @@ -16,6 +16,11 @@ namespace ReactiveUI.Primitives.Tests; /// public class SignalCreateTests { + /// + /// Value emitted by create-signal tests. + /// + private const int CreatedValue = 42; + /// /// Creates the argument checking. /// @@ -35,7 +40,7 @@ public void Create_NullCoalescingAction() { var xs = Signal.Create(o => { - o.OnNext(42); + o.OnNext(CreatedValue); return Disposable.Create(default!); }); @@ -43,7 +48,7 @@ public void Create_NullCoalescingAction() var d = xs.Subscribe(lst.Add); d.Dispose(); - Assert.True(lst.SequenceEqual([42])); + Assert.True(lst.SequenceEqual([CreatedValue])); } /// @@ -69,7 +74,7 @@ public void Create_ObserverThrows() Assert.Throws(() => Signal.Create(o => { - o.OnError(new Exception()); + o.OnError(new InvalidOperationException("source")); return Disposable.Empty; }).Subscribe(x => { }, ex => throw new InvalidOperationException())); Assert.Throws(() => @@ -103,7 +108,7 @@ public void CreateWithDisposable_NullCoalescingAction() { var xs = Signal.Create(o => { - o.OnNext(42); + o.OnNext(CreatedValue); return default!; }); @@ -111,7 +116,7 @@ public void CreateWithDisposable_NullCoalescingAction() var d = xs.Subscribe(lst.Add); d.Dispose(); - Assert.True(lst.SequenceEqual([42])); + Assert.True(lst.SequenceEqual([CreatedValue])); } /// diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs index b21c539..59342c8 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using ReactiveUI.Primitives.Signals; @@ -17,6 +16,76 @@ namespace ReactiveUI.Primitives.Tests; /// public class SignalFromTaskTest { + /// + /// Maximum wait for cancellation callbacks that are intentionally driven by timers. + /// + private const int CancellationCallbackTimeoutMilliseconds = 15000; + + /// + /// Delay used before checking that a task has started. + /// + private const int InitialDelayMilliseconds = 500; + + /// + /// Delay before token cancellation is requested. + /// + private const int TokenCancellationDelayMilliseconds = 1000; + + /// + /// Delay used to simulate cancellation cleanup. + /// + private const int CleanupDelayMilliseconds = 5000; + + /// + /// Delay used to wait for cancellation cleanup to finish. + /// + private const int CancellationWaitDelayMilliseconds = 6000; + + /// + /// Delay used by the command body. + /// + private const int CommandDelayMilliseconds = 10000; + + /// + /// Delay used to wait for normal command completion. + /// + private const int CompletionWaitDelayMilliseconds = 11000; + + /// + /// Exception message used by user exception tests. + /// + private const string BreakExecutionMessage = "break execution"; + + /// + /// Status text recorded when a command starts. + /// + private const string StartedCommand = "started command"; + + /// + /// Status text recorded when cancellation cleanup starts. + /// + private const string StartingCancellingCommand = "starting cancelling command"; + + /// + /// Status text recorded when cancellation cleanup finishes. + /// + private const string FinishedCancellingCommand = "finished cancelling command"; + + /// + /// Status text recorded when the command completes normally. + /// + private const string FinishedCommandNormally = "finished command Normally"; + + /// + /// Status text recorded by the exception handler. + /// + private const string ExceptionShouldBeHere = "Exception Should Be here"; + + /// + /// Status text recorded by the finalizer callback. + /// + private const string ShouldAlwaysComeHere = "Should always come here."; + /// /// Signals from task handles user exceptions. /// @@ -24,59 +93,46 @@ public class SignalFromTaskTest [Test] public async Task SignalFromTaskHandlesUserExceptions() { - var statusTrail = new List<(int, string)>(); + var statusTrail = new StatusTrail(); var position = 0; - Exception? exception = null; var fixture = Signal.FromTask( - async (cts) => + async cts => { - statusTrail.Add((position++, "started command")); - await Task.Delay(10000, cts.Token).HandleCancellation(async () => - { - // User Handles cancellation. - statusTrail.Add((position++, "starting cancelling command")); - - // dummy cleanup - await Task.Delay(5000, CancellationToken.None).ConfigureAwait(false); - statusTrail.Add((position++, "finished cancelling command")); - }).ConfigureAwait(true); + RecordStatus(statusTrail, ref position, StartedCommand); + await Task.Delay(CommandDelayMilliseconds, cts.Token) + .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)) + .ConfigureAwait(true); if (!cts.IsCancellationRequested) { - statusTrail.Add((position++, "finished command Normally")); + RecordStatus(statusTrail, ref position, FinishedCommandNormally); } - throw new Exception("break execution"); + throw new InvalidOperationException(BreakExecutionMessage); }).Catch( ex => { - exception = ex; - statusTrail.Add((position++, "Exception Should Be here")); + RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); return Signal.Throw(ex); - }).Finally(() => statusTrail.Add((position++, "Should always come here."))); + }).Finally(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); var result = false; - var cancel = fixture.Subscribe(_ => result = true); - await Task.Delay(500).ConfigureAwait(true); + using var subscription = fixture.Subscribe(_ => result = true); + await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); - Assert.Contains("started command", statusTrail.Select(x => x.Item2)); + Assert.Contains(StartedCommand, StatusMessages(statusTrail)); - await Task.Delay(10000).ConfigureAwait(true); - cancel.Dispose(); + await Task.Delay(CommandDelayMilliseconds).ConfigureAwait(true); + subscription.Dispose(); - // Wait 6000 ms to allow execution and cleanup to complete - await Task.Delay(6000).ConfigureAwait(false); + await Task.Delay(CancellationWaitDelayMilliseconds).ConfigureAwait(false); - Assert.DoesNotContain("starting cancelling command", statusTrail.Select(x => x.Item2)); - Assert.Contains("Should always come here.", statusTrail.Select(x => x.Item2)); - Assert.DoesNotContain("finished cancelling command", statusTrail.Select(x => x.Item2)); - Assert.Contains("Exception Should Be here", statusTrail.Select(x => x.Item2)); - Assert.Contains("finished command Normally", statusTrail.Select(x => x.Item2)); + Assert.DoesNotContain(StartingCancellingCommand, StatusMessages(statusTrail)); + Assert.Contains(ShouldAlwaysComeHere, StatusMessages(statusTrail)); + Assert.DoesNotContain(FinishedCancellingCommand, StatusMessages(statusTrail)); + Assert.Contains(ExceptionShouldBeHere, StatusMessages(statusTrail)); + Assert.Contains(FinishedCommandNormally, StatusMessages(statusTrail)); Assert.False(result); - //// (0, "started command") - //// (1, "finished command Normally") - //// (2, "Exception Should Be here") - //// (3, "Should always come here.") } /// @@ -86,56 +142,43 @@ await Task.Delay(10000, cts.Token).HandleCancellation(async () => [Test] public async Task SignalFromTaskHandlesCancellation() { - var statusTrail = new List<(int, string)>(); + var statusTrail = new StatusTrail(); var position = 0; - Exception? exception = null; var fixture = Signal.FromTask( - async (cts) => - { - statusTrail.Add((position++, "started command")); - await Task.Delay(10000, cts.Token).HandleCancellation(async () => - { - // User Handles cancellation. - statusTrail.Add((position++, "starting cancelling command")); - - // dummy cleanup - await Task.Delay(5000, CancellationToken.None).ConfigureAwait(false); - statusTrail.Add((position++, "finished cancelling command")); - }).ConfigureAwait(true); + async cts => + { + RecordStatus(statusTrail, ref position, StartedCommand); + await Task.Delay(CommandDelayMilliseconds, cts.Token) + .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)) + .ConfigureAwait(true); - if (!cts.IsCancellationRequested) - { - statusTrail.Add((position++, "finished command Normally")); - } + if (!cts.IsCancellationRequested) + { + RecordStatus(statusTrail, ref position, FinishedCommandNormally); + } - return RxVoid.Default; - }).Catch( + return RxVoid.Default; + }).Catch( ex => { - exception = ex; - statusTrail.Add((position++, "Exception Should Be here")); + RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); return Signal.Throw(ex); - }).Finally(() => statusTrail.Add((position++, "Should always come here."))); + }).Finally(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); var result = false; - var cancel = fixture.Subscribe(_ => result = true); - await Task.Delay(500).ConfigureAwait(true); + using var subscription = fixture.Subscribe(_ => result = true); + await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); - Assert.Contains("started command", statusTrail.Select(x => x.Item2)); - cancel.Dispose(); + Assert.Contains(StartedCommand, StatusMessages(statusTrail)); + subscription.Dispose(); - // Wait 6000 ms to allow execution and cleanup to complete - await Task.Delay(6000).ConfigureAwait(false); + await Task.Delay(CancellationWaitDelayMilliseconds).ConfigureAwait(false); - Assert.Contains("starting cancelling command", statusTrail.Select(x => x.Item2)); - Assert.Contains("Should always come here.", statusTrail.Select(x => x.Item2)); - Assert.Contains("finished cancelling command", statusTrail.Select(x => x.Item2)); - Assert.DoesNotContain("finished command Normally", statusTrail.Select(x => x.Item2)); + Assert.Contains(StartingCancellingCommand, StatusMessages(statusTrail)); + Assert.Contains(ShouldAlwaysComeHere, StatusMessages(statusTrail)); + Assert.Contains(FinishedCancellingCommand, StatusMessages(statusTrail)); + Assert.DoesNotContain(FinishedCommandNormally, StatusMessages(statusTrail)); Assert.False(result); - //// (0, "started command") - //// (1, "starting cancelling command") - //// (2, "Should always come here.") - //// (3, "finished cancelling command") } /// @@ -145,62 +188,58 @@ await Task.Delay(10000, cts.Token).HandleCancellation(async () => [Test] public async Task SignalFromTaskHandlesTokenCancellation() { - var statusTrail = new List<(int, string)>(); + var statusTrail = new StatusTrail(); + var cleanupCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finallyCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var position = 0; - Exception? exception = null; var fixture = Signal.FromTask( - async (cts) => + async cts => { - statusTrail.Add((position++, "started command")); - await Task.Delay(1000, cts.Token).HandleCancellation(); - _ = Task.Run(async () => - { - // Wait for 1s then cancel - await Task.Delay(1000); - cts.Cancel(); - }); - await Task.Delay(5000, cts.Token).HandleCancellation(async () => - { - // User Handles cancellation. - statusTrail.Add((position++, "starting cancelling command")); - - // dummy cleanup - await Task.Delay(5000, CancellationToken.None).ConfigureAwait(false); - statusTrail.Add((position++, "finished cancelling command")); - }).ConfigureAwait(true); + RecordStatus(statusTrail, ref position, StartedCommand); + await Task.Delay(TokenCancellationDelayMilliseconds, cts.Token).HandleCancellation().ConfigureAwait(true); + + var cancellationTask = CancelAfterDelayAsync(cts); + await Task.Delay(CleanupDelayMilliseconds, cts.Token) + .HandleCancellation(() => + { + RecordCancellationCleanup(statusTrail, ref position); + cleanupCompleted.TrySetResult(); + }) + .ConfigureAwait(true); + await cancellationTask.ConfigureAwait(false); if (!cts.IsCancellationRequested) { - statusTrail.Add((position++, "finished command Normally")); + RecordStatus(statusTrail, ref position, FinishedCommandNormally); } return RxVoid.Default; }).Catch( ex => { - exception = ex; - statusTrail.Add((position++, "Exception Should Be here")); + RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); return Signal.Throw(ex); - }).Finally(() => statusTrail.Add((position++, "Should always come here."))); + }).Finally(() => + { + RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere); + finallyCompleted.TrySetResult(); + }); var result = false; - var cancel = fixture.Subscribe(_ => result = true); - await Task.Delay(500).ConfigureAwait(true); + using var subscription = fixture.Subscribe(_ => result = true); + await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); - Assert.Contains("started command", statusTrail.Select(x => x.Item2)); + Assert.Contains(StartedCommand, StatusMessages(statusTrail)); - // Wait 8000 ms to allow execution and cleanup to complete - await Task.Delay(8000).ConfigureAwait(false); + await WaitForAsync( + Task.WhenAll(cleanupCompleted.Task, finallyCompleted.Task), + CancellationCallbackTimeoutMilliseconds).ConfigureAwait(false); - Assert.Contains("starting cancelling command", statusTrail.Select(x => x.Item2)); - Assert.Contains("Should always come here.", statusTrail.Select(x => x.Item2)); - Assert.Contains("finished cancelling command", statusTrail.Select(x => x.Item2)); - Assert.DoesNotContain("finished command Normally", statusTrail.Select(x => x.Item2)); + Assert.Contains(StartingCancellingCommand, StatusMessages(statusTrail)); + Assert.Contains(ShouldAlwaysComeHere, StatusMessages(statusTrail)); + Assert.Contains(FinishedCancellingCommand, StatusMessages(statusTrail)); + Assert.DoesNotContain(FinishedCommandNormally, StatusMessages(statusTrail)); Assert.False(result); - //// (0, "started command") - //// (1, "starting cancelling command") - //// (2, "Should always come here.") - //// (3, "finished cancelling command") } /// @@ -210,42 +249,35 @@ await Task.Delay(5000, cts.Token).HandleCancellation(async () => [Test] public async Task SignalFromTaskHandlesCancellationInBase() { - var statusTrail = new List<(int, string)>(); + var statusTrail = new StatusTrail(); var position = 0; - Exception? exception = null; var fixture = Signal.FromTask( - async (cts) => + async cts => { - var ex = new Exception(); - statusTrail.Add((position++, "started command")); - await Task.Delay(10000, cts.Token).ConfigureAwait(true); + RecordStatus(statusTrail, ref position, StartedCommand); + await Task.Delay(CommandDelayMilliseconds, cts.Token).ConfigureAwait(true); if (!cts.IsCancellationRequested) { - statusTrail.Add((position++, "finished command Normally")); + RecordStatus(statusTrail, ref position, FinishedCommandNormally); } return RxVoid.Default; }).Catch( ex => { - exception = ex; - statusTrail.Add((position++, "Exception Should Be here")); + RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); return Signal.Throw(ex); - }).Finally(() => statusTrail.Add((position++, "Should always come here."))); + }).Finally(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); - var cancel = fixture.Subscribe(); - await Task.Delay(500).ConfigureAwait(true); - Assert.Contains("started command", statusTrail.Select(x => x.Item2)); - cancel.Dispose(); + using var subscription = fixture.Subscribe(); + await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); + Assert.Contains(StartedCommand, StatusMessages(statusTrail)); + subscription.Dispose(); - // Wait 5050 ms to allow execution and cleanup to complete - await Task.Delay(6000).ConfigureAwait(false); + await Task.Delay(CancellationWaitDelayMilliseconds).ConfigureAwait(false); - Assert.DoesNotContain("finished command Normally", statusTrail.Select(x => x.Item2)); - Assert.Equal("Should always come here.", statusTrail[^1].Item2); - - //// (0, "started command") - //// (1, "Should always come here.") + Assert.DoesNotContain(FinishedCommandNormally, StatusMessages(statusTrail)); + Assert.Equal(ShouldAlwaysComeHere, statusTrail.LastMessage); } /// @@ -255,56 +287,42 @@ public async Task SignalFromTaskHandlesCancellationInBase() [Test] public async Task SignalFromTaskHandlesCompletion() { - var statusTrail = new List<(int, string)>(); + var statusTrail = new StatusTrail(); var position = 0; - Exception? exception = null; var fixture = Signal.FromTask( - async (cts) => + async cts => { - statusTrail.Add((position++, "started command")); - await Task.Delay(10000, cts.Token).HandleCancellation(async () => - { - // NOT EXPECTED TO ENTER HERE - - // User Handles cancellation. - statusTrail.Add((position++, "starting cancelling command")); - - // dummy cleanup - await Task.Delay(5000, CancellationToken.None).ConfigureAwait(false); - statusTrail.Add((position++, "finished cancelling command")); - }).ConfigureAwait(true); + RecordStatus(statusTrail, ref position, StartedCommand); + await Task.Delay(CommandDelayMilliseconds, cts.Token) + .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)) + .ConfigureAwait(true); if (!cts.IsCancellationRequested) { - statusTrail.Add((position++, "finished command Normally")); + RecordStatus(statusTrail, ref position, FinishedCommandNormally); } return RxVoid.Default; }).Catch( ex => { - exception = ex; - statusTrail.Add((position++, "Exception Should Be here")); + RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); return Signal.Throw(ex); - }).Finally(() => statusTrail.Add((position++, "Should always come here."))); + }).Finally(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); var result = false; - var cancel = fixture.Subscribe(_ => result = true); - await Task.Delay(500).ConfigureAwait(true); + using var subscription = fixture.Subscribe(_ => result = true); + await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); - Assert.Contains("started command", statusTrail.Select(x => x.Item2)); + Assert.Contains(StartedCommand, StatusMessages(statusTrail)); - // Wait 11000 ms to allow execution complete - await Task.Delay(11000).ConfigureAwait(false); + await Task.Delay(CompletionWaitDelayMilliseconds).ConfigureAwait(false); - Assert.DoesNotContain("starting cancelling command", statusTrail.Select(x => x.Item2)); - Assert.DoesNotContain("finished cancelling command", statusTrail.Select(x => x.Item2)); - Assert.Contains("finished command Normally", statusTrail.Select(x => x.Item2)); - Assert.Equal("Should always come here.", statusTrail[^1].Item2); + Assert.DoesNotContain(StartingCancellingCommand, StatusMessages(statusTrail)); + Assert.DoesNotContain(FinishedCancellingCommand, StatusMessages(statusTrail)); + Assert.Contains(FinishedCommandNormally, StatusMessages(statusTrail)); + Assert.Equal(ShouldAlwaysComeHere, statusTrail.LastMessage); Assert.True(result); - //// (0, "started command") - //// (2, "finished command Normally") - //// (1, "Should always come here.") } /// @@ -314,59 +332,46 @@ await Task.Delay(10000, cts.Token).HandleCancellation(async () => [Test] public async Task SignalFromTask_T_HandlesUserExceptions() { - var statusTrail = new List<(int, string)>(); + var statusTrail = new StatusTrail(); var position = 0; - Exception? exception = null; var fixture = Signal.FromTask( - async (cts) => + async cts => { - statusTrail.Add((position++, "started command")); - await Task.Delay(10000, cts.Token).HandleCancellation(async () => - { - // User Handles cancellation. - statusTrail.Add((position++, "starting cancelling command")); - - // dummy cleanup - await Task.Delay(5000, CancellationToken.None).ConfigureAwait(false); - statusTrail.Add((position++, "finished cancelling command")); - }).ConfigureAwait(true); + RecordStatus(statusTrail, ref position, StartedCommand); + await Task.Delay(CommandDelayMilliseconds, cts.Token) + .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)) + .ConfigureAwait(true); if (!cts.IsCancellationRequested) { - statusTrail.Add((position++, "finished command Normally")); + RecordStatus(statusTrail, ref position, FinishedCommandNormally); } - throw new Exception("break execution"); + throw new InvalidOperationException(BreakExecutionMessage); }).Catch( ex => { - exception = ex; - statusTrail.Add((position++, "Exception Should Be here")); + RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); return Signal.Throw(ex); - }).Finally(() => statusTrail.Add((position++, "Should always come here."))); + }).Finally(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); var result = false; - var cancel = fixture.Subscribe(_ => result = true); - await Task.Delay(500).ConfigureAwait(true); + using var subscription = fixture.Subscribe(_ => result = true); + await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); - Assert.Contains("started command", statusTrail.Select(x => x.Item2)); + Assert.Contains(StartedCommand, StatusMessages(statusTrail)); - await Task.Delay(10000).ConfigureAwait(true); - cancel.Dispose(); + await Task.Delay(CommandDelayMilliseconds).ConfigureAwait(true); + subscription.Dispose(); - // Wait 6000 ms to allow execution and cleanup to complete - await Task.Delay(6000).ConfigureAwait(false); + await Task.Delay(CancellationWaitDelayMilliseconds).ConfigureAwait(false); - Assert.DoesNotContain("starting cancelling command", statusTrail.Select(x => x.Item2)); - Assert.Contains("Should always come here.", statusTrail.Select(x => x.Item2)); - Assert.DoesNotContain("finished cancelling command", statusTrail.Select(x => x.Item2)); - Assert.Contains("Exception Should Be here", statusTrail.Select(x => x.Item2)); - Assert.Contains("finished command Normally", statusTrail.Select(x => x.Item2)); + Assert.DoesNotContain(StartingCancellingCommand, StatusMessages(statusTrail)); + Assert.Contains(ShouldAlwaysComeHere, StatusMessages(statusTrail)); + Assert.DoesNotContain(FinishedCancellingCommand, StatusMessages(statusTrail)); + Assert.Contains(ExceptionShouldBeHere, StatusMessages(statusTrail)); + Assert.Contains(FinishedCommandNormally, StatusMessages(statusTrail)); Assert.False(result); - //// (0, "started command") - //// (1, "finished command Normally") - //// (2, "Exception Should Be here") - //// (3, "Should always come here.") } /// @@ -376,56 +381,43 @@ await Task.Delay(10000, cts.Token).HandleCancellation(async () => [Test] public async Task SignalFromTask_T_HandlesCancellation() { - var statusTrail = new List<(int, string)>(); + var statusTrail = new StatusTrail(); var position = 0; - Exception? exception = null; var fixture = Signal.FromTask( - async (cts) => + async cts => { - statusTrail.Add((position++, "started command")); - await Task.Delay(10000, cts.Token).HandleCancellation(async () => - { - // User Handles cancellation. - statusTrail.Add((position++, "starting cancelling command")); - - // dummy cleanup - await Task.Delay(5000, CancellationToken.None).ConfigureAwait(false); - statusTrail.Add((position++, "finished cancelling command")); - }).ConfigureAwait(true); + RecordStatus(statusTrail, ref position, StartedCommand); + await Task.Delay(CommandDelayMilliseconds, cts.Token) + .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)) + .ConfigureAwait(true); if (!cts.IsCancellationRequested) { - statusTrail.Add((position++, "finished command Normally")); + RecordStatus(statusTrail, ref position, FinishedCommandNormally); } return RxVoid.Default; }).Catch( ex => { - exception = ex; - statusTrail.Add((position++, "Exception Should Be here")); + RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); return Signal.Throw(ex); - }).Finally(() => statusTrail.Add((position++, "Should always come here."))); + }).Finally(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); var result = false; - var cancel = fixture.Subscribe(_ => result = true); - await Task.Delay(500).ConfigureAwait(true); + using var subscription = fixture.Subscribe(_ => result = true); + await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); - Assert.Contains("started command", statusTrail.Select(x => x.Item2)); - cancel.Dispose(); + Assert.Contains(StartedCommand, StatusMessages(statusTrail)); + subscription.Dispose(); - // Wait 6000 ms to allow execution and cleanup to complete - await Task.Delay(6000).ConfigureAwait(false); + await Task.Delay(CancellationWaitDelayMilliseconds).ConfigureAwait(false); - Assert.Contains("starting cancelling command", statusTrail.Select(x => x.Item2)); - Assert.Contains("Should always come here.", statusTrail.Select(x => x.Item2)); - Assert.Contains("finished cancelling command", statusTrail.Select(x => x.Item2)); - Assert.DoesNotContain("finished command Normally", statusTrail.Select(x => x.Item2)); + Assert.Contains(StartingCancellingCommand, StatusMessages(statusTrail)); + Assert.Contains(ShouldAlwaysComeHere, StatusMessages(statusTrail)); + Assert.Contains(FinishedCancellingCommand, StatusMessages(statusTrail)); + Assert.DoesNotContain(FinishedCommandNormally, StatusMessages(statusTrail)); Assert.False(result); - //// (0, "started command") - //// (1, "starting cancelling command") - //// (3, "Should always come here.") - //// (2, "finished cancelling command") } /// @@ -435,62 +427,58 @@ await Task.Delay(10000, cts.Token).HandleCancellation(async () => [Test] public async Task SignalFromTask_T_HandlesTokenCancellation() { - var statusTrail = new List<(int, string)>(); + var statusTrail = new StatusTrail(); + var cleanupCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finallyCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var position = 0; - Exception? exception = null; var fixture = Signal.FromTask( - async (cts) => + async cts => { - statusTrail.Add((position++, "started command")); - await Task.Delay(1000, cts.Token).HandleCancellation(); - _ = Task.Run(async () => - { - // Wait for 1s then cancel - await Task.Delay(1000); - cts.Cancel(); - }); - await Task.Delay(5000, cts.Token).HandleCancellation(async () => - { - // User Handles cancellation. - statusTrail.Add((position++, "starting cancelling command")); - - // dummy cleanup - await Task.Delay(5000, CancellationToken.None).ConfigureAwait(false); - statusTrail.Add((position++, "finished cancelling command")); - }).ConfigureAwait(true); + RecordStatus(statusTrail, ref position, StartedCommand); + await Task.Delay(TokenCancellationDelayMilliseconds, cts.Token).HandleCancellation().ConfigureAwait(true); + + var cancellationTask = CancelAfterDelayAsync(cts); + await Task.Delay(CleanupDelayMilliseconds, cts.Token) + .HandleCancellation(() => + { + RecordCancellationCleanup(statusTrail, ref position); + cleanupCompleted.TrySetResult(); + }) + .ConfigureAwait(true); + await cancellationTask.ConfigureAwait(false); if (!cts.IsCancellationRequested) { - statusTrail.Add((position++, "finished command Normally")); + RecordStatus(statusTrail, ref position, FinishedCommandNormally); } return RxVoid.Default; }).Catch( ex => { - exception = ex; - statusTrail.Add((position++, "Exception Should Be here")); + RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); return Signal.Throw(ex); - }).Finally(() => statusTrail.Add((position++, "Should always come here."))); + }).Finally(() => + { + RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere); + finallyCompleted.TrySetResult(); + }); var result = false; - var cancel = fixture.Subscribe(_ => result = true); - await Task.Delay(500).ConfigureAwait(true); + using var subscription = fixture.Subscribe(_ => result = true); + await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); - Assert.Contains("started command", statusTrail.Select(x => x.Item2)); + Assert.Contains(StartedCommand, StatusMessages(statusTrail)); - // Wait 8000 ms to allow execution and cleanup to complete - await Task.Delay(8000).ConfigureAwait(false); + await WaitForAsync( + Task.WhenAll(cleanupCompleted.Task, finallyCompleted.Task), + CancellationCallbackTimeoutMilliseconds).ConfigureAwait(false); - Assert.Contains("starting cancelling command", statusTrail.Select(x => x.Item2)); - Assert.Contains("Should always come here.", statusTrail.Select(x => x.Item2)); - Assert.Contains("finished cancelling command", statusTrail.Select(x => x.Item2)); - Assert.DoesNotContain("finished command Normally", statusTrail.Select(x => x.Item2)); + Assert.Contains(StartingCancellingCommand, StatusMessages(statusTrail)); + Assert.Contains(ShouldAlwaysComeHere, StatusMessages(statusTrail)); + Assert.Contains(FinishedCancellingCommand, StatusMessages(statusTrail)); + Assert.DoesNotContain(FinishedCommandNormally, StatusMessages(statusTrail)); Assert.False(result); - //// (0, "started command") - //// (1, "starting cancelling command") - //// (2, "Should always come here.") - //// (3, "finished cancelling command") } /// @@ -500,42 +488,35 @@ await Task.Delay(5000, cts.Token).HandleCancellation(async () => [Test] public async Task SignalFromTask_T_HandlesCancellationInBase() { - var statusTrail = new List<(int, string)>(); + var statusTrail = new StatusTrail(); var position = 0; - Exception? exception = null; var fixture = Signal.FromTask( - async (cts) => + async cts => { - var ex = new Exception(); - statusTrail.Add((position++, "started command")); - await Task.Delay(10000, cts.Token).ConfigureAwait(true); + RecordStatus(statusTrail, ref position, StartedCommand); + await Task.Delay(CommandDelayMilliseconds, cts.Token).ConfigureAwait(true); if (!cts.IsCancellationRequested) { - statusTrail.Add((position++, "finished command Normally")); + RecordStatus(statusTrail, ref position, FinishedCommandNormally); } return RxVoid.Default; }).Catch( ex => { - exception = ex; - statusTrail.Add((position++, "Exception Should Be here")); + RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); return Signal.Throw(ex); - }).Finally(() => statusTrail.Add((position++, "Should always come here."))); - - var cancel = fixture.Subscribe(); - await Task.Delay(500).ConfigureAwait(true); - Assert.Contains("started command", statusTrail.Select(x => x.Item2)); - cancel.Dispose(); + }).Finally(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); - // Wait 5050 ms to allow execution and cleanup to complete - await Task.Delay(6000).ConfigureAwait(false); + using var subscription = fixture.Subscribe(); + await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); + Assert.Contains(StartedCommand, StatusMessages(statusTrail)); + subscription.Dispose(); - Assert.DoesNotContain("finished command Normally", statusTrail.Select(x => x.Item2)); - Assert.Equal("Should always come here.", statusTrail[^1].Item2); + await Task.Delay(CancellationWaitDelayMilliseconds).ConfigureAwait(false); - //// (0, "started command") - //// (1, "Should always come here.") + Assert.DoesNotContain(FinishedCommandNormally, StatusMessages(statusTrail)); + Assert.Equal(ShouldAlwaysComeHere, statusTrail.LastMessage); } /// @@ -545,55 +526,159 @@ public async Task SignalFromTask_T_HandlesCancellationInBase() [Test] public async Task SignalFromTask_T_HandlesCompletion() { - var statusTrail = new List<(int, string)>(); + var statusTrail = new StatusTrail(); var position = 0; - Exception? exception = null; var fixture = Signal.FromTask( - async (cts) => + async cts => { - statusTrail.Add((position++, "started command")); - await Task.Delay(10000, cts.Token).HandleCancellation(async () => - { - // NOT EXPECTED TO ENTER HERE - - // User Handles cancellation. - statusTrail.Add((position++, "starting cancelling command")); - - // dummy cleanup - await Task.Delay(5000, CancellationToken.None).ConfigureAwait(false); - statusTrail.Add((position++, "finished cancelling command")); - }).ConfigureAwait(true); + RecordStatus(statusTrail, ref position, StartedCommand); + await Task.Delay(CommandDelayMilliseconds, cts.Token) + .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)) + .ConfigureAwait(true); if (!cts.IsCancellationRequested) { - statusTrail.Add((position++, "finished command Normally")); + RecordStatus(statusTrail, ref position, FinishedCommandNormally); } return RxVoid.Default; }).Catch( ex => { - exception = ex; - statusTrail.Add((position++, "Exception Should Be here")); + RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); return Signal.Throw(ex); - }).Finally(() => statusTrail.Add((position++, "Should always come here."))); + }).Finally(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); var result = false; - var cancel = fixture.Subscribe(_ => result = true); - await Task.Delay(500).ConfigureAwait(true); + using var subscription = fixture.Subscribe(_ => result = true); + await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); - Assert.Contains("started command", statusTrail.Select(x => x.Item2)); + Assert.Contains(StartedCommand, StatusMessages(statusTrail)); - // Wait 11000 ms to allow execution complete - await Task.Delay(11000).ConfigureAwait(false); + await Task.Delay(CompletionWaitDelayMilliseconds).ConfigureAwait(false); - Assert.DoesNotContain("starting cancelling command", statusTrail.Select(x => x.Item2)); - Assert.DoesNotContain("finished cancelling command", statusTrail.Select(x => x.Item2)); - Assert.Contains("finished command Normally", statusTrail.Select(x => x.Item2)); - Assert.Equal("Should always come here.", statusTrail[^1].Item2); + Assert.DoesNotContain(StartingCancellingCommand, StatusMessages(statusTrail)); + Assert.DoesNotContain(FinishedCancellingCommand, StatusMessages(statusTrail)); + Assert.Contains(FinishedCommandNormally, StatusMessages(statusTrail)); + Assert.Equal(ShouldAlwaysComeHere, statusTrail.LastMessage); Assert.True(result); - //// (0, "started command") - //// (2, "finished command Normally") - //// (1, "Should always come here.") + } + + /// + /// Gets the recorded status messages. + /// + /// The status trail. + /// The recorded messages. + private static string[] StatusMessages(StatusTrail statusTrail) => statusTrail.Messages(); + + /// + /// Waits for a timed test callback to complete. + /// + /// The task to await. + /// The timeout in milliseconds. + /// A representing the asynchronous operation. + private static async Task WaitForAsync(Task task, int timeoutMilliseconds) + { + var timeout = Task.Delay(timeoutMilliseconds); + var completed = await Task.WhenAny(task, timeout).ConfigureAwait(false); + if (completed == timeout) + { + throw new TimeoutException($"Timed out after {timeoutMilliseconds}ms waiting for cancellation callbacks."); + } + + await task.ConfigureAwait(false); + } + + /// + /// Records a status message. + /// + /// The status trail. + /// The current status position. + /// The message to record. + private static void RecordStatus(StatusTrail statusTrail, ref int position, string message) => + statusTrail.Add(ref position, message); + + /// + /// Records synchronous cancellation cleanup. + /// + /// The status trail. + /// The current status position. + private static void RecordCancellationCleanup(StatusTrail statusTrail, ref int position) + { + RecordStatus(statusTrail, ref position, StartingCancellingCommand); + Thread.Sleep(CleanupDelayMilliseconds); + RecordStatus(statusTrail, ref position, FinishedCancellingCommand); + } + + /// + /// Cancels the source after the token cancellation delay. + /// + /// The cancellation source. + /// A representing the asynchronous operation. + private static async Task CancelAfterDelayAsync(CancellationTokenSource cts) + { + await Task.Delay(TokenCancellationDelayMilliseconds).ConfigureAwait(false); + await cts.CancelAsync().ConfigureAwait(false); + } + + /// + /// Thread-safe status trail used by async cancellation tests. + /// + private sealed class StatusTrail + { + /// + /// Synchronizes access to the recorded statuses. + /// + private readonly object _gate = new(); + + /// + /// Stores the recorded status positions and messages. + /// + private readonly List<(int Position, string Message)> _items = []; + + /// + /// Gets the last recorded status message. + /// + public string LastMessage + { + get + { + lock (_gate) + { + return _items[^1].Message; + } + } + } + + /// + /// Adds a status message. + /// + /// The current status position. + /// The message. + public void Add(ref int position, string message) + { + lock (_gate) + { + _items.Add((position++, message)); + } + } + + /// + /// Creates a snapshot of the recorded messages. + /// + /// The message snapshot. + public string[] Messages() + { + lock (_gate) + { + var messages = new string[_items.Count]; + for (var i = 0; i < messages.Length; i++) + { + messages[i] = _items[i].Message; + } + + return messages; + } + } } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs index 280dd26..fc6061d 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs @@ -14,6 +14,86 @@ namespace ReactiveUI.Primitives.Tests; /// public class SignalTests { + /// + /// Number of values expected in pair buffers. + /// + private const int PairCount = 2; + + /// + /// Number of values skipped between non-overlapping buffers. + /// + private const int SkipCount = 2; + + /// + /// Test value two. + /// + private const int ValueTwo = 2; + + /// + /// Test value three. + /// + private const int ValueThree = 3; + + /// + /// Test value four. + /// + private const int ValueFour = 4; + + /// + /// Test value five. + /// + private const int ValueFive = 5; + + /// + /// Test value six. + /// + private const int ValueSix = 6; + + /// + /// Test value seven. + /// + private const int ValueSeven = 7; + + /// + /// Test value eight. + /// + private const int ValueEight = 8; + + /// + /// Divisor used by even-value filters. + /// + private const int EvenDivisor = 2; + + /// + /// Multiplier used by select projection tests. + /// + private const int SelectMultiplier = 2; + + /// + /// Expected first pair of buffered values. + /// + private static readonly int[] FirstPair = [1, ValueTwo]; + + /// + /// Expected second pair of buffered values. + /// + private static readonly int[] SecondPair = [ValueThree, ValueFour]; + + /// + /// Expected third pair of buffered values. + /// + private static readonly int[] ThirdPair = [ValueFive, ValueSix]; + + /// + /// Expected single RxVoid notification. + /// + private static readonly RxVoid[] SingleRxVoid = [RxVoid.Default]; + + /// + /// Expected pair of RxVoid notifications. + /// + private static readonly RxVoid[] DoubleRxVoid = [RxVoid.Default, RxVoid.Default]; + /// /// Called when [next]. /// @@ -29,12 +109,12 @@ public void OnNext() Assert.Equal(1, value); subject.OnNext(1); - Assert.Equal(2, value); + Assert.Equal(PairCount, value); subscription.Dispose(); subject.OnNext(1); - Assert.Equal(2, value); + Assert.Equal(PairCount, value); } /// @@ -75,7 +155,7 @@ public void OnCompleted() var subject = new Signal(); var completed = false; - var subscription = subject.Subscribe(_ => { }, () => completed = true); + using var subscription = subject.Subscribe(_ => { }, () => completed = true); subject.OnCompleted(); @@ -90,7 +170,7 @@ public void OnCompleted_NoErrors() { var subject = new Signal(); - var subscription = subject.Subscribe(_ => { }); + using var subscription = subject.Subscribe(_ => { }); subject.OnCompleted(); } @@ -104,7 +184,7 @@ public void OnCompletedOnce() var subject = new Signal(); var completed = 0; - var subscription = subject.Subscribe(_ => { }, () => completed++); + using var subscription = subject.Subscribe(_ => { }, () => completed++); subject.OnCompleted(); @@ -153,9 +233,9 @@ public void OnError() var subject = new Signal(); var error = false; - var subscription = subject.Subscribe(_ => { }, _ => error = true); + using var subscription = subject.Subscribe(_ => { }, _ => error = true); - subject.OnError(new Exception()); + subject.OnError(new InvalidOperationException()); Assert.True(error); } @@ -169,13 +249,13 @@ public void OnErrorOnce() var subject = new Signal(); var errors = 0; - var subscription = subject.Subscribe(_ => { }, _ => errors++); + using var subscription = subject.Subscribe(_ => { }, _ => errors++); - subject.OnError(new Exception()); + subject.OnError(new InvalidOperationException()); Assert.Equal(1, errors); - subject.OnError(new Exception()); + subject.OnError(new InvalidOperationException()); Assert.Equal(1, errors); } @@ -190,7 +270,7 @@ public void OnErrorDisposed() subject.Dispose(); - Assert.Throws(() => subject.OnError(new Exception())); + Assert.Throws(() => subject.OnError(new InvalidOperationException())); } /// @@ -204,7 +284,7 @@ public void OnErrorDisposedSubscriber() subject.Subscribe(_ => { }, _ => error = true).Dispose(); - subject.OnError(new Exception()); + subject.OnError(new InvalidOperationException()); Assert.False(error); } @@ -217,7 +297,7 @@ public void OnErrorRethrowsByDefault() { var subject = new Signal(); - var subs = subject.Subscribe(_ => { }); + using var subscription = subject.Subscribe(_ => { }); Assert.Throws(() => subject.OnError(new ArgumentException())); } @@ -271,7 +351,7 @@ public void SubscribeOnCompleted() public void SubscribeOnError() { var subject = new Signal(); - subject.OnError(new Exception()); + subject.OnError(new InvalidOperationException()); var error = false; subject.Subscribe(_ => { }, _ => error = true); @@ -299,17 +379,17 @@ public void SubscribeActionObservers_DisposeIndependently() Assert.Equal(1, second); firstSubscription.Dispose(); - subject.OnNext(2); + subject.OnNext(ValueTwo); Assert.Equal(1, first); - Assert.Equal(3, second); + Assert.Equal(ValueThree, second); Assert.True(subject.HasObservers); secondSubscription.Dispose(); - subject.OnNext(4); + subject.OnNext(ValueFour); Assert.Equal(1, first); - Assert.Equal(3, second); + Assert.Equal(ValueThree, second); Assert.False(subject.HasObservers); } @@ -320,10 +400,10 @@ public void SubscribeActionObservers_DisposeIndependently() public void SubjectWhere() { var subject = new Signal(); - subject.Where(i => i % 2 == 0).Subscribe(i => Assert.Equal(2, i)); + subject.Where(i => i % EvenDivisor == 0).Subscribe(i => Assert.Equal(ValueTwo, i)); subject.OnNext(1); - subject.OnNext(2); - subject.OnNext(3); + subject.OnNext(ValueTwo); + subject.OnNext(ValueThree); subject.Dispose(); } @@ -334,8 +414,8 @@ public void SubjectWhere() public void SubjectSelect() { var subject = new Signal(); - subject.Select(i => i * 2).Subscribe(i => Assert.Equal(4, i)); - subject.OnNext(2); + subject.Select(i => i * SelectMultiplier).Subscribe(i => Assert.Equal(ValueFour, i)); + subject.OnNext(ValueTwo); subject.Dispose(); } @@ -347,16 +427,16 @@ public void SubjectBuffer() { var subject = new Signal(); var result = new List(); - subject.Buffer(2).Subscribe(i => result = [.. i]); + subject.Buffer(PairCount).Subscribe(i => result = [.. i]); subject.OnNext(1); - subject.OnNext(2); - Assert.Equal(new[] { 1, 2 }, result); - subject.OnNext(3); - subject.OnNext(4); - Assert.Equal(new[] { 3, 4 }, result); - subject.OnNext(5); - subject.OnNext(6); - Assert.Equal(new[] { 5, 6 }, result); + subject.OnNext(ValueTwo); + Assert.Equal(FirstPair, result); + subject.OnNext(ValueThree); + subject.OnNext(ValueFour); + Assert.Equal(SecondPair, result); + subject.OnNext(ValueFive); + subject.OnNext(ValueSix); + Assert.Equal(ThirdPair, result); subject.Dispose(); } @@ -368,19 +448,19 @@ public void SubjectBufferTake2Skip2() { var subject = new Signal(); var result = new List(); - subject.Buffer(2, 2).Subscribe(i => result = [.. i]); + subject.Buffer(PairCount, SkipCount).Subscribe(i => result = [.. i]); subject.OnNext(1); - subject.OnNext(2); - Assert.Equal(new[] { 1, 2 }, result); - subject.OnNext(3); - subject.OnNext(4); - Assert.Equal(new[] { 1, 2 }, result); - subject.OnNext(5); - subject.OnNext(6); - Assert.Equal(new[] { 5, 6 }, result); - subject.OnNext(7); - subject.OnNext(8); - Assert.Equal(new[] { 5, 6 }, result); + subject.OnNext(ValueTwo); + Assert.Equal(FirstPair, result); + subject.OnNext(ValueThree); + subject.OnNext(ValueFour); + Assert.Equal(FirstPair, result); + subject.OnNext(ValueFive); + subject.OnNext(ValueSix); + Assert.Equal(ThirdPair, result); + subject.OnNext(ValueSeven); + subject.OnNext(ValueEight); + Assert.Equal(ThirdPair, result); subject.Dispose(); } @@ -394,9 +474,9 @@ public void SubjectRxVoid() var result = new List(); subject.Subscribe(result.Add); subject.OnNext(RxVoid.Default); - Assert.Equal(new[] { RxVoid.Default }, result); + Assert.Equal(SingleRxVoid, result); subject.OnNext(RxVoid.Default); - Assert.Equal(new[] { RxVoid.Default, RxVoid.Default }, result); + Assert.Equal(DoubleRxVoid, result); subject.Dispose(); } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/StatefulSharingAndBridgeContractTests.cs b/src/tests/ReactiveUI.Primitives.Tests/StatefulSharingAndBridgeContractTests.cs index 5825be1..c92a47b 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/StatefulSharingAndBridgeContractTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/StatefulSharingAndBridgeContractTests.cs @@ -20,12 +20,103 @@ namespace ReactiveUI.Primitives.Tests; +/// +/// Tests stateful signals, sharing helpers, and bridge source generators. +/// public class StatefulSharingAndBridgeContractTests { + /// + /// Initial state value used by projection tests. + /// + private const int InitialStateValue = 10; + + /// + /// Updated state value used by projection tests. + /// + private const int UpdatedStateValue = 11; + + /// + /// First value observed through a shared signal. + /// + private const int FirstSharedValue = 1; + + /// + /// Second value observed through a shared signal. + /// + private const int SecondSharedValue = 2; + + /// + /// Value emitted after shared subscriptions are disposed. + /// + private const int UnobservedSharedValue = 3; + + /// + /// First value observed through replay. + /// + private const int FirstReplayValue = 4; + + /// + /// Second value observed through replay. + /// + private const int SecondReplayValue = 5; + + /// + /// Successful command result. + /// + private const int CommandResult = 42; + + /// + /// Generated System.Reactive bridge type marker. + /// + private const string SystemReactiveBridgeName = "SystemReactiveSignalBridge"; + + /// + /// Generated R3 bridge type marker. + /// + private const string R3BridgeName = "R3SignalBridge"; + + /// + /// Expected mutable state values. + /// + private static readonly int[] ExpectedStateValues = [InitialStateValue, UpdatedStateValue, UpdatedStateValue]; + + /// + /// Expected projected read-only state values. + /// + private static readonly string[] ExpectedReadOnlyValues = ["v:10", "v:11", "v:11"]; + + /// + /// Expected values for the first shared subscription. + /// + private static readonly int[] ExpectedFirstSharedValues = [FirstSharedValue]; + + /// + /// Expected values for the second shared subscription. + /// + private static readonly int[] ExpectedSecondSharedValues = [FirstSharedValue, SecondSharedValue]; + + /// + /// Expected replayed values. + /// + private static readonly int[] ExpectedReplayValues = [FirstReplayValue, SecondReplayValue]; + + /// + /// Expected command results. + /// + private static readonly int[] ExpectedCommandResults = [CommandResult]; + + /// + /// Expected command running-state notifications. + /// + private static readonly bool[] ExpectedRunningValues = [false, true, false]; + + /// + /// Verifies mutable state exposes latest values and read-only projected values. + /// [Test] public void StatefulSignalsExposeLatestValuesAndReadOnlyProjections() { - var state = new StateSignal(10); + var state = new StateSignal(InitialStateValue); var values = new List(); var readonlyValues = new List(); @@ -33,15 +124,18 @@ public void StatefulSignalsExposeLatestValuesAndReadOnlyProjections() using var readOnly = state.ToReadOnlyState(value => $"v:{value}"); readOnly.Changed.Subscribe(readonlyValues.Add); - state.Value = 11; + state.Value = UpdatedStateValue; state.Refresh(); - Assert.Equal(11, state.Value); + Assert.Equal(UpdatedStateValue, state.Value); Assert.Equal("v:11", readOnly.Value); - Assert.Equal(new[] { 10, 11, 11 }, values); - Assert.Equal(new[] { "v:10", "v:11", "v:11" }, readonlyValues); + Assert.Equal(ExpectedStateValues, values); + Assert.Equal(ExpectedReadOnlyValues, readonlyValues); } + /// + /// Verifies shared and replayed connectable signals control source subscriptions. + /// [Test] public void ConnectableShareAndReplayLiveControlSourceSubscriptions() { @@ -59,41 +153,46 @@ public void ConnectableShareAndReplayLiveControlSourceSubscriptions() using var firstSubscription = shared.Subscribe(first.Add); using var secondSubscription = shared.Subscribe(second.Add); - source.OnNext(1); + source.OnNext(FirstSharedValue); firstSubscription.Dispose(); - source.OnNext(2); + source.OnNext(SecondSharedValue); secondSubscription.Dispose(); - source.OnNext(3); + source.OnNext(UnobservedSharedValue); Assert.Equal(1, sourceSubscriptions); - Assert.Equal(new[] { 1 }, first); - Assert.Equal(new[] { 1, 2 }, second); + Assert.Equal(ExpectedFirstSharedValues, first); + Assert.Equal(ExpectedSecondSharedValues, second); var replayed = cold.ReplayLive(1); var replayConnection = replayed.Connect(); var replayFirst = new List(); var replaySecond = new List(); replayed.Subscribe(replayFirst.Add); - source.OnNext(4); + source.OnNext(FirstReplayValue); replayed.Subscribe(replaySecond.Add); - source.OnNext(5); + source.OnNext(SecondReplayValue); replayConnection.Dispose(); - Assert.Equal(new[] { 4, 5 }, replayFirst); - Assert.Equal(new[] { 4, 5 }, replaySecond); + Assert.Equal(ExpectedReplayValues, replayFirst); + Assert.Equal(ExpectedReplayValues, replaySecond); } + /// + /// Verifies command signals publish results, failures, and running state. + /// + /// A task that completes when the command assertions finish. [Test] public async Task CommandSignalPublishesResultsFailuresAndRunningState() { var canRun = new StateSignal(true); var command = new CommandSignal( async token => - { - await Task.Yield(); - token.ThrowIfCancellationRequested(); - return 42; - }, canRun); + { + await Task.Yield(); + token.ThrowIfCancellationRequested(); + return CommandResult; + }, + canRun); var results = new List(); var running = new List(); @@ -102,16 +201,28 @@ public async Task CommandSignalPublishesResultsFailuresAndRunningState() var executed = await command.ExecuteAsync(); canRun.Value = false; - var rejected = Assert.Throws(() => command.ExecuteAsync().GetAwaiter().GetResult()); + InvalidOperationException? rejected = null; + try + { + await command.ExecuteAsync(); + } + catch (InvalidOperationException error) + { + rejected = error; + } - Assert.Equal(42, executed); - Assert.Equal(new[] { 42 }, results); - Assert.Equal(new[] { false, true, false }, running); - Assert.Equal("Command cannot run.", rejected.Message); + Assert.NotNull(rejected); + Assert.Equal(CommandResult, executed); + Assert.Equal(ExpectedCommandResults, results); + Assert.Equal(ExpectedRunningValues, running); + Assert.Equal("Command cannot run.", rejected!.Message); } + /// + /// Verifies bridge generators emit adapters when external shapes are present. + /// [Test] - [RequiresAssemblyFiles()] + [RequiresAssemblyFiles] public void BridgeGeneratorsEmitOnlyWhenExternalShapesArePresentAndCompileSmokeAdapters() { const string source = """ @@ -158,12 +269,15 @@ public static void Use(IObservable source, R3.Observable r3) var (diagnostics, generatedSources) = RunGenerators(source); Assert.Equal(0, diagnostics.Length); - Assert.True(generatedSources.Any(text => text.Contains("SystemReactiveSignalBridge"))); - Assert.True(generatedSources.Any(text => text.Contains("R3SignalBridge"))); + Assert.True(Array.Exists(generatedSources, static text => text.Contains(SystemReactiveBridgeName, StringComparison.Ordinal))); + Assert.True(Array.Exists(generatedSources, static text => text.Contains(R3BridgeName, StringComparison.Ordinal))); } + /// + /// Verifies bridge generators skip adapters when external packages are absent. + /// [Test] - [RequiresAssemblyFiles()] + [RequiresAssemblyFiles] public void BridgeGeneratorsDoNotEmitExternalAdaptersWhenExternalPackagesAreAbsent() { const string source = """ @@ -179,10 +293,15 @@ public static class CoreOnlySmoke var (diagnostics, generatedSources) = RunGenerators(source); Assert.Equal(0, diagnostics.Length); - Assert.False(generatedSources.Any(text => text.Contains("SystemReactiveSignalBridge"))); - Assert.False(generatedSources.Any(text => text.Contains("R3SignalBridge"))); + Assert.False(Array.Exists(generatedSources, static text => text.Contains(SystemReactiveBridgeName, StringComparison.Ordinal))); + Assert.False(Array.Exists(generatedSources, static text => text.Contains(R3BridgeName, StringComparison.Ordinal))); } + /// + /// Runs the bridge source generators for the supplied source. + /// + /// Source code to compile. + /// Compilation diagnostics and generated source text. [RequiresAssemblyFiles("Calls System.Reflection.Assembly.Location")] private static (ImmutableArray Diagnostics, string[] GeneratedSources) RunGenerators(string source) { diff --git a/src/tests/ReactiveUI.Primitives.Tests/TestClasses/EmptySequencer.cs b/src/tests/ReactiveUI.Primitives.Tests/TestClasses/EmptySequencer.cs index 1b1f241..bfdfc37 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/TestClasses/EmptySequencer.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/TestClasses/EmptySequencer.cs @@ -6,18 +6,28 @@ namespace ReactiveUI.Primitives.Concurrency; +/// +/// Provides a sequencer test double that rejects scheduled work. +/// internal sealed class EmptySequencer : ISequencer { - public static readonly EmptySequencer Instance = new(); + /// + /// Gets the shared empty sequencer instance. + /// + public static EmptySequencer Instance { get; } = new(); - public DateTimeOffset Now => DateTimeOffset.MinValue; + /// + public DateTimeOffset Now => DateTimeOffset.UnixEpoch; + /// public IDisposable Schedule(TState state, Func action) => - throw new NotImplementedException(); + throw new NotSupportedException(); + /// public IDisposable Schedule(TState state, TimeSpan dueTime, Func action) => - throw new NotImplementedException(); + throw new NotSupportedException(); + /// public IDisposable Schedule(TState state, DateTimeOffset dueTime, Func action) => - throw new NotImplementedException(); + throw new NotSupportedException(); }